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.
| Status | When to use | NextResponse call |
|---|---|---|
| 200 | Successful GET / PUT / PATCH | NextResponse.json(data) |
| 201 | Resource created (POST) | NextResponse.json(data, { status: 201 }) |
| 204 | Success, no body (DELETE) | new Response(null, { status: 204 }) |
| 400 | Malformed JSON body | NextResponse.json(err, { status: 400 }) |
| 401 | Missing or invalid auth token | NextResponse.json(err, { status: 401 }) |
| 403 | Authenticated but not authorized | NextResponse.json(err, { status: 403 }) |
| 404 | Resource not found | NextResponse.json(err, { status: 404 }) |
| 422 | Schema validation failed | NextResponse.json(err, { status: 422 }) |
| 500 | Unexpected server error | NextResponse.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(orroute.js) inside theapp/directory that exports named async functions matching HTTP method names (GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS). Route Handlers replace the Pages Routerpages/api/convention in the Next.js App Router and use standard Web Fetch API request and response objects. - NextResponse
- A class exported from
next/serverthat extends the standard Web APIResponseclass with Next.js-specific helpers. Its staticNextResponse.json(data, init)method serializes the first argument withJSON.stringify, setsContent-Type: application/jsonautomatically, and returns aResponseinstance. It also providesNextResponse.redirect()andNextResponse.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 asfs,child_process, TCP connections, or native addons. JSON operations withNextResponse.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; callschema.safeParse(value)for non-throwing validation that returns{ success: true, data }or{ success: false, error }. The inferred TypeScript type matches the schema exactly viaz.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. AfterNseconds, the next request triggers a background revalidation — similar to Incremental Static Regeneration for pages. Settingrevalidate = 0disables caching;revalidate = falsecaches indefinitely (until a deployment). A Route Handler accessesrequest.cookiesorrequest.headersopts out of caching regardless of therevalidatesetting.
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
- Next.js Route Handlers — Official Next.js documentation for App Router Route Handlers
- NextResponse API — NextResponse class reference
- Zod Documentation — TypeScript-first schema validation library