JSON in Next.js API Routes and Route Handlers

Last updated:

Next.js App Router Route Handlers replace the Pages Router pages/api/ convention and give you named exports per HTTP verb, standard Web API request/response objects, and native integration with Next.js caching. Returning JSON takes one line: return NextResponse.json(data). Parsing a JSON body requires a single await request.json() call, wrapped in error handling. This guide covers both the App Router and Pages Router patterns, input validation with Zod, error envelopes, cache headers, and Edge Runtime deployment.

App Router Route Handlers (Next.js 13+)

App Router Route Handlers live in files named route.ts inside the app/ directory. Each HTTP method is a named export — GET, POST, PUT, PATCH, DELETE, OPTIONS, and HEAD. There is no default export; the framework dispatches by matching the export name to the request method.

// app/api/users/route.ts
import { NextResponse } from 'next/server'

// GET /api/users
export async function GET() {
  const users = await db.query('SELECT id, name, email FROM users')
  return NextResponse.json(users)
}

// POST /api/users
export async function POST(request: Request) {
  const body = await request.json()
  const user = await db.create(body)
  return NextResponse.json(user, { status: 201 })
}

The file name must be route.ts, not page.ts. A directory can have both page.tsx (for UI) and route.ts (for API) — but only at different URL segments. If the same segment has both, Next.js will throw a build error.

Dynamic Route Segments

Dynamic segments work the same way as page routes. Create app/api/users/[id]/route.ts and receive the params as the second argument to the handler:

// app/api/users/[id]/route.ts
import { NextResponse } from 'next/server'

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const user = await db.findById(id)
  if (!user) {
    return NextResponse.json({ error: 'User not found' }, { status: 404 })
  }
  return NextResponse.json(user)
}

Note: in Next.js 15+, params is a Promise and must be awaited. In Next.js 13–14, it was a plain object. Always check the version your project targets.

Returning JSON Responses

NextResponse.json() is a static factory method that serializes the first argument with JSON.stringify and sets Content-Type: application/json; charset=utf-8 automatically. The second argument mirrors the ResponseInit interface — pass status, headers, or both.

import { NextResponse } from 'next/server'

// 200 OK (default)
return NextResponse.json({ id: 1, name: 'Alice' })

// 201 Created
return NextResponse.json({ id: 1 }, { status: 201 })

// 204 No Content (empty body — do not use json() here)
return new Response(null, { status: 204 })

// 400 with validation error detail
return NextResponse.json(
  { error: 'Validation failed', fields: { email: 'Required' } },
  { status: 400 }
)

// Custom headers alongside JSON body
return NextResponse.json(data, {
  status: 200,
  headers: {
    'Cache-Control': 'public, max-age=60',
    'X-Request-Id': crypto.randomUUID(),
  },
})

Consistent Error Envelope

Define a standard error shape for all failure responses so clients can handle errors generically:

// lib/api-response.ts
import { NextResponse } from 'next/server'

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

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

// Usage
return ok({ id: user.id })
return err('Not found', 404)
return err('Validation failed', 422, result.error.flatten())

Parsing JSON Request Bodies

The request parameter in a Route Handler is a standard Request object from the Web Fetch API. Call await request.json() to parse the body. This method throws a SyntaxError for invalid JSON and a TypeError if the body has already been consumed — so call it exactly once and always guard with try/catch.

import { NextResponse } from 'next/server'

export async function POST(request: Request) {
  let body: unknown

  try {
    body = await request.json()
  } catch {
    return NextResponse.json(
      { error: 'Request body must be valid JSON' },
      { status: 400 }
    )
  }

  // body is typed as unknown — validate before use
  // ...
}

After the try/catch, body is typed as unknown. Do not access any properties until you have narrowed the type through validation. Skipping validation and casting to a known type is the most common source of runtime crashes in Next.js API routes.

Reading Query Parameters

