Coding & dev/blog/coding/nextjs-api-routes-guide

Next.js API Routes in the App Router: Patterns That Actually Scale

The Pages Router's API routes were simple. A file in /pages/api/, a function that takes a request and a response, done. The App Router's Route Handlers are more powerful and considerably more confusing — especially if you're coming from the Pages Router and expecting the same mental model. This guide covers the patterns I've reached for in production App Router codebases: the ones that hold up under real traffic, real errors, and real team members who didn't write the original code.

Route Handlers: the mental model shift

In the Pages Router, your API routes lived in /pages/api/ and exported a default function. In the App Router, they live anywhere in the /app directory inside a file called route.ts — and instead of a default export, you export named functions corresponding to HTTP methods: GET, POST, PUT, PATCH, DELETE.

This is not just a naming change. It's a fundamentally different model. Route Handlers run in the Edge Runtime by default (when deployed to Vercel), they use the standard Web Request and Response objects instead of Node's req/res, and they can be co-located directly with the UI components they serve. That last part is the most useful thing about them.

typescriptapp/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl
  const page = Number(searchParams.get('page')) || 1
  const limit = Number(searchParams.get('limit')) || 20

  const posts = await db.post.findMany({
    skip: (page - 1) * limit,
    take: limit,
    orderBy: { createdAt: 'desc' }
  })

  return NextResponse.json({ posts, page, limit })
}

export async function POST(request: NextRequest) {
  const body = await request.json()
  // validation, creation logic here
  return NextResponse.json({ success: true }, { status: 201 })
}

Consistent error handling — the thing most codebases get wrong

The default approach to error handling in Next.js apps I've seen in the wild is: wrap things in try/catch, return a 500 with a message, and hope for the best. This works until you need to debug a production error and your logs say "Something went wrong" with no indication of which endpoint, what input, or what actually went wrong.

Build a consistent error response structure from day one. Every error response from every Route Handler should have the same shape. Every caught error should be logged with context. And different error types should map to different HTTP status codes — a validation error is a 400, an auth failure is a 401, a missing resource is a 404, and only a genuinely unexpected exception should ever be a 500.

typescriptlib/api-response.ts
import { NextResponse } from 'next/server'
import { ZodError } from 'zod'

export function apiSuccess<T>(data: T, status = 200) {
  return NextResponse.json({ success: true, data }, { status })
}

export function apiError(message: string, status: number, details?: unknown) {
  return NextResponse.json(
    { success: false, error: message, details },
    { status }
  )
}

export function handleRouteError(error: unknown) {
  if (error instanceof ZodError) {
    return apiError('Validation failed', 400, error.flatten())
  }
  if (error instanceof AuthError) {
    return apiError('Unauthorised', 401)
  }
  console.error('[API Error]', error)
  return apiError('Internal server error', 500)
}

Now every Route Handler uses the same pattern. The response shape is always predictable. The client always knows what to expect. And when something does fail, it fails with enough information to diagnose it.

Input validation with Zod

Never trust the body of a POST request. Not even from your own frontend. Network interception, browser extensions, direct API calls from scripts, a future you who forgot what the schema was — all of these can send you unexpected shapes. Zod validation at the Route Handler boundary is not optional in a production codebase.

typescriptapp/api/users/route.ts
import { z } from 'zod'
import { apiSuccess, handleRouteError } from '@/lib/api-response'

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
  role: z.enum(['admin', 'editor', 'viewer']).default('viewer')
})

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const validated = CreateUserSchema.parse(body) // throws ZodError if invalid

    const user = await db.user.create({ data: validated })
    return apiSuccess(user, 201)
  } catch (error) {
    return handleRouteError(error) // ZodError → 400, others → 500
  }
}

Middleware: where authentication lives

A common mistake: putting authentication checks inside individual Route Handlers. This means auth logic is scattered, inconsistently implemented, and every new Route Handler you add needs you to remember to add the auth check. Someone will forget.

Authentication belongs in middleware. The middleware.ts file at the root of your project runs before every matching request — before the Route Handler even executes. This is where you validate JWTs, check sessions, and redirect unauthenticated users.

typescriptmiddleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyJWT } from '@/lib/auth'

const PROTECTED_PATHS = ['/api/admin', '/api/users', '/dashboard']

