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.
Nextcraft Engineering Team
Typed Route Parameters
Dynamic routes in Next.js have typed params via the auto-generated types in .next/types. Use them:
// 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):
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:
// 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:
// 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:
// 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' };
}
}
// 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):
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:
// 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:
// 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);
// 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.
Continue reading
Related articles
Why Next.js App Router Is Better for SEO Than Pages Router
The App Router isn't just a new file-system convention — it fundamentally changes how search engines crawl and index your Next.js application.
EngineeringServer Components vs Client Components: Making the Right Call
The boundary between Server and Client Components is the most consequential architectural decision you make in a Next.js application. Here's how to draw it correctly.
EngineeringBuilding High-Performance Next.js Applications for Scale
A deep dive into how we utilize App Router and React Server Components to scale our client builds effectively.
Stay Informed.
Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.