import { NextResponse } from 'next/server'
import { NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl
  const page = Number(searchParams.get('page') ?? '1')
  const limit = Math.min(Number(searchParams.get('limit') ?? '20'), 100)

  const results = await db.paginate({ page, limit })
  return NextResponse.json({ data: results, page, limit })
}

Use NextRequest (from next/server) instead of the plain Request type to get the nextUrl property, which parses the full URL including searchParams.

Validating Input with Zod

Zod is the standard validation library for Next.js Route Handlers. It provides TypeScript-first runtime validation: define a schema once, get both runtime safety and inferred TypeScript types. Use schema.safeParse() instead of schema.parse() to avoid uncaught throws — safeParse always returns a result object.

// app/api/users/route.ts
import { NextResponse } from 'next/server'
import { z } from 'zod'

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

type CreateUserInput = z.infer<typeof CreateUserSchema>

export async function POST(request: Request) {
  let body: unknown
  try {
    body = await request.json()
  } catch {
    return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
  }

  const result = CreateUserSchema.safeParse(body)
  if (!result.success) {
    return NextResponse.json(
      { error: 'Validation failed', issues: result.error.flatten() },
      { status: 422 }
    )
  }

  // result.data is fully typed as CreateUserInput
  const user = await db.createUser(result.data)
  return NextResponse.json(user, { status: 201 })
}

result.error.flatten() produces a structured error object with fieldErrors (per-field messages) and formErrors (top-level messages) — useful for frontend form validation feedback. Return status 422 Unprocessable Entity for schema validation failures; reserve 400 Bad Request for JSON parse errors.

Reusable Validation Middleware Pattern

// lib/validate.ts
import { NextResponse } from 'next/server'
import { z } from 'zod'

export async function parseBody<T>(
  request: Request,
  schema: z.ZodSchema<T>
): Promise<{ data: T; error: null } | { data: null; error: NextResponse }> {
  let raw: unknown
  try {
    raw = await request.json()
  } catch {
    return {
      data: null,
      error: NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }),
    }
  }

  const result = schema.safeParse(raw)
  if (!result.success) {
    return {
      data: null,
      error: NextResponse.json(
        { error: 'Validation failed', issues: result.error.flatten() },
        { status: 422 }
      ),
    }
  }
  return { data: result.data, error: null }
}

// Usage in a route
export async function POST(request: Request) {
  const { data, error } = await parseBody(request, CreateUserSchema)
  if (error) return error
  // data is typed as CreateUserInput
}

Error Handling and HTTP Status Codes

Route Handlers that throw an uncaught error return a 500 Internal Server Error response with an empty body in production (the full error is logged server-side). Catch and handle all expected errors explicitly — database errors, external API failures, and business logic violations — and return structured JSON for each case.

