All insights
Engineering7 min read

TypeScript Patterns That Make Large Next.js Codebases Maintainable

TypeScript adds correctness guarantees, but only if you use it well. These patterns separate Next.js codebases that scale from ones that become rigid and error-prone.

NC

Nextcraft Engineering Team

Typed Route Parameters

Dynamic routes in Next.js have typed params via the auto-generated types in .next/types. Use them:

code
// app/blog/[slug]/page.tsx
import type { PageProps } from './$types'; // auto-generated

export default async function BlogPost({ params }: PageProps) {
  const { slug } = await params; // typed: { slug: string }
  // ...
}

For manual typing (if you prefer explicit over generated):

code
type Props = {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ page?: string; sort?: string }>;
};

export default async function Page({ params, searchParams }: Props) {
  const { slug } = await params;
  const { page = '1' } = await searchParams;
}

Note: in Next.js 16, params and searchParams are Promises — always await them.

Narrowing API Responses

When fetching from external APIs, validate the response shape rather than asserting it:

code
// Bad — assertion silences TypeScript but doesn't catch runtime failures
const data = await fetch('/api/users').then(r => r.json()) as User[];

// Good — validate and narrow the type at the boundary
import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string(),
  createdAt: z.string().datetime(),
});

const UsersSchema = z.array(UserSchema);

async function getUsers() {
  const raw = await fetch('/api/users').then(r => r.json());
  return UsersSchema.parse(raw); // throws if shape doesn't match
}

Zod gives you runtime validation and TypeScript inference from a single schema. The inferred type is correct because it's derived from the validation, not asserted on top of an untyped value.

Discriminated Unions for State

Discriminated unions model loading/error/success states without optional fields:

code
// Bad — optional fields create ambiguous combinations
type DataState = {
  data?: User;
  error?: string;
  loading: boolean;
};

// Good — each state is unambiguous
type DataState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: string };

function UserProfile({ state }: { state: DataState }) {
  if (state.status === 'loading') return <Spinner />;
  if (state.status === 'error') return <ErrorMessage error={state.error} />;
  if (state.status === 'idle') return null;
  
  // TypeScript knows state.data exists here
  return <Profile user={state.data} />;
}

Typed Server Actions

Server Actions should be typed end to end:

code
// lib/actions/create-project.ts
'use server';

import { z } from 'zod';

const CreateProjectSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().optional(),
});

type ActionResult =
  | { success: true; projectId: string }
  | { success: false; error: string; fieldErrors?: Record<string, string> };

export async function createProject(
  formData: FormData
): Promise<ActionResult> {
  const raw = Object.fromEntries(formData);
  const parsed = CreateProjectSchema.safeParse(raw);
  
  if (!parsed.success) {
    return {
      success: false,
      error: 'Validation failed',
      fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string>,
    };
  }
  
  try {
    const project = await db.projects.create({ data: parsed.data });
    return { success: true, projectId: project.id };
  } catch {
    return { success: false, error: 'Failed to create project' };
  }
}
code
// Client Component consuming the action
'use client';

import { createProject } from '@/lib/actions/create-project';
import { useActionState } from 'react';

export function CreateProjectForm() {
  const [state, action, isPending] = useActionState(createProject, null);
  
  return (
    <form action={action}>
      <input name="name" />
      {state?.success === false && state.fieldErrors?.name && (
        <p className="text-red-500">{state.fieldErrors.name}</p>
      )}
      <button disabled={isPending}>Create</button>
    </form>
  );
}

Branded Types for ID Safety

Prevent mixing up different entity IDs (both are string but semantically different):

code
type UserId = string & { readonly brand: unique symbol };
type ProjectId = string & { readonly brand: unique symbol };

function createUserId(id: string): UserId {
  return id as UserId;
}

function createProjectId(id: string): ProjectId {
  return id as ProjectId;
}

// Now TypeScript prevents accidental mixing:
function getProject(id: ProjectId) { /* ... */ }

const userId = createUserId('user_123');
getProject(userId); // TypeScript error: UserId is not assignable to ProjectId

This pattern eliminates an entire class of bugs where the wrong ID type is passed to a function.

Generic Repository Pattern

If you're accessing a database directly, a generic repository pattern keeps things typed:

code
// lib/db/repository.ts
type FindOptions<T> = {
  where?: Partial<T>;
  limit?: number;
  offset?: number;
  orderBy?: { [K in keyof T]?: 'asc' | 'desc' };
};

class Repository<T extends { id: string }> {
  constructor(private table: string) {}
  
  async findById(id: string): Promise<T | null> {
    return db.query(`SELECT * FROM ${this.table} WHERE id = ?`, [id]);
  }
  
  async find(options: FindOptions<T>): Promise<T[]> {
    // implementation
  }
}

// Usage — fully typed per entity
const users = new Repository<User>('users');
const projects = new Repository<Project>('projects');

const user = await users.findById('123'); // typed as User | null

Type-Safe Environment Variables

Never use process.env.SOME_VAR as string. Validate at startup:

code
// lib/env.ts
import { z } from 'zod';

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  NEXTAUTH_SECRET: z.string().min(32),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  NEXT_PUBLIC_APP_URL: z.string().url(),
});

export const env = EnvSchema.parse(process.env);
code
// Use throughout the codebase
import { env } from '@/lib/env';

const stripe = new Stripe(env.STRIPE_SECRET_KEY); // typed, validated

If a required environment variable is missing, the app crashes at startup with a clear error — not silently at runtime when the feature is first used.

TypeScript in Next.js is most valuable at the boundaries: route params, API responses, form data, environment variables. Tighten those, and the interior of your application becomes significantly easier to reason about.

Stay Informed.

Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.

Insights once a month. No spam. Unsubscribe anytime.