Next.js JSON API: App Router Route Handlers & NextResponse.json()
Next.js App Router API routes return JSON by using NextResponse.json(data, { status: 200 }) — a helper that sets Content-Type: application/json and serializes the object automatically, replacing the Pages Router's res.json() pattern. Route handlers live in app/api/[route]/route.ts and export named functions for each HTTP method (GET, POST, PUT, DELETE, PATCH); reading a POST body requires await request.json(), which throws a SyntaxError if the body is not valid JSON. This guide covers App Router route handler setup, returning JSON with NextResponse.json(), reading JSON request bodies, input validation with Zod, error responses, middleware for auth, and Edge Runtime vs Node.js Runtime JSON handling. Use Jsonic's JSON formatter to validate and prettify response payloads while developing your API.
Need to validate or format the JSON your Next.js API returns? Jsonic formats and validates JSON instantly.
Open JSON FormatterApp Router route handler setup — file location and method exports
The App Router replaces pages/api/ with app/api/ and changes the export model: instead of a single default export function, you export a named async function for each HTTP method you want to support. A route file must be named route.ts (or route.js). The folder path under app/ becomes the URL path. 3 key rules apply: (1) a folder with a route.ts cannot also have a page.tsx — they conflict at the same URL segment; (2) dynamic segments use square brackets, e.g., app/api/users/[id]/route.ts; (3) unhandled HTTP methods automatically return a 405 Method Not Allowed response.
// app/api/users/route.ts
import { NextResponse } from 'next/server'
// GET /api/users
export async function GET() {
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]
return NextResponse.json(users, { status: 200 })
}
// POST /api/users
export async function POST(request: Request) {
const body = await request.json()
// ... create user
return NextResponse.json({ id: 3, ...body }, { status: 201 })
}Dynamic route segments are accessed via the second parameter, a context object with a params property:
// app/api/users/[id]/route.ts
import { NextResponse } from 'next/server'
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const user = await db.users.findById(params.id)
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json(user)
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await db.users.deleteById(params.id)
return new Response(null, { status: 204 })
}Note the 204 No Content response uses the native Response constructor with a null body — NextResponse.json() always serializes a body, so it is not appropriate for 204 responses. For a broader comparison with other frameworks, see the Express JSON API guide.
Returning JSON with NextResponse.json() — status codes and headers
NextResponse.json() is the primary way to return JSON in App Router route handlers. It accepts 2 arguments: the data to serialize (any JSON-serializable value), and an optional options object with status and headers properties. It automatically sets Content-Type: application/json; charset=utf-8 and calls JSON.stringify() internally. The default status is 200 if omitted.
import { NextResponse } from 'next/server'
// 200 OK (default)
return NextResponse.json({ message: 'OK' })
// 201 Created
return NextResponse.json({ id: 42, name: 'Alice' }, { status: 201 })
// 400 Bad Request with error detail
return NextResponse.json(
{ error: 'Missing required field: name' },
{ status: 400 }
)
// 200 with custom headers
return NextResponse.json(
{ data: results },
{
status: 200,
headers: {
'Cache-Control': 'public, max-age=60',
'X-Total-Count': String(results.length),
},
}
)The table below shows the recommended status codes for common JSON API operations in a REST-style Next.js API:
| Operation | HTTP Method | Success Status | Error Status |
|---|---|---|---|
| Fetch resource(s) | GET | 200 OK | 404 Not Found |
| Create resource | POST | 201 Created | 400 Bad Request / 422 Unprocessable |
| Full replace | PUT | 200 OK | 404 Not Found / 400 Bad Request |
| Partial update | PATCH | 200 OK | 404 Not Found / 422 Unprocessable |
| Delete resource | DELETE | 204 No Content | 404 Not Found |
| Auth failure | Any | — | 401 Unauthorized / 403 Forbidden |
| Server error | Any | — | 500 Internal Server Error |
Always return a consistent error JSON shape so clients can reliably parse error messages without inspecting the status code first. A common convention is { "error": "human-readable message", "code": "MACHINE_CODE" }. To understand how JSON serialization works under the hood, see the JSON.stringify() tutorial.
Reading a JSON request body — request.json() and error handling
App Router route handlers receive a standard Web API Request object as the first parameter. Call await request.json() to parse the body as JSON. This returns a Promise that resolves to the parsed value. 3 things to know: (1) the body stream can only be consumed once — calling request.json() twice throws; (2) it throws a SyntaxError if the body is not valid JSON; (3) it resolves to null if the body is empty and valid JSON null was sent — always check the parsed type before using it.
// app/api/posts/route.ts
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
// 1. Parse body — catch SyntaxError for malformed JSON
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json(
{ error: 'Request body must be valid JSON' },
{ status: 400 }
)
}
// 2. Guard against null / non-object bodies
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return NextResponse.json(
{ error: 'Request body must be a JSON object' },
{ status: 400 }
)
}
// 3. body is now Record<string, unknown> — proceed with validation
const { title, content } = body as Record<string, unknown>
if (typeof title !== 'string' || !title.trim()) {
return NextResponse.json(
{ error: 'Missing required field: title' },
{ status: 422 }
)
}
return NextResponse.json({ id: 1, title, content }, { status: 201 })
}If you need to inspect the raw request body as a string (for example, to verify a webhook signature before parsing), use await request.text() and then parse manually with JSON.parse():
const rawBody = await request.text()
// Verify signature using rawBody string
const isValid = verifySignature(rawBody, request.headers.get('X-Signature') ?? '')
if (!isValid) return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
const body = JSON.parse(rawBody) // May throw SyntaxError — wrap in try/catchFor a comparison of how other frameworks handle JSON bodies, see the REST API JSON response guide.
Input validation with Zod — type-safe request bodies in 10 lines
Zod is the most popular schema validation library in the Next.js ecosystem. It infers TypeScript types from schemas at zero extra runtime cost and returns structured errors that map field names to error messages. Install with npm install zod and integrate in 3 steps: define a schema, call safeParse() on the parsed body, branch on result.success.
// app/api/users/route.ts
import { NextResponse } from 'next/server'
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Must be a valid email'),
age: z.number().int().min(0).max(150).optional(),
})
// Infer the TypeScript type for free
type CreateUserInput = z.infer<typeof createUserSchema>
export async function POST(request: Request) {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const result = createUserSchema.safeParse(rawBody)
if (!result.success) {
return NextResponse.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 422 }
)
}
// result.data is fully typed as CreateUserInput
const user = await db.users.create(result.data)
return NextResponse.json(user, { status: 201 })
}result.error.flatten().fieldErrors returns an object like { "email": ["Must be a valid email"], "name": ["Name is required"] }, which client-side form libraries (React Hook Form, Formik) can display directly as per-field error messages. For more complex validation — conditional fields, custom refinements, transformations — use z.discriminatedUnion() or z.superRefine(). You can also define Zod schemas that match your JSON serialization shape to ensure what you validate is what you serialize.
Error responses, authentication middleware, and CORS headers
Consistent error responses and authentication are cross-cutting concerns best handled in 2 places: a shared error-response utility for route handlers, and middleware.ts at the project root for request-level auth checks before the route handler runs. The middleware runs on every matched request and can return a JSON 401 before the route handler is ever invoked — saving compute and keeping auth logic out of every individual handler.
// lib/api-response.ts — shared error utility
import { NextResponse } from 'next/server'
export function jsonError(message: string, status: number, code?: string) {
return NextResponse.json({ error: message, code }, { status })
}
// Usage in any route handler:
// return jsonError('Not found', 404, 'USER_NOT_FOUND')// middleware.ts — runs before route handlers
import { NextResponse, type NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Protect all /api/ routes except /api/auth
if (
request.nextUrl.pathname.startsWith('/api/') &&
!request.nextUrl.pathname.startsWith('/api/auth')
) {
const token = request.headers.get('Authorization')?.replace('Bearer ', '')
if (!token || !isValidToken(token)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/api/:path*'],
}For CORS headers on public APIs — allowing browser clients from other origins to call your route handlers — add Access-Control-Allow-Origin to the response headers and handle the OPTIONS preflight method:
// app/api/public/route.ts
import { NextResponse } from 'next/server'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders })
}
export async function GET() {
return NextResponse.json({ data: 'public' }, { status: 200, headers: corsHeaders })
}For fetch-side JSON handling on the client, see fetching JSON with the JavaScript Fetch API.
Edge Runtime vs Node.js Runtime for JSON handlers
Next.js route handlers run in Node.js Runtime by default. Opt into Edge Runtime by adding export const runtime = 'edge' at the top of the route file. Both runtimes support NextResponse.json() and request.json() identically. The 3 critical differences are cold-start latency, available APIs, and memory limits:
| Feature | Node.js Runtime (default) | Edge Runtime |
|---|---|---|
| Cold start | 50–200 ms | Under 1 ms |
| Opt-in | Default (no config) | export const runtime = 'edge' |
| Node.js built-ins | All (fs, crypto, path, etc.) | None |
| Web APIs | Partial (fetch, Web Crypto) | Full (fetch, Web Crypto, Streams) |
| Max execution | 300 s (Vercel) | 30 s (Vercel) |
| Memory limit | 1 GB (Vercel) | 128 MB (Vercel) |
| Database drivers | All (Prisma, pg, mongoose) | HTTP-based only (Turso, PlanetScale HTTP) |
| Best for | DB queries, file ops, heavy compute | Auth checks, redirects, lightweight JSON transforms |
// app/api/geo/route.ts — Edge Runtime example
import { NextResponse, type NextRequest } from 'next/server'
export const runtime = 'edge'
export async function GET(request: NextRequest) {
// NextRequest extends Request with Vercel geo/ip data
const country = request.geo?.country ?? 'unknown'
const city = request.geo?.city ?? 'unknown'
return NextResponse.json({ country, city })
}You can mix runtimes across routes in the same Next.js project. A common pattern: use Edge Runtime for the middleware (fast auth checks) and Node.js Runtime for route handlers that query a database. Both runtimes serialize JSON identically via JSON.stringify() — there are no differences in the output format or encoding.
Caching behavior — App Router does not cache API routes by default
A key difference from Pages Router: App Router API route handlers are not cached by default. Every request runs the handler fresh. In Pages Router, GET handlers were opt-in cached via incremental static regeneration (ISR). In App Router, 3 options control caching:
// Option 1: Force dynamic (default for route handlers — explicit for clarity)
export const dynamic = 'force-dynamic'
// Option 2: Cache the response for 60 seconds (ISR-style)
export const revalidate = 60
// Option 3: Cache permanently until manually revalidated
export const revalidate = falseWhen revalidate is set, the GET handler is called at build time (or on first request) and the response is cached. Subsequent requests within the revalidation window return the cached response. This only applies to GET handlers — POST, PUT, DELETE, and PATCH are always dynamic.
// app/api/config/route.ts — cached JSON endpoint
import { NextResponse } from 'next/server'
export const revalidate = 300 // re-fetch every 5 minutes
export async function GET() {
const config = await fetchRemoteConfig() // called at most once per 5 min
return NextResponse.json(config)
}You can also add Cache-Control headers manually to control CDN and browser caching independently from Next.js's internal cache. For understanding how JSON data flows from server to client, the Fetch API JSON guide covers the client side in detail.
Frequently asked questions
How do I create an API route that returns JSON in Next.js App Router?
Create a file at app/api/[route]/route.ts and export a named async function for each HTTP method you want to handle — GET, POST, PUT, DELETE, or PATCH. Return JSON by calling NextResponse.json(data, { status: 200 }), which automatically sets Content-Type: application/json and serializes the object. Example: export async function GET() { return NextResponse.json({ message: "hello" }, { status: 200 }) }. The route segment can be any folder path under app/api/. You no longer use the Pages Router pattern of export default function handler(req, res) { res.json(...) } — each HTTP method is a separate named export in the App Router. NextResponse is imported from "next/server". If you omit the status option, it defaults to 200. For a full walkthrough of REST API JSON patterns, see the REST API JSON response guide.
How do I read a JSON request body in a Next.js API route?
In App Router route handlers, the first parameter is a standard Web API Request object. Call await request.json() to parse the body as JSON. This method returns a Promise that resolves to the parsed value. Always wrap it in a try/catch because it throws a SyntaxError if the request body is not valid JSON. Example: export async function POST(request: Request) { try { const body = await request.json(); return NextResponse.json({ received: body }); } catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); } }. The request.json() call consumes the body stream — you cannot call it twice. If you need the raw text as well, use await request.text() instead and parse manually with JSON.parse(). You do not need to configure bodyParser in App Router — it is handled automatically.
How do I return a custom HTTP status code with JSON in Next.js?
Pass a second options argument to NextResponse.json() with a status property: NextResponse.json(data, { status: 201 }) for 201 Created, NextResponse.json({ error: "Not found" }, { status: 404 }) for 404. You can also set custom response headers in the same options object using a headers property: NextResponse.json(data, { status: 200, headers: { "X-Custom-Header": "value" } }). Common status codes for JSON APIs: 200 for successful GET, 201 for successful resource creation (POST), 204 for successful DELETE with no body (use new Response(null, { status: 204 }) since NextResponse.json() always sets a body), 400 for bad request, 401 for unauthenticated, 403 for forbidden, 404 for not found, 422 for validation failure, 500 for internal server error. Always return consistent JSON error shapes so clients can reliably parse error messages. For a comparative look at Express JSON API status code patterns, see that guide.
How do I validate JSON input in a Next.js API route with Zod?
Install Zod (npm install zod), define a schema with z.object(), and call schema.safeParse(body) after reading the request body. safeParse returns { success: true, data } or { success: false, error } without throwing — use success to branch on valid/invalid input. Example: const result = schema.safeParse(await request.json()); if (!result.success) { return NextResponse.json({ errors: result.error.flatten().fieldErrors }, { status: 422 }); }. The error.flatten().fieldErrors object maps field names to arrays of error messages, making it easy to return structured validation errors that client-side forms can display per field. Zod also infers TypeScript types: type Input = z.infer<typeof schema> gives you a typed variable at no extra cost. For JSON Schema-based validation as an alternative, see the JSON.stringify() tutorial.
What is the difference between Edge Runtime and Node.js Runtime for JSON in Next.js?
Next.js route handlers run in Node.js Runtime by default. You can opt into Edge Runtime by adding export const runtime = "edge" to the route file. Edge Runtime uses V8 isolates deployed close to the user (e.g., Vercel Edge Network), giving cold-start latency under 1 ms vs 50–200 ms for Node.js cold starts. Both runtimes support NextResponse.json() and request.json() identically. The key difference is available APIs: Edge Runtime does not support Node.js built-ins like fs, crypto, path, or most npm packages that depend on them. Edge Runtime supports the Web Crypto API, fetch, and Web Streams. For pure JSON transformation logic with no file system or database access, Edge Runtime is faster. For database queries, file operations, or packages that require Node.js APIs, use the default Node.js Runtime. You can mix runtimes across routes in the same Next.js project.
How do I handle JSON parse errors in Next.js API routes?
Wrap await request.json() in a try/catch block and return a 400 response when a SyntaxError is caught. A SyntaxError is thrown when the request body is not valid JSON — for example, if the client sends malformed JSON, an empty body, or a non-JSON Content-Type. Example: try { const body = await request.json(); /* proceed */ } catch (err) { if (err instanceof SyntaxError) { return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 }); } return NextResponse.json({ error: "Internal server error" }, { status: 500 }); }. Also consider checking the Content-Type header before calling request.json(): if it is not application/json, return 415 Unsupported Media Type. A robust handler validates at least 3 things: the body parses as JSON, the parsed value is an object (not null, array, or primitive), and the object has the expected shape (using Zod or similar). Use Jsonic's JSON formatter to test and validate your request payloads during development before sending them to your API.
Ready to build your Next.js JSON API?
Use Jsonic's JSON Formatter to validate and prettify response payloads while you develop, and the JSON Diff tool to compare before/after API responses during testing.
Open JSON Formatter