StatusWhen to useNextResponse call
200Successful GET / PUT / PATCHNextResponse.json(data)
201Resource created (POST)NextResponse.json(data, { status: 201 })
204Success, no body (DELETE)new Response(null, { status: 204 })
400Malformed JSON bodyNextResponse.json(err, { status: 400 })
401Missing or invalid auth tokenNextResponse.json(err, { status: 401 })
403Authenticated but not authorizedNextResponse.json(err, { status: 403 })
404Resource not foundNextResponse.json(err, { status: 404 })
422Schema validation failedNextResponse.json(err, { status: 422 })
500Unexpected server errorNextResponse.json(err, { status: 500 })
// Centralized error handling pattern
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  try {
    const { id } = await params
    const user = await db.findById(id)

    if (!user) {
      return NextResponse.json({ error: 'User not found' }, { status: 404 })
    }

    return NextResponse.json(user)
  } catch (err) {
    console.error('GET /api/users/[id] failed:', err)
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

Caching and Revalidation

Next.js Route Handlers participate in the Next.js data cache. A GET handler that does not access dynamic request values (cookies, headers, search params) is eligible for static caching. Control caching with two mechanisms: the revalidate export constant and Cache-Control headers.

// app/api/products/route.ts

// Revalidate the cached response every 60 seconds (ISR-style)
export const revalidate = 60

// Force dynamic — never cache (e.g., if reading cookies for auth)
// export const dynamic = 'force-dynamic'

export async function GET() {
  const products = await db.getProducts()
  return NextResponse.json(products)
}

Alternatively, set Cache-Control headers directly on the response for CDN-level caching (Vercel Edge Cache, Cloudflare, etc.). The stale-while-revalidate directive serves stale content while fetching a fresh version in the background — a useful pattern for product listings that tolerate a few seconds of staleness.

export async function GET() {
  const data = await fetchExpensiveData()

  return NextResponse.json(data, {
    headers: {
      // Cache for 60s, serve stale for up to 5 minutes while revalidating
      'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
    },
  })
}

When Caching Is Disabled Automatically

Next.js opts a Route Handler out of caching if any of these are present in the handler: request.cookies, request.headers, request.nextUrl.searchParams, or the POST/PUT/DELETE/PATCH method. Mutating methods are never cached by default. Use export const dynamic = 'force-static' to override this for GET handlers that you want cached despite accessing headers.

Pages Router API Routes (Legacy)

The Pages Router API Route pattern — files in pages/api/ with a default export handler — still works in Next.js 15. It uses Node.js-style req and res objects rather than the Web Fetch API. If your project uses pages/api/, these patterns continue to work without migration.

// pages/api/users.ts  (Pages Router — legacy)
import type { NextApiRequest, NextApiResponse } from 'next'

type User = { id: number; name: string; email: string }
type ErrorResponse = { error: string }

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<User[] | ErrorResponse>
) {
  if (req.method === 'GET') {
    const users = await db.getUsers()
    return res.status(200).json(users)
  }

  if (req.method === 'POST') {
    const body = req.body as Partial<User>  // Next.js parses JSON body automatically
    const user = await db.createUser(body)
    return res.status(201).json(user)
  }

  res.setHeader('Allow', ['GET', 'POST'])
  return res.status(405).json({ error: 'Method not allowed' })
}

Key differences from App Router Route Handlers: the default export handles all HTTP methods through an if/switch on req.method; Next.js automatically parses JSON request bodies into req.body when Content-Type: application/json is present; and res.json() serializes the argument and sets the content type header.

Pages Router with Zod Validation

import type { NextApiRequest, NextApiResponse } from 'next'
import { z } from 'zod'

const CreateSchema = z.object({
  name:  z.string().min(1),
  email: z.string().email(),
})

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  const result = CreateSchema.safeParse(req.body)
  if (!result.success) {
    return res.status(422).json({ error: result.error.flatten() })
  }

  const user = await db.create(result.data)
  return res.status(201).json(user)
}

The validation pattern is identical to App Router — only the response API differs. When migrating from Pages Router to App Router, the Zod schemas and business logic transfer unchanged; only the handler function signature and the response calls change.

Definitions

Route Handler
A file named route.ts (or route.js) inside the app/ directory that exports named async functions matching HTTP method names (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS). Route Handlers replace the Pages Router pages/api/ convention in the Next.js App Router and use standard Web Fetch API request and response objects.
NextResponse
A class exported from next/server that extends the standard Web API Response class with Next.js-specific helpers. Its static NextResponse.json(data, init) method serializes the first argument with JSON.stringify, sets Content-Type: application/json automatically, and returns a Response instance. It also provides NextResponse.redirect() and NextResponse.rewrite() for middleware use.
Edge Runtime
A lightweight JavaScript runtime based on the V8 engine and Web APIs, deployed to Vercel's global edge network in 40+ regions. Enabled per Route Handler with export const runtime = 'edge'. Edge Runtime has cold starts under 10ms (compared to 250ms+ for Node.js serverless) but does not support Node.js-specific APIs such as fs, child_process, TCP connections, or native addons. JSON operations with NextResponse.json() work identically on Edge and Node.js runtimes.
Zod validation
A TypeScript-first schema declaration and validation library. Define a schema with z.object(), z.string(), and other primitives; call schema.safeParse(value) for non-throwing validation that returns { success: true, data } or { success: false, error }. The inferred TypeScript type matches the schema exactly via z.infer<typeof schema>, eliminating the need for separate type declarations alongside validation logic.
revalidate
A Route Segment Config export (export const revalidate = N) that controls how long Next.js caches a Route Handler's response in seconds. After N seconds, the next request triggers a background revalidation — similar to Incremental Static Regeneration for pages. Setting revalidate = 0 disables caching; revalidate = false caches indefinitely (until a deployment). A Route Handler accesses request.cookies or request.headers opts out of caching regardless of the revalidate setting.

