Building a Type-Safe API with Next.js App Router
Learn how to build fully type-safe APIs using Next.js App Router, TypeScript, and Zod for runtime validation.
Building a Type-Safe API with Next.js App Router
In this tutorial, we'll explore how to build fully type-safe APIs using Next.js 16's App Router, TypeScript, and Zod for runtime validation.
Why Type Safety Matters
Type safety helps us catch errors at compile time rather than runtime, leading to:
- Fewer bugs in production
- Better developer experience with autocomplete
- Self-documenting code
- Easier refactoring
Setting Up Route Handlers
Next.js App Router uses Route Handlers for API routes. Here's a basic example:
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const users = await fetchUsers();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const newUser = await createUser(body);
return NextResponse.json(newUser, { status: 201 });
}Adding Runtime Validation with Zod
TypeScript only provides compile-time type checking. For runtime validation, we use Zod:
// app/api/users/route.ts
import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";
const userSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
age: z.number().min(18, "Must be at least 18 years old"),
});
type UserInput = z.infer<typeof userSchema>;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate the request body
const validatedData = userSchema.parse(body);
// validatedData is now fully typed as UserInput
const newUser = await createUser(validatedData);
return NextResponse.json(newUser, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ errors: error.errors }, { status: 400 });
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}Type-Safe Response Handling
We can also create helper functions for type-safe responses:
// lib/api-response.ts
export function successResponse<T>(data: T, status = 200) {
return NextResponse.json({ success: true, data }, { status });
}
export function errorResponse(message: string, status = 400) {
return NextResponse.json({ success: false, error: message }, { status });
}Now our route handlers become cleaner:
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validatedData = userSchema.parse(body);
const newUser = await createUser(validatedData);
return successResponse(newUser, 201);
} catch (error) {
if (error instanceof z.ZodError) {
return errorResponse("Validation failed", 400);
}
return errorResponse("Internal server error", 500);
}
}Advanced: Middleware for Authentication
We can add authentication middleware to protect our routes:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const token = request.headers.get("authorization");
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Verify token and attach user to request
return NextResponse.next();
}
export const config = {
matcher: "/api/protected/:path*",
};Best Practices
Here are some best practices to follow:
- Always validate input - Use Zod or similar libraries
- Handle errors gracefully - Provide meaningful error messages
- Use TypeScript - Leverage the type system for safety
- Test your APIs - Write integration tests for all endpoints
- Document your routes - Use JSDoc comments or OpenAPI specs
Common HTTP Status Codes
When building APIs, it's important to use the correct HTTP status codes. Here's a quick reference:
| Status Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, or PATCH request |
| 201 | Created | Successful POST request that creates a resource |
| 400 | Bad Request | Invalid request data (validation errors) |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource doesn't exist |
| 500 | Internal Server Error | Server-side error |
For more details, check the MDN HTTP Status Codes documentation.
Implementation Checklist
Before deploying your API to production, make sure you've completed these steps:
- Define Zod schemas for validation
- Implement error handling
- Add TypeScript types
- Write unit tests for validation logic
- Write integration tests for API endpoints
- Add rate limiting1
- Implement request logging
- Set up monitoring and alerts
Conclusion
Building type-safe APIs with Next.js App Router is straightforward and provides significant benefits:
- ✅ Catch errors early with TypeScript
- ✅ Runtime validation with Zod
- ✅ Clean, maintainable code
- ✅ Better developer experience
In the past, we had to use separate validation libraries for client and server, but now Zod allows us to share schemas across both!
In our next post, we'll explore how to consume these APIs in a type-safe way from your React components!
Additional Resources
Questions or feedback? Let us know what you'd like to see in future posts!
Footnotes
-
Rate limiting is crucial for production APIs to prevent abuse. Consider using libraries like
@upstash/ratelimitor implementing custom middleware. ↩