You've got TypeScript everywhere. Strict mode on, noImplicitAny, the linter is happy, the IDE underlines anything that smells. The team writes types for components, for hooks, for utilities, for everything. The whole repo feels like a fortress.

Then someone changes a field on a server response, ships it on a Tuesday, and by Wednesday morning a different team's dashboard is rendering "undefined undefined" in three places. Nothing failed to compile. Nothing failed in CI. The types lied.

This is the part of full-stack TypeScript nobody warns you about. Inside a single function, TypeScript is excellent. Across a function call in the same file, perfect. Across a module boundary, still great. But the moment a value crosses the network, or comes from process.env, or arrives through searchParams, or shows up as a webhook body, TypeScript's guarantees are gone. You're just hoping the shape is what the type says it is.

Next.js makes this harder than a plain Node service does, because it deliberately blurs the server/client boundary. You can call a server action from a button. You can pass props from a server component to a client one. You can read cookies() in some files but not others. The seams are everywhere, and most of them look like normal function calls. A safer Next.js app isn't one with more types, it's one where the seams are explicit and validated.

Let's walk through the patterns that actually move the needle.

Where TypeScript Actually Ends

Before any pattern, it helps to be precise about where the lie starts. There are exactly four places in a Next.js app where TypeScript's static guarantees turn into "trust me, bro":

  1. The network boundary. Anything that came over HTTP, fetch responses, webhook bodies, third-party APIs, is unknown in the honest reading and any in the lazy one. TS only knows what you told it to expect. It has no way to verify.
  2. process.env. Typed as string | undefined, but in real life a missing STRIPE_SECRET_KEY doesn't crash at boot, it crashes the first time a customer tries to check out, in production, on a Friday.
  3. URL inputs. searchParams, dynamic route params, form data. All start their lives as strings (or arrays of strings), even when you've typed them as numbers.
  4. The serialization boundary between server and client components. Props you pass from a server component to a client one go through serialization. Types that look fine in the editor (a Date, a Map, a function) blow up at runtime.

Every pattern below is about putting a wall at one of these four spots and refusing to let untyped data through.

Shared Types Are The Spine, Not The Whole Skeleton

Most Next.js apps grow a types/ directory early. Then it becomes a graveyard.

The mistake is treating "shared types" as a dumping ground for every interface anyone might import. Six months in, the file has 400 lines, half of them are stale, and nobody knows whether User from types/user.ts is the database row, the API response, or the form payload, because all three have ended up there.

Shared types should answer one question: what does a thing look like when it's traveling between server and client? That's it. If a type is server-only (a Prisma row, a service-internal DTO), it doesn't go in shared. If it's client-only (a UI state machine), it doesn't go in shared either. Shared is the middle.

A practical shape:

TypeScript types/api.ts
// What the wire actually carries. Not the DB row. Not the form.
export type UserDTO = {
  id: string;
  email: string;
  displayName: string | null;
  createdAt: string; // ISO — never `Date`, see serialization section
};

export type PaginatedResponse<T> = {
  items: T[];
  nextCursor: string | null;
};

Then the server has its own type for the DB row, and the client has its own type for the form state, and both map to and from UserDTO at their respective edges. Three types instead of one. That feels redundant, until the day the DB grows a lastSeenAt column and you don't want every form on the site to know about it.

The rule that keeps shared types honest: if you're tempted to put a method on it, it doesn't belong in shared. Methods are behavior, behavior runs somewhere specific. Shared types are inert shapes.

The server-only Package: A Wall You Can Actually Enforce

You've probably written this bug. A utility module imports a service that reads from your database. A client component imports a helper from that utility module, maybe for a string formatter that's also in there. The bundler follows the import graph. Suddenly your DB connection string, or worse, your service-role key, is in the browser bundle.

Next.js ships a special escape hatch for this: the server-only package. You install it, then import it at the top of any module that must never end up in client code:

TypeScript lib/db.ts
import "server-only";
import { drizzle } from "drizzle-orm/postgres-js";
// ...