FAQ

How do I return JSON from a Next.js App Router Route Handler?

Export a named async function matching the HTTP verb from a route.ts file inside app/api/. Return NextResponse.json(data) from the function. Next.js sets Content-Type: application/json and serializes the data automatically. Example: export async function GET() { return NextResponse.json({ ok: true }) }. There is no default export — each method is its own named export.

How do I set the HTTP status code when returning JSON in Next.js?

Pass a second argument to NextResponse.json() with a status field: NextResponse.json(data, { status: 201 }). Omitting the second argument defaults to 200. For error responses, use 400 for malformed JSON, 422 for schema validation failures, 401 for missing authentication, 403 for authorization failures, and 404 for missing resources. You can also pass a headers object in the same second argument alongside status.

How do I parse a JSON request body in a Next.js Route Handler?

Call await request.json() inside the Route Handler. The request object is a standard Web API Request. Wrap the call in try/catch because it throws a SyntaxError for invalid JSON. After parsing, the value is typed as unknown — use Zod safeParse or a type guard before accessing any properties. Do not call request.json() more than once; the body stream can only be consumed once.

How do I validate JSON request body with Zod in Next.js?

Define a Zod schema, call await request.json() to parse the body, then pass it to schema.safeParse(body). Check result.success: if false, return NextResponse.json({ error: result.error.flatten() }, { status: 422 }). If true, use result.data — it is typed to the schema's inferred type with no additional casting needed.

What is the difference between Next.js App Router Route Handlers and Pages Router API Routes?

App Router Route Handlers (file: app/api/route.ts) export named functions per HTTP method and receive a standard Web Fetch Request object. Pages Router API Routes (file: pages/api/handler.ts) export a single default function that receives Node.js-style req/res objects. App Router handlers integrate with Next.js caching and support Edge Runtime; Pages Router handlers run only on Node.js and do not participate in the data cache.

How do I handle CORS in Next.js Route Handlers when returning JSON?

Add CORS headers to the NextResponse.json() options: { headers: { "Access-Control-Allow-Origin": "https://yourdomain.com" } }. Also export an OPTIONS handler for preflight requests that returns new Response(null, { status: 204, headers: corsHeaders }). For a Next.js middleware approach, set CORS headers in middleware.ts so they apply to all matching routes without repeating them in every Route Handler.

How do I cache JSON responses in Next.js Route Handlers?

Use export const revalidate = 60 to cache the Route Handler response in Next.js's data cache for 60 seconds, with automatic revalidation on the next request after expiry. Alternatively, set Cache-Control headers in the response for CDN-level caching: 'public, max-age=60, stale-while-revalidate=300'. Handlers that read request.cookies or request.headers are opted out of caching automatically; use export const dynamic = 'force-dynamic' to make this explicit.

How do I deploy a Next.js Route Handler to the Vercel Edge Network?

Add export const runtime = 'edge' to the route.ts file. The handler deploys to Vercel's Edge Network instead of serverless Node.js functions. Edge handlers start in under 10ms with no cold start penalty, but cannot use Node.js-only APIs like fs, TCP connections, or native modules. Use HTTP-based database drivers (Neon serverless, PlanetScale HTTP) in Edge handlers instead of TCP drivers. NextResponse.json() works identically on Edge and Node.js runtimes.

Further reading and primary sources