Building Type-Safe APIs with Next.js and TypeScript
Learn how to build robust, type-safe APIs using Next.js App Router, TypeScript, and modern validation libraries for bulletproof backend development
Alex Rodriguez
@alexrodriguezdevBuilding Type-Safe APIs with Next.js and TypeScript
In the world of modern web development, APIs are the backbone of almost every application. Yet too often, we treat them as an afterthought, leading to runtime errors, inconsistent data structures, and frustrated developers. Today, we'll explore how to build truly type-safe APIs that catch errors before they reach production.
The Problem with Traditional APIs
Traditional REST APIs often suffer from several issues:
- Runtime Type Errors: No guarantee that the data matches expected types
- Inconsistent Responses: Different endpoints returning different structures
- Poor Developer Experience: Manual typing and error-prone integrations
- Documentation Drift: API docs becoming outdated quickly
Let's solve these problems with a type-first approach.
Setting Up the Foundation
Project Structure
First, let's establish a clean structure for our API endpoints:
src/
├── app/
│ └── api/
│ ├── users/
│ │ ├── route.ts
│ │ └── [id]/
│ │ └── route.ts
│ └── posts/
│ └── route.ts
├── lib/
│ ├── api/
│ │ ├── types.ts
│ │ ├── validation.ts
│ │ └── responses.ts
│ └── db/
└── schemas/
Essential Dependencies
{
"dependencies": {
"zod": "^3.22.4",
"superjson": "^2.2.1"
},
"devDependencies": {
"@types/node": "^20.10.0"
}
}
Building Type-Safe Schemas
Defining Your Data Models
Start by defining your data models with Zod schemas:
// src/schemas/user.ts
import { z } from "zod";
export const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1, "Name is required"),
avatar: z.string().url().optional(),
role: z.enum(["USER", "ADMIN", "MODERATOR"]),
preferences: z.object({
theme: z.enum(["light", "dark", "system"]),
notifications: z.boolean(),
language: z.string().length(2),
}),
createdAt: z.date(),
updatedAt: z.date(),
});
export const createUserSchema = userSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const updateUserSchema = userSchema
.omit({
id: true,
createdAt: true,
updatedAt: true,
})
.partial();
// Infer TypeScript types from schemas
export type User = z.infer<typeof userSchema>;
export type CreateUserData = z.infer<typeof createUserSchema>;
export type UpdateUserData = z.infer<typeof updateUserSchema>;
API Response Schemas
Create consistent response structures:
// src/lib/api/responses.ts
import { z } from "zod";
export const successResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.object({
success: z.literal(true),
data: dataSchema,
meta: z
.object({
timestamp: z.string(),
requestId: z.string(),
})
.optional(),
});
export const errorResponseSchema = z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
details: z.record(z.any()).optional(),
}),
meta: z
.object({
timestamp: z.string(),
requestId: z.string(),
})
.optional(),
});
export const paginatedResponseSchema = <T extends z.ZodTypeAny>(
itemSchema: T,
) =>
z.object({
success: z.literal(true),
data: z.array(itemSchema),
pagination: z.object({
page: z.number(),
limit: z.number(),
total: z.number(),
totalPages: z.number(),
hasNext: z.boolean(),
hasPrev: z.boolean(),
}),
});
// Type helpers
export type SuccessResponse<T> = {
success: true;
data: T;
meta?: {
timestamp: string;
requestId: string;
};
};
export type ErrorResponse = z.infer<typeof errorResponseSchema>;
export type PaginatedResponse<T> = {
success: true;
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
};
Creating Type-Safe API Routes
Building a Robust API Handler
Create a wrapper that handles validation, errors, and responses:
// src/lib/api/handler.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { v4 as uuidv4 } from "uuid";
interface ApiConfig<TBody = any, TQuery = any> {
bodySchema?: z.ZodSchema<TBody>;
querySchema?: z.ZodSchema<TQuery>;
requireAuth?: boolean;
}
export function createApiHandler<TBody = any, TQuery = any>(
config: ApiConfig<TBody, TQuery> = {},
) {
return function <TResponse>(
handler: (params: {
body: TBody;
query: TQuery;
params: Record<string, string>;
userId?: string;
request: NextRequest;
}) => Promise<TResponse>,
) {
return async function (
request: NextRequest,
context: { params: Record<string, string> },
) {
const requestId = uuidv4();
const timestamp = new Date().toISOString();
try {
// Parse and validate query parameters
const url = new URL(request.url);
const queryParams = Object.fromEntries(url.searchParams.entries());
let validatedQuery = queryParams as TQuery;
if (config.querySchema) {
const queryResult = config.querySchema.safeParse(queryParams);
if (!queryResult.success) {
return NextResponse.json(
{
success: false,
error: {
code: "VALIDATION_ERROR",
message: "Invalid query parameters",
details: queryResult.error.flatten(),
},
meta: { timestamp, requestId },
} satisfies ErrorResponse,
{ status: 400 },
);
}
validatedQuery = queryResult.data;
}
// Parse and validate request body
let validatedBody = undefined as TBody;
if (config.bodySchema && request.method !== "GET") {
try {
const rawBody = await request.json();
const bodyResult = config.bodySchema.safeParse(rawBody);
if (!bodyResult.success) {
return NextResponse.json(
{
success: false,
error: {
code: "VALIDATION_ERROR",
message: "Invalid request body",
details: bodyResult.error.flatten(),
},
meta: { timestamp, requestId },
} satisfies ErrorResponse,
{ status: 400 },
);
}
validatedBody = bodyResult.data;
} catch (error) {
return NextResponse.json(
{
success: false,
error: {
code: "INVALID_JSON",
message: "Request body must be valid JSON",
},
meta: { timestamp, requestId },
} satisfies ErrorResponse,
{ status: 400 },
);
}
}
// Authentication check (simplified)
let userId: string | undefined;
if (config.requireAuth) {
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return NextResponse.json(
{
success: false,
error: {
code: "UNAUTHORIZED",
message: "Authentication required",
},
meta: { timestamp, requestId },
} satisfies ErrorResponse,
{ status: 401 },
);
}
// In real app, validate JWT and extract userId
userId = "user-123"; // Placeholder
}
// Execute the handler
const result = await handler({
body: validatedBody,
query: validatedQuery,
params: context.params,
userId,
request,
});
// Return successful response
return NextResponse.json(
{
success: true,
data: result,
meta: { timestamp, requestId },
} satisfies SuccessResponse<TResponse>,
{ status: 200 },
);
} catch (error) {
console.error("API Error:", error);
return NextResponse.json(
{
success: false,
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
},
meta: { timestamp, requestId },
} satisfies ErrorResponse,
{ status: 500 },
);
}
};
};
}
Implementing API Routes
Now let's create type-safe API routes:
// src/app/api/users/route.ts
import { z } from "zod";
import { createApiHandler } from "@/lib/api/handler";
import { userSchema, createUserSchema } from "@/schemas/user";
import { paginatedResponseSchema } from "@/lib/api/responses";
const getUsersQuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
role: z.enum(["USER", "ADMIN", "MODERATOR"]).optional(),
});
// GET /api/users
export const GET = createApiHandler({
querySchema: getUsersQuerySchema,
requireAuth: true,
})<PaginatedResponse<User>>(async ({ query, userId }) => {
// Simulated database query
const users = await getUsers({
page: query.page,
limit: query.limit,
search: query.search,
role: query.role,
requestingUserId: userId,
});
return {
success: true,
data: users.items,
pagination: {
page: query.page,
limit: query.limit,
total: users.total,
totalPages: Math.ceil(users.total / query.limit),
hasNext: query.page * query.limit < users.total,
hasPrev: query.page > 1,
},
};
});
// POST /api/users
export const POST = createApiHandler({
bodySchema: createUserSchema,
requireAuth: true,
})<User>(async ({ body, userId }) => {
// Create user in database
const newUser = await createUser(body, userId);
return newUser;
});
// Type-safe database operations (simplified)
async function getUsers(filters: {
page: number;
limit: number;
search?: string;
role?: string;
requestingUserId: string;
}) {
// Database query implementation
return {
items: [] as User[],
total: 0,
};
}
async function createUser(userData: CreateUserData, creatorId: string) {
// Database creation implementation
return {} as User;
}
Individual Resource Routes
// src/app/api/users/[id]/route.ts
import { z } from "zod";
import { createApiHandler } from "@/lib/api/handler";
import { userSchema, updateUserSchema } from "@/schemas/user";
const userParamsSchema = z.object({
id: z.string().uuid("Invalid user ID format"),
});
// GET /api/users/[id]
export const GET = createApiHandler({
requireAuth: true,
})<User>(async ({ params, userId }) => {
const { id } = userParamsSchema.parse(params);
const user = await getUserById(id, userId);
if (!user) {
throw new Error("User not found");
}
return user;
});
// PATCH /api/users/[id]
export const PATCH = createApiHandler({
bodySchema: updateUserSchema,
requireAuth: true,
})<User>(async ({ body, params, userId }) => {
const { id } = userParamsSchema.parse(params);
const updatedUser = await updateUser(id, body, userId);
return updatedUser;
});
// DELETE /api/users/[id]
export const DELETE = createApiHandler({
requireAuth: true,
})<{ deleted: boolean }>(async ({ params, userId }) => {
const { id } = userParamsSchema.parse(params);
await deleteUser(id, userId);
return { deleted: true };
});
Client-Side Type Safety
Creating Type-Safe API Clients
Build a client that maintains type safety:
// src/lib/api/client.ts
import { z } from "zod";
class ApiClient {
private baseUrl: string;
private token?: string;
constructor(baseUrl: string = "/api") {
this.baseUrl = baseUrl;
}
setToken(token: string) {
this.token = token;
}
private async request<T>(
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: HeadersInit = {
"Content-Type": "application/json",
...options.headers,
};
if (this.token) {
headers.Authorization = `Bearer ${this.token}`;
}
const response = await fetch(url, {
...options,
headers,
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new ApiError(data.error || "Request failed", response.status);
}
return data.data;
}
// Type-safe methods
async getUsers(query?: {
page?: number;
limit?: number;
search?: string;
role?: string;
}): Promise<PaginatedResponse<User>> {
const searchParams = new URLSearchParams();
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined) {
searchParams.append(key, String(value));
}
});
}
const endpoint = `/users${searchParams.toString() ? `?${searchParams}` : ""}`;
return this.request<PaginatedResponse<User>>(endpoint);
}
async getUserById(id: string): Promise<User> {
return this.request<User>(`/users/${id}`);
}
async createUser(data: CreateUserData): Promise<User> {
return this.request<User>("/users", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateUser(id: string, data: UpdateUserData): Promise<User> {
return this.request<User>(`/users/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async deleteUser(id: string): Promise<{ deleted: boolean }> {
return this.request<{ deleted: boolean }>(`/users/${id}`, {
method: "DELETE",
});
}
}
class ApiError extends Error {
constructor(
message: string,
public status: number,
public code?: string,
) {
super(message);
this.name = "ApiError";
}
}
export const apiClient = new ApiClient();
export { ApiError };
React Hook Integration
Create hooks for seamless React integration:
// src/hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
export function useUsers(params?: {
page?: number;
limit?: number;
search?: string;
role?: string;
}) {
return useQuery({
queryKey: ["users", params],
queryFn: () => apiClient.getUsers(params),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useUser(id: string) {
return useQuery({
queryKey: ["user", id],
queryFn: () => apiClient.getUserById(id),
enabled: !!id,
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: apiClient.createUser,
onSuccess: () => {
// Invalidate and refetch users list
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateUserData }) =>
apiClient.updateUser(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ["user", id] });
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}
Advanced Patterns
Generic CRUD Operations
Create reusable patterns for common operations:
// src/lib/api/crud.ts
import { z } from "zod";
import { createApiHandler } from "./handler";
export function createCrudHandlers<
TEntity,
TCreateSchema extends z.ZodSchema,
TUpdateSchema extends z.ZodSchema,
>(config: {
entityName: string;
createSchema: TCreateSchema;
updateSchema: TUpdateSchema;
repository: {
findMany: (filters: any) => Promise<{ items: TEntity[]; total: number }>;
findById: (id: string) => Promise<TEntity | null>;
create: (data: z.infer<TCreateSchema>) => Promise<TEntity>;
update: (id: string, data: z.infer<TUpdateSchema>) => Promise<TEntity>;
delete: (id: string) => Promise<void>;
};
}) {
const list = createApiHandler({
requireAuth: true,
})<PaginatedResponse<TEntity>>(async ({ query }) => {
const result = await config.repository.findMany(query);
return {
success: true,
data: result.items,
pagination: {
// ... pagination logic
},
};
});
const getById = createApiHandler({
requireAuth: true,
})<TEntity>(async ({ params }) => {
const entity = await config.repository.findById(params.id);
if (!entity) {
throw new Error(`${config.entityName} not found`);
}
return entity;
});
const create = createApiHandler({
bodySchema: config.createSchema,
requireAuth: true,
})<TEntity>(async ({ body }) => {
return config.repository.create(body);
});
const update = createApiHandler({
bodySchema: config.updateSchema,
requireAuth: true,
})<TEntity>(async ({ params, body }) => {
return config.repository.update(params.id, body);
});
const remove = createApiHandler({
requireAuth: true,
})<{ deleted: boolean }>(async ({ params }) => {
await config.repository.delete(params.id);
return { deleted: true };
});
return { list, getById, create, update, remove };
}
Testing Type-Safe APIs
Write comprehensive tests for your APIs:
// src/app/api/users/route.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { testApiHandler } from "next-test-api-route-handler";
import * as handler from "./route";
describe("/api/users", () => {
describe("GET", () => {
it("should return paginated users", async () => {
await testApiHandler({
handler,
test: async ({ fetch }) => {
const res = await fetch({
method: "GET",
headers: {
Authorization: "Bearer valid-token",
},
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toMatchObject({
success: true,
data: expect.any(Array),
pagination: {
page: expect.any(Number),
limit: expect.any(Number),
total: expect.any(Number),
},
});
},
});
});
it("should validate query parameters", async () => {
await testApiHandler({
handler,
test: async ({ fetch }) => {
const res = await fetch({
method: "GET",
url: "/api/users?page=invalid",
headers: {
Authorization: "Bearer valid-token",
},
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.success).toBe(false);
expect(data.error.code).toBe("VALIDATION_ERROR");
},
});
});
});
});
Benefits and Best Practices
Key Benefits
- Compile-Time Safety: Catch type errors before deployment
- Excellent DX: Auto-completion and intelligent error messages
- Self-Documenting: Types serve as living documentation
- Consistent APIs: Standardized request/response patterns
- Easy Refactoring: Type system helps with safe code changes
Best Practices
- Schema-First Design: Start with Zod schemas, derive types
- Consistent Error Handling: Use standardized error responses
- Validation at Boundaries: Validate all inputs and outputs
- Comprehensive Testing: Test both happy paths and edge cases
- Documentation: Generate API docs from your schemas
Conclusion
Building type-safe APIs with Next.js and TypeScript transforms the development experience. By leveraging Zod for validation, creating consistent response patterns, and maintaining type safety from backend to frontend, we create robust, maintainable APIs that scale with our applications.
The initial setup investment pays dividends in reduced bugs, improved developer experience, and easier maintenance. Start with one endpoint, establish your patterns, and gradually apply them across your entire API surface.
Resources
Ready to implement type-safe APIs in your project? Start with our starter template that includes all these patterns out of the box.
Share this post: