System Design & Architecture
Part 0 of 9Type-Safe APIs Across the Full Stack Without Selling Your Soul
Type-Safe APIs Across the Full Stack Without Selling Your Soul
tRPC, Zod, and shared TypeScript types between Next.js frontend and backend — practical architecture for keeping contracts tight without over-engineering.
The Problem Statement
You're building a Next.js app. You have API routes. You have frontend components that call them. Every time you change an API response, you need to update the types on the frontend. Manually. And hope you didn't miss anything.
// The nightmare scenario
// API route
export async function GET() {
return Response.json({
users: [{ id: 1, name: 'Alice', email: 'alice@example.com' }],
});
}
// Frontend (somewhere else in the codebase)
interface User {
id: number;
name: string;
// Forgot email exists
}
const { users } = await fetch('/api/users').then(r => r.json());
// TypeScript thinks this is fine
// Runtime: users[0].email is undefined... wait, or is it?
The problem isn't just missing types. It's that your API contract exists in two places — the server that implements it and the client that consumes it. They drift. Bugs happen. You ship broken code that TypeScript promised was safe.
This post is about eliminating that drift without building a bureaucracy around it.
The Spectrum of Solutions
COMPLEXITY ──────────────────────────────────────────────────────────►
LOW MEDIUM HIGH
┌─────────────┐ ┌──────────────────┐ ┌──────────────────────┐
│ Shared │ │ Zod + Inferred │ │ tRPC │
│ Types │ │ Types │ │ │
└─────────────┘ └──────────────────┘ └──────────────────────┘
│ │ │
│ │ │
Manual sync Runtime Full type inference
No validation validation + runtime validation
Simple + type inference + client generation
Low overhead Medium overhead Higher learning curve
RECOMMENDATION:
├── Solo / small team, simple APIs → Shared types
├── Need validation, growing team → Zod + inference
└── Complex app, many endpoints → tRPC
Level 1: Shared Types (The Baseline)
The simplest approach: define types once, import everywhere.
Project Structure
src/
├── types/
│ └── api.ts # Shared type definitions
├── app/
│ └── api/
│ └── users/
│ └── route.ts
└── components/
└── UserList.tsx
Implementation
// src/types/api.ts
export interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
export interface CreateUserInput {
name: string;
email: string;
}
export interface ApiResponse<T> {
data: T;
error?: string;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
// src/app/api/users/route.ts
import { User, CreateUserInput, ApiResponse } from '@/types/api';
import { NextRequest } from 'next/server';
export async function GET(): Promise<Response> {
const users: User[] = await db.users.findMany();
const response: ApiResponse<User[]> = { data: users };
return Response.json(response);
}
export async function POST(request: NextRequest): Promise<Response> {
const body: CreateUserInput = await request.json();
// ⚠️ Problem: We're trusting the client sent valid data
// TypeScript says it's CreateUserInput, but runtime could be anything
const user: User = await db.users.create({ data: body });
const response: ApiResponse<User> = { data: user };
return Response.json(response, { status: 201 });
}
// src/lib/api-client.ts
import { User, CreateUserInput, ApiResponse } from '@/types/api';
const BASE_URL = '/api';
export async function getUsers(): Promise<User[]> {
const response = await fetch(`${BASE_URL}/users`);
const json: ApiResponse<User[]> = await response.json();
if (json.error) throw new Error(json.error);
return json.data;
}
export async function createUser(input: CreateUserInput): Promise<User> {
const response = await fetch(`${BASE_URL}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json: ApiResponse<User> = await response.json();
if (json.error) throw new Error(json.error);
return json.data;
}
// src/components/UserList.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getUsers, createUser } from '@/lib/api-client';
export function UserList() {
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: getUsers,
});
// TypeScript knows users is User[] | undefined
// Autocomplete works for user.name, user.email, etc.
return (
<ul>
{users?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Pros and Cons
✓ Simple - just TypeScript, no libraries
✓ Zero runtime overhead
✓ Easy to understand and maintain
✓ Works with any backend, any framework
✗ Types can lie - no runtime validation
✗ Manual sync between type and implementation
✗ Easy to forget to update types
✗ No guarantee API actually returns what type says
When Shared Types Are Enough
✓ Small project with few endpoints
✓ Solo developer or tiny team
✓ APIs are simple CRUD operations
✓ You trust your own code (no external clients)
✓ You're prototyping and will add validation later
Level 2: Zod + Inferred Types (The Sweet Spot)
Zod gives you runtime validation AND TypeScript types from a single source of truth.
The Core Idea
// Define schema once
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
// Infer type from schema
type User = z.infer<typeof UserSchema>;
// Equivalent to: { id: string; name: string; email: string }
// Validate at runtime
const user = UserSchema.parse(untrustedData);
// Throws if invalid, returns typed data if valid
Project Structure
src/
├── schemas/
│ ├── user.ts
│ ├── product.ts
│ └── index.ts
├── app/
│ └── api/
│ └── users/
│ └── route.ts
└── lib/
└── api-client.ts
Schema Definitions
// src/schemas/user.ts
import { z } from 'zod';
// Input schemas (what clients send)
export const CreateUserSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Invalid email address'),
role: z.enum(['user', 'admin']).default('user'),
});
export const UpdateUserSchema = CreateUserSchema.partial();
export const UserIdSchema = z.object({
id: z.string().uuid(),
});
// Response schemas (what API returns)
export const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
role: z.enum(['user', 'admin']),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const UsersResponseSchema = z.object({
users: z.array(UserSchema),
total: z.number(),
});
// Infer types from schemas
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
export type User = z.infer<typeof UserSchema>;
export type UsersResponse = z.infer<typeof UsersResponseSchema>;
API Routes with Validation
// src/app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { CreateUserSchema, UserSchema, UsersResponseSchema } from '@/schemas/user';
import { z } from 'zod';
export async function GET(request: NextRequest) {
const users = await db.users.findMany();
// Validate response before sending (catches schema drift)
const response = UsersResponseSchema.parse({
users,
total: users.length,
});
return NextResponse.json(response);
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate input - throws ZodError if invalid
const validatedInput = CreateUserSchema.parse(body);
const user = await db.users.create({
data: validatedInput,
});
// Validate output
const response = UserSchema.parse(user);
return NextResponse.json(response, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Validation failed',
details: error.errors,
},
{ status: 400 }
);
}
throw error;
}
}
Validation Middleware Pattern
// src/lib/api-utils.ts
import { NextRequest, NextResponse } from 'next/server';
import { z, ZodSchema } from 'zod';
interface ValidatedRequest<TBody, TParams, TQuery> {
body: TBody;
params: TParams;
query: TQuery;
}
interface ValidationSchemas<TBody, TParams, TQuery> {
body?: ZodSchema<TBody>;
params?: ZodSchema<TParams>;
query?: ZodSchema<TQuery>;
}
export function withValidation<
TBody = unknown,
TParams = unknown,
TQuery = unknown,
TResponse = unknown,
>(
schemas: ValidationSchemas<TBody, TParams, TQuery>,
responseSchema: ZodSchema<TResponse>,
handler: (
validated: ValidatedRequest<TBody, TParams, TQuery>,
request: NextRequest
) => Promise<TResponse>
) {
return async (
request: NextRequest,
context: { params: Record<string, string> }
) => {
try {
// Validate body
let body: TBody = undefined as TBody;
if (schemas.body) {
const rawBody = await request.json().catch(() => ({}));
body = schemas.body.parse(rawBody);
}
// Validate params
let params: TParams = undefined as TParams;
if (schemas.params) {
params = schemas.params.parse(context.params);
}
// Validate query
let query: TQuery = undefined as TQuery;
if (schemas.query) {
const searchParams = Object.fromEntries(
request.nextUrl.searchParams.entries()
);
query = schemas.query.parse(searchParams);
}
// Run handler
const result = await handler({ body, params, query }, request);
// Validate response
const validatedResponse = responseSchema.parse(result);
return NextResponse.json(validatedResponse);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.errors },
{ status: 400 }
);
}
throw error;
}
};
}
// src/app/api/users/[id]/route.ts
import { withValidation } from '@/lib/api-utils';
import { UserIdSchema, UpdateUserSchema, UserSchema } from '@/schemas/user';
export const PATCH = withValidation(
{
params: UserIdSchema,
body: UpdateUserSchema,
},
UserSchema,
async ({ params, body }) => {
const user = await db.users.update({
where: { id: params.id },
data: body,
});
return user;
}
);
Type-Safe API Client
// src/lib/api-client.ts
import { z, ZodSchema } from 'zod';
import {
CreateUserSchema,
UserSchema,
UsersResponseSchema,
type CreateUserInput,
type User,
type UsersResponse,
} from '@/schemas/user';
class ApiError extends Error {
constructor(
message: string,
public status: number,
public details?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
async function fetchWithValidation<T>(
url: string,
schema: ZodSchema<T>,
options?: RequestInit
): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(
error.message || 'Request failed',
response.status,
error.details
);
}
const data = await response.json();
// Validate response matches expected schema
return schema.parse(data);
}
export const api = {
users: {
list: (): Promise<UsersResponse> =>
fetchWithValidation('/api/users', UsersResponseSchema),
get: (id: string): Promise<User> =>
fetchWithValidation(`/api/users/${id}`, UserSchema),
create: (input: CreateUserInput): Promise<User> =>
fetchWithValidation('/api/users', UserSchema, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}),
update: (id: string, input: Partial<CreateUserInput>): Promise<User> =>
fetchWithValidation(`/api/users/${id}`, UserSchema, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}),
delete: (id: string): Promise<void> =>
fetch(`/api/users/${id}`, { method: 'DELETE' }).then(() => undefined),
},
};
Using in Components
// src/components/CreateUserForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateUserSchema, type CreateUserInput } from '@/schemas/user';
import { api } from '@/lib/api-client';
export function CreateUserForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateUserInput>({
resolver: zodResolver(CreateUserSchema),
});
const onSubmit = async (data: CreateUserInput) => {
// data is validated by react-hook-form using the same schema
// api.users.create validates the response
// Types match everywhere automatically
const user = await api.users.create(data);
console.log('Created user:', user.id);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} type="email" />
{errors.email && <span>{errors.email.message}</span>}
<button type="submit">Create User</button>
</form>
);
}
Pros and Cons
✓ Single source of truth (schema = type = validation)
✓ Runtime validation catches real bugs
✓ Form validation reuses same schemas
✓ Type inference is automatic
✓ Works with any HTTP client
✓ Schemas are composable and extendable
✗ Slight runtime overhead for validation
✗ Need to remember to validate responses
✗ API client still manually written
✗ No automatic client generation
Level 3: tRPC (The Full Solution)
tRPC eliminates the API boundary entirely. Your frontend calls backend functions directly, with full type inference and no code generation.
The Mental Model
Traditional API:
┌──────────────┐ HTTP/JSON ┌──────────────┐
│ Frontend │ ───────────────▶│ Backend │
│ │ │ │
│ Types: A │ Serialized │ Types: B │
│ │◀─────────────── │ │
└──────────────┘ Hope A === B └──────────────┘
tRPC:
┌──────────────────────────────────────────────┐
│ TypeScript Boundary │
│ │
│ Frontend Backend │
│ ──────── ─────── │
│ trpc.user.get({ id }) ────▶ procedure │
│ │ │ │
│ └─── Same types ──────────┘ │
│ │
└──────────────────────────────────────────────┘
Project Setup
src/
├── server/
│ ├── trpc.ts # tRPC initialization
│ ├── routers/
│ │ ├── user.ts
│ │ ├── product.ts
│ │ └── index.ts
│ └── context.ts
├── app/
│ └── api/
│ └── trpc/
│ └── [trpc]/
│ └── route.ts
├── lib/
│ └── trpc/
│ ├── client.ts
│ └── server.ts
└── components/
Server Setup
// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { ZodError } from 'zod';
import superjson from 'superjson';
import { Context } from './context';
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
// Middleware for authenticated routes
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
...ctx,
user: ctx.session.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);
// src/server/context.ts
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
export async function createContext() {
const session = await getServerSession(authOptions);
return {
session,
db,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;
Router Definition
// src/server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';
export const userRouter = router({
// Public procedure - anyone can call
list: publicProcedure
.input(
z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const { page, limit } = input;
const [users, total] = await Promise.all([
ctx.db.users.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
ctx.db.users.count(),
]);
return {
users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}),
// Get single user
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const user = await ctx.db.users.findUnique({
where: { id: input.id },
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
});
}
return user;
}),
// Protected procedure - requires authentication
create: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['user', 'admin']).default('user'),
})
)
.mutation(async ({ ctx, input }) => {
// Check if email already exists
const existing = await ctx.db.users.findUnique({
where: { email: input.email },
});
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Email already in use',
});
}
return ctx.db.users.create({
data: {
...input,
createdBy: ctx.user.id,
},
});
}),
update: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
data: z.object({
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
role: z.enum(['user', 'admin']).optional(),
}),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.db.users.update({
where: { id: input.id },
data: input.data,
});
}),
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.users.delete({
where: { id: input.id },
});
return { success: true };
}),
});
// src/server/routers/index.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { productRouter } from './product';
export const appRouter = router({
user: userRouter,
product: productRouter,
});
// Export type for client
export type AppRouter = typeof appRouter;
API Route Handler
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
import { createContext } from '@/server/context';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
});
export { handler as GET, handler as POST };
Client Setup
// src/lib/trpc/client.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers';
export const trpc = createTRPCReact<AppRouter>();
// src/lib/trpc/provider.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import superjson from 'superjson';
import { trpc } from './client';
function getBaseUrl() {
if (typeof window !== 'undefined') return '';
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
Server-Side Calls
// src/lib/trpc/server.ts
import 'server-only';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@/server/routers';
import superjson from 'superjson';
import { headers } from 'next/headers';
export const serverTrpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: `${process.env.NEXT_PUBLIC_APP_URL}/api/trpc`,
transformer: superjson,
headers() {
// Forward cookies for auth
const headersList = headers();
return {
cookie: headersList.get('cookie') ?? '',
};
},
}),
],
});
// Or use caller for direct procedure calls (no HTTP)
import { appRouter } from '@/server/routers';
import { createContext } from '@/server/context';
export async function createServerCaller() {
const ctx = await createContext();
return appRouter.createCaller(ctx);
}
Using in Components
// src/components/UserList.tsx
'use client';
import { trpc } from '@/lib/trpc/client';
export function UserList() {
// Full type inference - no manual types needed!
const { data, isLoading, error } = trpc.user.list.useQuery({
page: 1,
limit: 10,
});
const utils = trpc.useUtils();
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.user.list.invalidate();
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<ul>
{data.users.map((user) => (
// TypeScript knows user has id, name, email, etc.
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
<button
onClick={() => {
createUser.mutate({
name: 'New User',
email: 'new@example.com',
});
}}
>
Add User
</button>
</div>
);
}
// src/app/users/[id]/page.tsx (Server Component)
import { createServerCaller } from '@/lib/trpc/server';
export default async function UserPage({ params }: { params: { id: string } }) {
const trpc = await createServerCaller();
// Direct procedure call, no HTTP
const user = await trpc.user.byId({ id: params.id });
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
Optimistic Updates with tRPC
// Full type safety in optimistic updates
const utils = trpc.useUtils();
const updateUser = trpc.user.update.useMutation({
onMutate: async (newData) => {
// Cancel outgoing fetches
await utils.user.byId.cancel({ id: newData.id });
// Snapshot previous value
const previousUser = utils.user.byId.getData({ id: newData.id });
// Optimistically update
utils.user.byId.setData({ id: newData.id }, (old) => {
if (!old) return old;
return { ...old, ...newData.data };
});
return { previousUser };
},
onError: (err, newData, context) => {
// Rollback on error
utils.user.byId.setData(
{ id: newData.id },
context?.previousUser
);
},
onSettled: (data, error, variables) => {
// Refetch after mutation
utils.user.byId.invalidate({ id: variables.id });
},
});
Pros and Cons
✓ End-to-end type safety with zero code generation
✓ Full inference - hover over any call to see types
✓ Integrated with React Query (caching, optimistic updates)
✓ Runtime validation via Zod
✓ Batching multiple requests automatically
✓ Works with Server Components
✓ Great DevX with autocomplete
✗ Learning curve for tRPC concepts
✗ Tightly couples frontend and backend
✗ Not RESTful (harder for external clients)
✗ More setup than simple fetch
✗ Debugging can be harder (abstraction layer)
Comparison Matrix
┌─────────────────────────────────────────────────────────────────────────────┐
│ FEATURE COMPARISON │
├──────────────────────┬──────────────┬───────────────┬───────────────────────┤
│ │ Shared Types │ Zod + Infer │ tRPC │
├──────────────────────┼──────────────┼───────────────┼───────────────────────┤
│ Type Safety │ Compile-time │ Compile + Run │ Compile + Runtime │
│ Runtime Validation │ ✗ │ ✓ │ ✓ │
│ Code Generation │ ✗ │ ✗ │ ✗ (not needed) │
│ Learning Curve │ Low │ Medium │ Medium-High │
│ Setup Complexity │ Minimal │ Low │ Medium │
│ External API Clients │ ✓ │ ✓ │ ✗ (need adapter) │
│ React Query Built-in │ ✗ │ ✗ │ ✓ │
│ Batching │ Manual │ Manual │ Automatic │
│ Error Handling │ Manual │ Semi-auto │ Built-in │
│ Server Components │ ✓ │ ✓ │ ✓ │
│ Bundle Size │ 0 │ ~12KB │ ~20KB │
├──────────────────────┴──────────────┴───────────────┴───────────────────────┤
│ BEST FOR │
├──────────────────────┬──────────────┬───────────────┬───────────────────────┤
│ │ Prototypes │ Growing apps │ Complex full-stack │
│ │ Small apps │ Teams │ Next.js apps │
│ │ Solo devs │ Need REST API │ Internal tools │
└──────────────────────┴──────────────┴───────────────┴───────────────────────┘
Migration Paths
From Shared Types to Zod
// Before: Manual interface
interface User {
id: string;
name: string;
email: string;
}
// After: Zod schema with inferred type
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
// Migration is type-compatible - existing code keeps working
From Zod to tRPC
// Your existing Zod schemas work directly in tRPC
// Before: Manual API route
export async function POST(request: NextRequest) {
const body = CreateUserSchema.parse(await request.json());
const user = await db.users.create({ data: body });
return Response.json(UserSchema.parse(user));
}
// After: tRPC procedure using same schemas
const userRouter = router({
create: protectedProcedure
.input(CreateUserSchema)
.output(UserSchema) // Optional, for extra safety
.mutation(async ({ ctx, input }) => {
return ctx.db.users.create({ data: input });
}),
});
Patterns and Anti-Patterns
Anti-Pattern: Over-Sharing Types
// ❌ Sharing internal types directly
// src/types/api.ts
import { Prisma } from '@prisma/client';
// Exposes database implementation to frontend
export type User = Prisma.UserGetPayload<{}>;
// ✓ Define API contracts explicitly
// src/schemas/user.ts
export const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
// Only expose what the API actually returns
// Internal fields (passwordHash, etc.) are excluded
});
Pattern: API Versioning
// tRPC router versioning
const v1Router = router({
user: userRouterV1,
});
const v2Router = router({
user: userRouterV2, // Breaking changes
});
export const appRouter = router({
v1: v1Router,
v2: v2Router,
});
// Client can use:
// trpc.v1.user.list()
// trpc.v2.user.list()
Pattern: Discriminated Unions for Results
// Better than throwing for expected errors
const UserResultSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('success'),
user: UserSchema,
}),
z.object({
type: z.literal('not_found'),
message: z.string(),
}),
z.object({
type: z.literal('forbidden'),
message: z.string(),
}),
]);
// In tRPC
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }): Promise<z.infer<typeof UserResultSchema>> => {
const user = await ctx.db.users.findUnique({ where: { id: input.id } });
if (!user) {
return { type: 'not_found', message: 'User not found' };
}
return { type: 'success', user };
});
// Client can handle all cases type-safely
const result = trpc.getUser.useQuery({ id: '123' });
if (result.data?.type === 'success') {
console.log(result.data.user.name); // TypeScript knows user exists
}
Pattern: Derived Types
// Create related types from base schema
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
role: z.enum(['user', 'admin']),
createdAt: z.date(),
});
// Derive create input (omit auto-generated fields)
const CreateUserSchema = UserSchema.omit({
id: true,
createdAt: true,
});
// Derive update input (make all fields optional)
const UpdateUserSchema = CreateUserSchema.partial();
// Derive public response (omit sensitive fields)
const PublicUserSchema = UserSchema.omit({
// If you had sensitive fields
});
// Pick specific fields
const UserSummarySchema = UserSchema.pick({
id: true,
name: true,
});
Quick Reference
Decision Tree
Do you need runtime validation?
├── No → Shared Types (simplest)
│
└── Yes → Do you want automatic client generation?
├── No, REST is fine → Zod + manual client
│
└── Yes, or React Query integration → tRPC
The Minimal tRPC Setup
// 1. Server: src/server/trpc.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const router = t.router;
export const procedure = t.procedure;
// 2. Router: src/server/routers/index.ts
import { z } from 'zod';
import { router, procedure } from '../trpc';
export const appRouter = router({
hello: procedure
.input(z.object({ name: z.string() }))
.query(({ input }) => `Hello ${input.name}`),
});
export type AppRouter = typeof appRouter;
// 3. API Route: src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
// 4. Client: src/lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers';
export const trpc = createTRPCReact<AppRouter>();
// 5. Use it
const { data } = trpc.hello.useQuery({ name: 'World' });
Zod Cheat Sheet
// Primitives
z.string()
z.number()
z.boolean()
z.date()
z.undefined()
z.null()
// Strings
z.string().min(1).max(100)
z.string().email()
z.string().url()
z.string().uuid()
z.string().regex(/pattern/)
// Numbers
z.number().int()
z.number().positive()
z.number().min(0).max(100)
// Objects
z.object({ key: z.string() })
z.object({}).passthrough() // Allow extra keys
z.object({}).strict() // No extra keys
// Arrays
z.array(z.string())
z.string().array() // Same thing
// Unions
z.union([z.string(), z.number()])
z.string().or(z.number()) // Same thing
// Optional/Nullable
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
// Transforms
z.string().transform(s => s.toUpperCase())
// Refinements
z.string().refine(s => s.includes('@'), 'Must be email')
// Type inference
type User = z.infer<typeof UserSchema>;
Closing Thoughts
Type-safe APIs aren't about choosing the fanciest tool. They're about making sure the contract between your frontend and backend is explicit, verified, and impossible to accidentally break.
The right choice depends on your context:
- Building a quick prototype? Shared types are fine.
- Growing team, need validation? Zod gives you a lot for little cost.
- Complex app, want the best DX? tRPC eliminates entire categories of bugs.
The worst choice is the one nobody follows. A simple system everyone uses beats a sophisticated system half the team ignores.
Start with what your team will actually adopt. Upgrade when the pain justifies the complexity.
What did you think?