If a client component (or anything reachable from a client component) ever imports this module, directly or transitively, the build fails. The error is loud and points at the import chain. This is the cheapest, most effective safety net in the whole Next.js toolbox, and an alarming number of codebases don't use it.

The mirror image is client-only, which guards modules that depend on browser APIs (window, IntersectionObserver, anything from react-dom's client entry). Those failures usually surface eventually, ReferenceError: window is not defined at build time, but client-only makes the error happen at the import site, which is where you actually want to look.

A small playbook:

  • Every file in lib/server/ or app/api/ that touches a secret, a DB, or a third-party server SDK: add import "server-only" at the top.
  • Every file that calls window, document, or anything from @react-three/fiber or similar: add import "client-only".
  • Shared utilities (pure functions, formatters, type guards) get neither, they can run on both sides, and the bundler will figure it out.

That's the wall. Three lines of work, gigantic blast radius if you skip it.

Three-column architecture diagram for a Next.js + TypeScript app: a Server Only column listing db.ts, services/*, stripe.ts, env.server.ts under an &#39;import server-only&#39; lock; a Shared column listing types/api.ts, validation/schemas.ts, formatters/* with &#39;no side effects&#39;; a Client Only column with hooks and components that touch window or document under an &#39;import client-only&#39; tag. Arrows show that Server Only and Client Only can both import Shared, and a red X blocks Server Only from importing Client Only directly.

Validation At The Edge, Not In The Middle

Here is the single most important pattern in the whole article, so it gets its own section.

Every value entering your app from outside, request body, query string, dynamic route params, webhook payload, third-party API response, env var, must be validated against a schema before it touches any code that assumes a type. Not in the middle of your business logic. Not in a controller helper. At the edge. The moment the data arrives.

The tool of choice in the TypeScript world is zod, and the reason is simple: a zod schema is a TypeScript type. You write the schema once, and z.infer<typeof schema> gives you the static type for free. No drift between runtime check and compile-time shape, because they're the same artifact.

A boring example:

TypeScript lib/validation/user.ts
import { z } from "zod";

export const CreateUserInput = z.object({
  email: z.string().email(),
  displayName: z.string().min(1).max(120).nullable(),
  acceptedTerms: z.literal(true), // not "boolean" — must be exactly true
});

export type CreateUserInput = z.infer<typeof CreateUserInput>;

The shape is the validation, and the validation is the type. If you later decide displayName must be at most 80 chars, you change it in one place and TS plus runtime both pick it up.

Where to apply it:

TypeScript app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { CreateUserInput } from "@/lib/validation/user";
import { createUser } from "@/lib/server/users";
import "server-only";

export async function POST(req: NextRequest) {
  const json = await req.json().catch(() => null);
  const parsed = CreateUserInput.safeParse(json);
  if (!parsed.success) {
    return NextResponse.json(
      { error: "Invalid input", issues: parsed.error.issues },
      { status: 400 },
    );
  }

  // From this line on, `parsed.data` is fully typed as CreateUserInput.
  // Everything downstream can trust it.
  const user = await createUser(parsed.data);
  return NextResponse.json(user, { status: 201 });
}

A few things to notice.

The .catch(() => null) matters. req.json() throws on malformed JSON, and an unhandled throw in a route handler turns into a 500 instead of the 400 it should be. Catching it and letting the schema produce the proper error gives clients a consistent response shape.

safeParse instead of parse. The latter throws; the former returns a discriminated union. The route handler should never throw in normal control flow, failed validation is a normal case, not an exceptional one.

The downstream createUser function takes a CreateUserInput, not an unknown. The wall is at the route handler. Once data crosses it, the rest of the server can lean on the type and not redo the check.

The same pattern applies to:

  • Webhooks, every webhook body gets a schema, even if it's "trusted." Stripe, GitHub, Clerk, your own webhook from a sister service. Schemas first, logic second.
  • Third-party API responses, when you fetch an external API, parse the JSON through a schema before using it. Yes, even when the docs say the shape is fixed. APIs change.
  • searchParams and params, both arrive as strings. If your page expects ?page=2, that 2 is a string, and parseInt is not validation.
TSX app/products/page.tsx
import { z } from "zod";

const ProductSearchParams = z.object({
  page: z.coerce.number().int().min(1).default(1),
  sort: z.enum(["price", "name", "rating"]).default("price"),
  q: z.string().trim().min(0).max(200).optional(),
});

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const raw = await searchParams;
  const parsed = ProductSearchParams.safeParse(raw);
  if (!parsed.success) return <BadRequest issues={parsed.error.issues} />;

  const { page, sort, q } = parsed.data;
  // Fully typed and bounded from here on.
  // ...
}

z.coerce.number() turns the incoming string into a number and validates it in one step. .default(1) means a missing param doesn't crash the page, it gets a sensible value. .enum(...) rejects unexpected sort options that an attacker (or a stale bookmark) might send.

This pattern, repeated at every entry point, is what actually makes the app safer. Not "more types." Not "stricter tsconfig." A wall at every edge.

Typed Route Handlers Are Half The Story

Route handlers in the App Router (route.ts files) have a deceptively typed surface. NextRequest and NextResponse look like complete contracts. They're not, they're the transport, not the contract.

A route handler's real contract is three things:

  1. The input schema, what request body and params it accepts.
  2. The output schema, what response body it returns on success.
  3. The error shape, what response body it returns on each kind of failure.

If you only type the input, you've done half the work. Clients still don't know what to expect back, and TypeScript on the client side will infer any from a fetch().then(r => r.json()).

The pattern that scales is putting all three in one module and re-exporting the schemas so the client can use them too:

TypeScript app/api/users/contracts.ts
import { z } from "zod";

export const CreateUserInput = z.object({
  email: z.string().email(),
  displayName: z.string().min(1).max(120).nullable(),
  acceptedTerms: z.literal(true),
});

export const UserResponse = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  displayName: z.string().nullable(),
  createdAt: z.string().datetime(),
});

export const ApiError = z.object({
  error: z.string(),
  issues: z.array(z.unknown()).optional(),
});

export type CreateUserInput = z.infer<typeof CreateUserInput>;
export type UserResponse = z.infer<typeof UserResponse>;
export type ApiError = z.infer<typeof ApiError>;

The route handler imports these and uses them on both sides:

TypeScript app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { CreateUserInput, UserResponse, ApiError } from "./contracts";
import { createUser } from "@/lib/server/users";
import "server-only";

export async function POST(req: NextRequest) {
  const json = await req.json().catch(() => null);
  const parsed = CreateUserInput.safeParse(json);
  if (!parsed.success) {
    const body: ApiError = {
      error: "Invalid input",
      issues: parsed.error.issues,
    };
    return NextResponse.json(body, { status: 400 });
  }

  const user = await createUser(parsed.data);
  const body: UserResponse = {
    id: user.id,
    email: user.email,
    displayName: user.displayName,
    createdAt: user.createdAt.toISOString(),
  };
  return NextResponse.json(body, { status: 201 });
}

The explicit : UserResponse and : ApiError annotations look like noise, TS would infer them. The reason they're worth typing is that they catch the case where the handler accidentally returns a slightly-different shape (an extra debugging field, a missing one, a Date instead of an ISO string). Without the annotation, the handler happily ships the wrong shape and only the client breaks.

The client, meanwhile, gets to use the same schemas to parse the response:

TypeScript lib/client/api.ts
import { CreateUserInput, UserResponse, ApiError } from "@/app/api/users/contracts";

export async function createUser(input: CreateUserInput): Promise<UserResponse> {
  const res = await fetch("/api/users", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(input),
  });

  const json = await res.json().catch(() => null);
  if (!res.ok) {
    const err = ApiError.safeParse(json);
    throw new Error(err.success ? err.data.error : "Unknown error");
  }
  return UserResponse.parse(json);
}

Note the validation on the client side too. The server is supposed to return UserResponse, but "supposed to" is not a guarantee, somebody could deploy a broken build, an intermediate proxy could mangle the body, a future engineer could change the route handler without updating the contract. Re-parsing on the client costs microseconds and catches all three.

This is the closest thing you get to end-to-end type safety without a framework like tRPC. The schemas are the contract; both sides use them; drift becomes a build error or a runtime parse error, never silent garbage.

Server Actions Are Typed, But They're Still A Network Call

This one bites people. Server actions look like local function calls:

TSX components/CreateUserForm.tsx
"use client";
import { createUserAction } from "@/app/actions/users";

export function CreateUserForm() {
  return (
    <form action={createUserAction}>
      <input name="email" />
      <input name="displayName" />
      <button type="submit">Create</button>
    </form>
  );
}

And on the server:

TypeScript app/actions/users.ts
"use server";
import { CreateUserInput } from "@/lib/validation/user";

export async function createUserAction(formData: FormData) {
  // formData is FormData, not CreateUserInput. Don't be fooled.
  // ...
}

The type system on the call site sees createUserAction: (formData: FormData) => Promise<unknown>. That looks typed. It is not protective. The action received a FormData object that was serialized across the wire, the same way as a fetch, and Next.js handed it to your function. Whatever the client sent is what's there. The fact that the import is "local" is a lie of convenience.

The pattern that fixes this is exactly the same as for route handlers: validate at the edge.

TypeScript app/actions/users.ts
"use server";
import { CreateUserInput } from "@/lib/validation/user";
import "server-only";

type ActionResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string; issues?: unknown[] };

export async function createUserAction(
  formData: FormData,
): Promise<ActionResult<{ id: string }>> {
  const raw = {
    email: formData.get("email"),
    displayName: formData.get("displayName"),
    acceptedTerms: formData.get("acceptedTerms") === "on",
  };
  const parsed = CreateUserInput.safeParse(raw);
  if (!parsed.success) {
    return { ok: false, error: "Invalid input", issues: parsed.error.issues };
  }

  const user = await createUser(parsed.data);
  return { ok: true, data: { id: user.id } };
}

Two things worth pointing out.

The ActionResult<T> discriminated union is the actions equivalent of an API error shape. Throwing from a server action used to render the nearest error boundary in Next.js, which is almost never what a form wants, you want to keep the user on the page with their data and show inline errors. A typed result lets you do that without try/catch everywhere.

The formData.get(...) calls return FormDataEntryValue | null, which is string | File | null. The schema parses it into the right TypeScript shape. Without the schema, you're one stray as string cast away from a runtime crash on a missing form field.

The Serialization Boundary Is Strict About More Than You Think

When a server component passes props to a client component, those props are serialized, turned into a string, sent across the boundary, deserialized on the client. The set of values that survive this trip is narrower than what TypeScript thinks is "a plain object."

What works: plain JSON values (strings, numbers, booleans, null, arrays of these, objects of these). Promises (for the async streaming use case). Server actions (passed as opaque references).

What does not work, and what TypeScript will happily let you write anyway:

  • Date objects. They become invalid ({}) on the client. Always pass dates as ISO strings; use new Date(str) on the client side if you need a Date.
  • Map, Set, BigInt. Not serializable. Convert before passing.
  • Functions (except server actions). Cannot be serialized.
  • undefined inside an object key. Becomes null on the other side. If you typed it as optional, the client may receive a null where the type said it could be undefined.
  • Class instances. They lose their methods on arrival; the client gets a plain object with the data but no behavior.
  • Circular references. Throw on serialization.

The cheapest defense is to type your client-component props as the serialized shape, not as the rich server shape. If your server has a User with a createdAt: Date, the client component takes a { createdAt: string } and the server component does the conversion at the call site:

TSX app/users/[id]/page.tsx
import { UserCard } from "./UserCard";

export default async function UserPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const user = await getUser(id); // user.createdAt is Date

  return (
    <UserCard
      id={user.id}
      email={user.email}
      displayName={user.displayName}
      createdAt={user.createdAt.toISOString()}
    />
  );
}
TSX app/users/[id]/UserCard.tsx
"use client";

type Props = {
  id: string;
  email: string;
  displayName: string | null;
  createdAt: string; // ISO — converted on the server
};

export function UserCard(props: Props) {
  const joined = new Date(props.createdAt).toLocaleDateString();
  return <div>{props.email} — joined {joined}</div>;
}

This is more verbose than <UserCard user={user} />. The verbosity is the point, every conversion happens at one spot, you can see what's being sent, and a TypeScript error catches you if you ever try to pass a Date.

If you do this enough, it's worth a small utility:

TypeScript lib/serialize/user.ts
import type { User } from "@/lib/server/users";
import type { UserDTO } from "@/types/api";

export function serializeUser(u: User): UserDTO {
  return {
    id: u.id,
    email: u.email,
    displayName: u.displayName,
    createdAt: u.createdAt.toISOString(),
  };
}

Now the boundary has a name. serializeUser is the only place where the wire shape is defined, and the server component just calls it.

Comparison diagram of what survives the Next.js server-to-client serialization boundary: plain strings, numbers, and plain objects pass through with green checks; Date objects become invalid empty objects, Maps become empty objects, BigInt throws, Class instances lose their methods, functions cannot be serialized, and undefined values become null on the client.

Env Vars: Validate Once, At Boot

The other place a Next.js app silently lies to itself is process.env. TypeScript types every env var as string | undefined, which is technically correct and operationally useless, half your code has process.env.STRIPE_SECRET_KEY! with a non-null assertion because the developer "knows" it's set, until the day it isn't.

The pattern that ends this argument: validate the entire env at module load, fail loudly if anything is missing or malformed, and import the validated object instead of touching process.env directly anywhere else.

TypeScript lib/env.server.ts
import { z } from "zod";
import "server-only";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
  REDIS_URL: z.string().url().optional(),
});

const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
  console.error("Invalid environment configuration:", parsed.error.flatten());
  throw new Error("Missing or invalid environment variables");
}

export const env = parsed.data;
export type Env = z.infer<typeof EnvSchema>;

Three things this buys you.

First, boot-time failure. If you forget a var, the app crashes on the first import, usually during the build, which means you find out in CI, not at 3am from a customer. Compare that to the silent undefined that flows through your code until the moment Stripe gets Bearer undefined and returns 401.

Second, shape, not just presence. STRIPE_SECRET_KEY isn't just "a string", it starts with sk_. A DATABASE_URL is a URL, not a string with a typo. The schema enforces this.

Third, a single import. Anywhere in the server code, you write import { env } from "@/lib/env.server" and you get a fully typed, fully validated object. No more process.env.SOMETHING! sprinkled around.

For client-side env vars (the ones prefixed NEXT_PUBLIC_), make a separate file:

TypeScript lib/env.client.ts
import { z } from "zod";

const ClientEnvSchema = z.object({
  NEXT_PUBLIC_APP_URL: z.string().url(),
  NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
});

// process.env access for NEXT_PUBLIC_* vars is inlined by the bundler.
const parsed = ClientEnvSchema.safeParse({
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
});

if (!parsed.success) {
  throw new Error("Invalid client environment configuration");
}

export const clientEnv = parsed.data;

The reason for the explicit object literal here, instead of passing process.env: Next.js statically replaces process.env.NEXT_PUBLIC_* at build time with the literal values. It cannot do that if you pass process.env whole, it needs to see the property access syntactically. The shape above is what survives the bundler's transform.

A Worked Example: One Slice, End To End

Putting the pieces together. Here is a single feature, "let a signed-in user update their display name", with every boundary covered.

TypeScript app/account/contracts.ts
import { z } from "zod";

export const UpdateDisplayNameInput = z.object({
  displayName: z.string().trim().min(1).max(120),
});

export const UpdateDisplayNameResult = z.object({
  ok: z.literal(true),
  displayName: z.string(),
});

export type UpdateDisplayNameInput = z.infer<typeof UpdateDisplayNameInput>;
export type UpdateDisplayNameResult = z.infer<typeof UpdateDisplayNameResult>;
TypeScript app/account/actions.ts
"use server";
import "server-only";
import { revalidatePath } from "next/cache";
import { UpdateDisplayNameInput } from "./contracts";
import { getSession } from "@/lib/server/auth";
import { updateUser } from "@/lib/server/users";

type Result =
  | { ok: true; displayName: string }
  | { ok: false; error: string; issues?: unknown[] };

export async function updateDisplayNameAction(formData: FormData): Promise<Result> {
  const session = await getSession();
  if (!session) return { ok: false, error: "Not signed in" };

  const parsed = UpdateDisplayNameInput.safeParse({
    displayName: formData.get("displayName"),
  });
  if (!parsed.success) {
    return { ok: false, error: "Invalid input", issues: parsed.error.issues };
  }

  const updated = await updateUser(session.userId, {
    displayName: parsed.data.displayName,
  });
  revalidatePath("/account");
  return { ok: true, displayName: updated.displayName };
}
TSX app/account/DisplayNameForm.tsx
"use client";
import { useActionState } from "react";
import { updateDisplayNameAction } from "./actions";

type State =
  | { ok: true; displayName: string }
  | { ok: false; error: string; issues?: unknown[] }
  | null;

export function DisplayNameForm({ initial }: { initial: string }) {
  const [state, action, pending] = useActionState<State, FormData>(
    async (_prev, formData) => updateDisplayNameAction(formData),
    null,
  );

  return (
    <form action={action}>
      <label>
        Display name
        <input name="displayName" defaultValue={initial} disabled={pending} />
      </label>
      <button type="submit" disabled={pending}>
        {pending ? "Saving..." : "Save"}
      </button>
      {state?.ok === false && <p role="alert">{state.error}</p>}
      {state?.ok === true && <p>Saved.</p>}
    </form>
  );
}
TSX app/account/page.tsx
import { getSession } from "@/lib/server/auth";
import { getUser } from "@/lib/server/users";
import { DisplayNameForm } from "./DisplayNameForm";
import "server-only";

export default async function AccountPage() {
  const session = await getSession();
  if (!session) {
    return <p>Please sign in.</p>;
  }
  const user = await getUser(session.userId);
  return (
    <main>
      <h1>Account</h1>
      <DisplayNameForm initial={user.displayName ?? ""} />
    </main>
  );
}

Count the safety checkpoints:

  1. import "server-only" in the action and the page, neither can ever leak into the client bundle.
  2. Session check before any work, server actions are public endpoints and need auth.
  3. Schema validation of the form input, z.string().trim().min(1).max(120) rejects empty names, names that are only whitespace, and 50,000-character names.
  4. Discriminated Result type, the client knows there are exactly two outcomes and can render each.
  5. Serialization-safe props, the page passes a plain string to the client component, not a User object with a Date on it.
  6. No process.env at the call site, anything env-driven lives in lib/env.server.ts.

This is what "safer Next.js + TypeScript" actually looks like in code. None of it is exotic. Every piece is one of three patterns: validate at the edge, mark the boundary explicitly, type the wire as the wire.

The Cost, And Why It's Worth It

If you read all of that and thought "this is a lot of boilerplate", you're right. Compared to a vanilla setup, every feature gets one extra file (the contracts), one extra function (the schema), and a handful of explicit type annotations.

The trade is real, and it pays back the first time someone changes a field name and the build catches them instead of a customer. The patterns above are what separate a Next.js app that's "TypeScript on a good day" from one that's actually type-safe under change. The seams are where bugs live, and the seams in a Next.js app are everywhere, the network, the server/client boundary, the env, the URL. You can't shrink the number of seams, but you can refuse to let untyped data cross any of them.

That's the whole game. Walls at the edges, types on the wire, schemas you own.