export async function middleware(request: NextRequest) {
  const isProtected = PROTECTED_PATHS.some(
    path => request.nextUrl.pathname.startsWith(path)
  )

  if (!isProtected) return NextResponse.next()

  const token = request.cookies.get('auth-token')?.value
  if (!token) {
    return NextResponse.json({ error: 'Unauthorised' }, { status: 401 })
  }

  const payload = await verifyJWT(token)
  if (!payload) {
    return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
  }

  // Pass user data to route handlers via headers
  const response = NextResponse.next()
  response.headers.set('x-user-id', payload.userId)
  response.headers.set('x-user-role', payload.role)
  return response
}

export const config = {
  matcher: ['/api/:path*', '/dashboard/:path*']
}
Pass user identity from middleware to route handlers via request headers (x-user-id, x-user-role). Route handlers read them with request.headers.get('x-user-id'). This avoids re-validating the JWT in every handler while keeping user context available everywhere.

Caching Route Handlers correctly

In the App Router, GET Route Handlers are cached by default when they don't use dynamic functions. This is useful and surprising in equal measure. Useful because a GET endpoint that returns static-ish data can be served from cache without hitting your database. Surprising because you might expect a database call to always run and find that it doesn't.

✓ Opt out of cache for dynamic data
export const dynamic = 'force-dynamic'

export async function GET() {
  // always runs fresh
  const data = await db.getLatest()
  return NextResponse.json(data)
}
✗ Assume GET always runs fresh
// This may be served from cache
// if nothing signals dynamic usage
export async function GET() {
  const data = await db.getLatest()
  return NextResponse.json(data)
}

Dynamic route segments

When your Route Handler needs a path parameter — like /api/posts/[id] — the segment is passed as the second argument to the handler function.

typescriptapp/api/posts/[id]/route.ts
type RouteParams = { params: { id: string } }

export async function GET(
  request: NextRequest,
  { params }: RouteParams
) {
  const post = await db.post.findUnique({
    where: { id: params.id }
  })

  if (!post) {
    return apiError('Post not found', 404)
  }

  return apiSuccess(post)
}

Three patterns I use in almost every production codebase

Pattern 1 — withAuth wrapper Recommended

A higher-order function that wraps Route Handlers with role-based auth checks. Reduces boilerplate and makes it impossible to accidentally ship an unprotected endpoint.

Pattern 2 — Response factory functions Recommended

Helper functions like apiSuccess() and apiError() that enforce consistent response shapes. Your TypeScript types on the client can then be derived from the response schema rather than maintained separately.

Pattern 3 — Schema-first validation Recommended

Define your Zod schemas in a shared /lib/schemas directory. Import them in both the Route Handler (for runtime validation) and in your client-side forms (for UI validation). One source of truth for what your API accepts.

What this unlocks

The patterns in this post aren't interesting individually. Consistent error handling, Zod validation, middleware auth — these are table stakes. What's interesting is what you can do when they're all in place.

A codebase with consistent API patterns is dramatically easier to extend, easier to onboard new developers into, and easier to debug in production. It's the difference between a codebase that feels like it was built by one person with a plan and one that feels like six people each made different decisions independently.

If you're building something bigger than a side project, these patterns pay for themselves within the first month of active development. Set them up before you write your first Route Handler, not after you have thirty of them.

More on the development side in my TypeScript patterns for scale post. And if you need a custom Next.js application built with this kind of architectural rigour, my custom development service is where I do exactly that.

Need a Next.js app built properly?

I build production Next.js applications with the kind of architecture that holds up when real users hit it — clean patterns, typed APIs, and code your team can work with.

See my dev service →

FAQs

What stack do the coding articles assume?

Most examples skew Next.js and TypeScript because that is what clients ship. Patterns such as API design, caching discipline, and observability apply beyond a single framework.

Are snippets production-ready?

They are teaching aids. Adapt error handling, auth, and tests to your environment before shipping.

Do you cover backend and frontend?

Yes—where they meet in real products: routing, data fetching, edge vs node behavior, and performance budgets users actually feel.

Can I hire you to implement something from a post?

Often yes. Point to the article and describe how your codebase differs; we will scope the delta instead of re-deriving the theory.

Where do I report a bug in an example?

Send the permalink and the snippet version you used. I fix mistakes that mislead readers.