GetLaunchpad
Back to blog
6 min read

TypeScript tips every Next.js developer should know

Most TypeScript mistakes in Next.js codebases come down to a handful of patterns: untyped API bodies, missing runtime validation, and over-reliance on any and non-null assertions. Here's how to get it right.

TypeScript makes Next.js codebases dramatically safer — but only if you use it correctly. Most teams start by sprinkling any wherever the type checker complains, then wonder why bugs still make it to production. This guide covers the patterns that actually matter: typing API boundaries, validating at runtime, and avoiding the common mistakes that defeat the whole point of TypeScript.

Typing API route params and request bodies

Next.js App Router route handlers receive a Request object and a params promise. Both need explicit types to catch mistakes at compile time.

// app/api/projects/[id]/route.ts
import { NextResponse } from "next/server";

type RouteContext = {
  params: Promise<{ id: string }>;
};

export async function GET(_req: Request, { params }: RouteContext) {
  const { id } = await params;
  // id is string — safe to use
  return NextResponse.json({ id });
}

export async function PATCH(req: Request, { params }: RouteContext) {
  const { id } = await params;
  const body: unknown = await req.json();
  // Validate body before trusting it (see Zod section below)
}

Never type body as any. Mark it as unknown and validate before use. This forces you to handle malformed input instead of assuming the client sends what you expect.

Zod for runtime validation at API boundaries

TypeScript types vanish at runtime. A user who crafts a raw HTTP request can send anything, regardless of what your TypeScript says. Zod bridges the gap: it validates the shape of incoming data at runtime and infers TypeScript types from the same schema, so you never maintain types and validation separately.

import { z } from "zod";

const UpdateProjectSchema = z.object({
  name: z.string().min(1).max(100),
  status: z.enum(["active", "paused", "archived"]),
});

// Infer the TypeScript type from the schema — no duplication
type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;

export async function PATCH(req: Request, { params }: RouteContext) {
  const { id } = await params;
  const body: unknown = await req.json();

  const result = UpdateProjectSchema.safeParse(body);
  if (!result.success) {
    return NextResponse.json(
      { error: "Invalid input", issues: result.error.issues },
      { status: 400 }
    );
  }

  const { name, status } = result.data; // fully typed
  // proceed safely
}

Use safeParse (not parse) in API routes so you can return a 400 instead of throwing. Reserve parse for internal code where a schema failure is a programming error.

Typing useState correctly

The most common useState mistake is relying on inference when the initial value is null or undefined. Without a type parameter, TypeScript infers the narrowest possible type — and you end up with null when you meant User | null.

// Wrong — infers useState<null>, can never hold a User
const [user, setUser] = useState(null);

// Correct — explicit generic
const [user, setUser] = useState<User | null>(null);

// Wrong — the cast defeats type safety
const [data, setData] = useState(undefined as unknown as ApiResponse);

// Correct — model the loading state properly
const [data, setData] = useState<ApiResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);

When you find yourself writing as unknown as SomeType, that is a signal to reconsider your state model. Usually the right fix is to add null or undefined to the type union rather than casting.

Server vs client component type constraints

Next.js App Router enforces a hard boundary between server and client components, and TypeScript can help you stay on the right side of it.

// Server Component — can be async, can fetch data
// app/dashboard/page.tsx
export default async function DashboardPage() {
  const data = await fetchFromDatabase(); // fine — runs on server
  return <Dashboard initialData={data} />;
}

// Client Component — cannot be async at the component level
// components/Dashboard.tsx
"use client";
import { useState } from "react";

type Props = {
  initialData: ProjectData[]; // must be serializable
};

export function Dashboard({ initialData }: Props) {
  const [projects, setProjects] = useState(initialData);
  // ...
}

Props passed from a server component to a client component must be serializable — no functions, no class instances, no Date objects (use ISO strings instead). If you need a non-serializable value in a client component, move the logic into a client-side hook or API call.

Type-safe environment variables

Accessing process.env.SOME_KEY returns string | undefined. If you forget to set a variable in production, your app crashes at runtime with a cryptic error. Validate environment variables at startup instead.

// lib/env.ts — validate once at module load time
import { z } from "zod";

const EnvSchema = z.object({
  SUPABASE_URL: z.string().url(),
  SUPABASE_ANON_KEY: z.string().min(1),
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  RESEND_API_KEY: z.string().min(1),
});

export const env = EnvSchema.parse(process.env);
// env.SUPABASE_URL is now typed as string, never undefined

Import env from this module instead of accessing process.envdirectly. The parse throws at startup if anything is missing, giving you a clear error message instead of a TypeError: Cannot read properties of undefined buried in a route handler.

Common mistakes to avoid

Using any. Every any is a hole in your type coverage. Prefer unknown for external data (then narrow it), and use proper generics instead of any[] for collections. The only acceptable any is in a narrow utility type where the flexibility is intentional.

Non-null assertion abuse. The !operator tells TypeScript “trust me, this is not null” — but gives you no runtime protection. If the assumption is wrong, you get a runtime crash with no type-level warning. Use optional chaining (?.) or explicit null checks instead. Reserve ! for cases where you have already proven the value is defined (e.g., right after a null check in the same scope).

// Dangerous — crashes if user is undefined
const email = user!.emailAddresses[0]!.emailAddress;

// Safe — handles the absence explicitly
const email = user?.emailAddresses[0]?.emailAddress ?? "";

Missing error types. catch (e) gives you unknown in TypeScript 4+. Do not assume e is an Error — it could be a string, an object, or anything else thrown by a third-party library. Narrow it before accessing .message:

try {
  await riskyOperation();
} catch (e) {
  const message = e instanceof Error ? e.message : "Unknown error";
  console.error(message);
}

All of these patterns — Zod validation, typed env vars, server/client boundaries — are already in place in GetLaunchpad, a Next.js 16 SaaS boilerplate. Get private repo access and start building with a type-safe foundation from day one.

Share this article:Share on X

Ready to ship faster?

GetLaunchpad gives you everything covered in this guide — pre-configured, tested, and production-ready. Skip the setup and focus on your product.

Get the boilerplate →

More articles