Acme
Back to Blog

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.

RRichard Roe

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:

  1. Always validate input - Use Zod or similar libraries
  2. Handle errors gracefully - Provide meaningful error messages
  3. Use TypeScript - Leverage the type system for safety
  4. Test your APIs - Write integration tests for all endpoints
  5. 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 CodeMeaningWhen to Use
200OKSuccessful GET, PUT, or PATCH request
201CreatedSuccessful POST request that creates a resource
400Bad RequestInvalid request data (validation errors)
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but not authorized
404Not FoundResource doesn't exist
500Internal Server ErrorServer-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

  1. Rate limiting is crucial for production APIs to prevent abuse. Consider using libraries like @upstash/ratelimit or implementing custom middleware.

The magic of AI at your fingertips.

Join thousands of professionals who have already streamlined their scheduling with our AI Assistant.