// LOADING
// LOADING
// LOADING_ARTICLE
In a TypeScript full-stack app you face a specific problem: your server knows the shape of the data it sends, and your client knows the shape it expects. If those two descriptions ever diverge — because someone updated the API and forgot to update the client type, or because a JSON.stringify/JSON.parse round-trip converts a Date to a string — you get a runtime error that TypeScript could not catch.
The solution is to share types across the boundary. This post shows how to do it cleanly in a Next.js monorepo, starting from a single source of truth.
as SomeType Is Not EnoughThe naive approach is to cast the result of fetch to the type you expect:
tsconst res = await fetch('/api/users/123'); const user = (await res.json()) as User;
This gives you type-checking in the rest of the function, but it is a lie: TypeScript trusts your assertion without verifying it. If the API returns { id: number } and your User type has id: string, the cast succeeds and the bug shows up later.
The goal is a pipeline where the same type definition governs both what the server sends and what the client receives, with runtime validation catching any divergence.
Create a src/shared/schemas directory that both server API routes and client code can import from:
ts// src/shared/schemas/user.ts import { z } from 'zod'; export const UserSchema = z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email(), role: z.enum(['admin', 'editor', 'viewer']), createdAt: z.coerce.date(), }); export type User = z.infer<typeof UserSchema>; export const CreateUserSchema = z.object({ name: z.string().min(1), email: z.string().email(), password: z.string().min(8), }); export type CreateUserInput = z.infer<typeof CreateUserSchema>;
User is derived from the schema. There is no separate interface to maintain. This is the pattern runtime validation with Zod describes for environment variables and form inputs — applied here to API responses.
The API route validates incoming data and serializes the response in a known shape:
ts// src/app/api/users/[id]/route.ts import { NextRequest, NextResponse } from 'next/server'; import { UserSchema } from '@/shared/schemas/user'; import { findUserById } from '@/lib/db/users'; export async function GET( _req: NextRequest, { params }: { params: { id: string } } ): Promise<NextResponse<User | { error: string }>> { const user = await findUserById(params.id); if (!user) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } // Validate what the DB returned before sending it const parsed = UserSchema.parse(user); return NextResponse.json(parsed); }
Calling UserSchema.parse(user) on the database result before serializing serves two purposes: it strips any extra fields that should not leave the server (like a passwordHash), and it throws an error early if the database document has an unexpected shape — something that happens when migrations are incomplete.
On the client, wrap fetch with a function that validates the response against the same schema:
ts// src/lib/api.ts import { z, ZodSchema } from 'zod'; export class ApiError extends Error { constructor(public status: number, message: string) { super(message); this.name = 'ApiError'; } } export async function apiFetch<T>( schema: ZodSchema<T>, input: RequestInfo, init?: RequestInit ): Promise<T> { const res = await fetch(input, init); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new ApiError(res.status, body.error ?? res.statusText); } const json = await res.json(); return schema.parse(json); // throws ZodError if shape is wrong }
Usage:
tsimport { apiFetch } from '@/lib/api'; import { UserSchema, User } from '@/shared/schemas/user'; const user: User = await apiFetch(UserSchema, `/api/users/${id}`); // user.createdAt is a Date object, not a string
Zod's z.coerce.date() in the schema handles the fact that JSON only has strings — the round-trip Date → ISO string → Date happens automatically during parse.
For list endpoints and paginated results, define a reusable envelope using TypeScript generics:
ts// src/shared/schemas/common.ts import { z } from 'zod'; export const paginatedSchema = <T extends z.ZodTypeAny>(itemSchema: T) => z.object({ items: z.array(itemSchema), total: z.number().int(), page: z.number().int(), pageSize: z.number().int(), }); export type Paginated<T> = { items: T[]; total: number; page: number; pageSize: number; };
ts// Usage const UsersListSchema = paginatedSchema(UserSchema); type UsersList = z.infer<typeof UsersListSchema>; const result = await apiFetch(UsersListSchema, '/api/users?page=1'); // result.items is User[]
Some endpoints return different shapes depending on state. Model this with discriminated unions:
tsconst JobResultSchema = z.discriminatedUnion('status', [ z.object({ status: z.literal('pending'), jobId: z.string() }), z.object({ status: z.literal('complete'), jobId: z.string(), result: z.string() }), z.object({ status: z.literal('failed'), jobId: z.string(), error: z.string() }), ]); type JobResult = z.infer<typeof JobResultSchema>; const job = await apiFetch(JobResultSchema, `/api/jobs/${jobId}`); if (job.status === 'complete') { console.log(job.result); // only available here }
The client code is forced to check job.status before accessing variant-specific fields. No undefined property access errors.
The shared schema approach only works if you follow a few discipline rules:
.optional() first for backward compatibility.ZodError from apiFetch during development tells you immediately when server and client are out of sync, before the mismatch reaches production.any in shared code — see why type safety is becoming essential for the broader argument.For reference on structuring the files themselves, how I structure large Next.js + Node.js projects covers where shared schemas fit in the directory layout.
The pattern is straightforward: define a Zod schema in a shared module, derive the TypeScript type from it with z.infer, use schema.parse() on the server before sending and in a typed fetch wrapper on the client after receiving. This eliminates casting, catches serialization edge cases like Date-to-string conversion, and makes API contract violations visible as immediate errors rather than subtle runtime bugs. For practical examples built with this pattern end to end, explore the projects on this site.