Next.js API Route JSON Response: Route Handlers & NextResponse.json()
Last updated:
Next.js App Router replaces pages/api with Route Handlers in app/api/route.ts — export a named GET, POST, PUT, or DELETE function that returns a NextResponse.json(data, { status }) response. NextResponse.json() sets Content-Type: application/json and serializes the body in one call; it also accepts a second argument for status codes, headers, and cache-control. Server Actions ('use server') can return plain objects that Next.js serializes as JSON over the wire — no manual fetch() needed for form submissions and mutations.
This guide covers Route Handler patterns (GET/POST), reading JSON request bodies with request.json(), NextResponse.json() with status codes, fetch() caching strategies (cache: 'no-store' vs next: { revalidate: 60 }), Server Actions, and middleware JSON transformations.
Route Handlers: GET and POST in app/api/route.ts
Route Handlers are the App Router replacement for pages/api/*.ts. Create a file at app/api/[resource]/route.ts and export a named async function for each HTTP method. Each function receives a Request (or NextRequest) object and must return a Response. NextResponse.json() is the idiomatic way to return JSON — it handles JSON.stringify, sets the correct Content-Type header, and accepts an init object for status codes and additional headers. Route Handlers support both the Node.js runtime (default) and the Edge Runtime via the export const runtime = 'edge' segment config.
// app/api/users/route.ts
import { type NextRequest, NextResponse } from 'next/server'
// GET /api/users?page=1&limit=20
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 users = await db.user.findMany({
skip: (page - 1) * limit,
take: limit,
select: { id: true, name: true, email: true, createdAt: true },
})
// NextResponse.json: serializes + sets Content-Type: application/json
return NextResponse.json({ page, limit, data: users })
}
// POST /api/users (body: { name: string; email: string })
export async function POST(request: NextRequest) {
let body: unknown
try {
body = await request.json() // throws SyntaxError on invalid JSON
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const user = await db.user.create({ data: body as any })
// 201 Created + Location header
return NextResponse.json(
{ created: true, user },
{
status: 201,
headers: { Location: `/api/users/${user.id}` },
}
)
}
// DELETE /api/users/[id] lives in app/api/users/[id]/route.ts
// app/api/users/[id]/route.ts
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
await db.user.delete({ where: { id: params.id } })
return NextResponse.json({ deleted: true })
}
// OPTIONS — CORS preflight
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
})
}Avoid the older pattern new Response(JSON.stringify(data)) — you must set Content-Type: application/json manually and it is easy to forget. Route Handlers also support the PATCH and HEAD methods; export a function named PATCH or HEAD to handle them. Dynamic route segments are passed as the second parameter { params }. For Express.js JSON API patterns as a comparison, see the dedicated guide.
Reading JSON Request Bodies with request.json()
request.json() reads the request body stream, parses it as JSON, and returns the result. It is an async method — always await it. The method throws a SyntaxError if the body is not valid JSON or is empty; wrap every call in try/catch and return a 400 Bad Request response on failure. The body stream can only be consumed once — calling request.json() a second time returns an empty result. If you need to read the body more than once (e.g., for logging), clone the request first: const clone = request.clone().
// app/api/orders/route.ts
import { type NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
// ── Step 1: parse the body ─────────────────────────────────────────
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return NextResponse.json(
{ error: 'Request body must be valid JSON' },
{ status: 400 }
)
}
// ── Step 2: type-check and validate ───────────────────────────────
if (typeof rawBody !== 'object' || rawBody === null || Array.isArray(rawBody)) {
return NextResponse.json(
{ error: 'Body must be a JSON object' },
{ status: 400 }
)
}
// Safe cast after structural check
const body = rawBody as Record<string, unknown>
if (typeof body.productId !== 'string' || typeof body.quantity !== 'number') {
return NextResponse.json(
{ error: 'productId (string) and quantity (number) are required' },
{ status: 422 }
)
}
const order = await db.order.create({
data: { productId: body.productId, quantity: body.quantity },
})
return NextResponse.json({ order }, { status: 201 })
}
// ── Logging body without consuming the stream ─────────────────────────
export async function PUT(request: NextRequest) {
const cloned = request.clone() // clone before any read
const logBody = await cloned.json() // read clone for logging
console.log('[PUT /api/orders] body:', logBody)
const body = await request.json() // read original for processing
// ...process body
return NextResponse.json({ updated: true })
}
// ── Reading both JSON body and headers ────────────────────────────────
export async function PATCH(request: NextRequest) {
const contentType = request.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
return NextResponse.json(
{ error: 'Content-Type must be application/json' },
{ status: 415 }
)
}
const body = await request.json()
return NextResponse.json({ patched: true, received: body })
}A common mistake is calling request.json() without await — the unresolved Promise is then used as the body, causing subtle type errors downstream. TypeScript catches this when the function is typed correctly: the return type of request.json() is Promise<unknown>, so the compiler forces the await. Always validate the shape of rawBody before treating it as a specific type — JSON can contain any value, including arrays, strings, or null.
NextResponse.json(): Status Codes, Headers, and Errors
NextResponse.json(data, init?) is the single call that covers serialization, content type, and HTTP status. The first argument is any JSON-serializable value. The optional second argument is a ResponseInit object: status (integer), statusText (string), and headers (object or Headers instance). Use standard HTTP status codes consistently — 200 for success, 201 for created resources, 400 for malformed requests, 401 for unauthenticated, 403 for forbidden, 404 for not found, 422 for validation failures, and 500 for server errors. Returning the correct status codes enables API clients, CDNs, and monitoring tools to behave correctly.
// app/api/products/[id]/route.ts
import { type NextRequest, NextResponse } from 'next/server'
type Params = { params: { id: string } }
export async function GET(_req: NextRequest, { params }: Params) {
const product = await db.product.findUnique({ where: { id: params.id } })
// 404 Not Found
if (!product) {
return NextResponse.json(
{ error: 'Product not found', code: 'PRODUCT_NOT_FOUND' },
{ status: 404 }
)
}
// 200 OK with cache-control header
return NextResponse.json(product, {
status: 200,
headers: { 'Cache-Control': 'public, max-age=60, stale-while-revalidate=300' },
})
}
export async function PUT(request: NextRequest, { params }: Params) {
// 401 Unauthorized — no token
const token = request.headers.get('Authorization')
if (!token) {
return NextResponse.json({ error: 'Authorization header required' }, { status: 401 })
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
try {
const updated = await db.product.update({ where: { id: params.id }, data: body as any })
return NextResponse.json({ updated: true, product: updated })
} catch (err) {
// 500 Internal Server Error — do not expose internal details
console.error('[PUT /api/products] error:', err)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// ── Centralized error handler ─────────────────────────────────────────
function apiError(message: string, status: number, extra?: Record<string, unknown>) {
return NextResponse.json({ error: message, ...extra }, { status })
}
// Usage:
// return apiError('Product not found', 404, { code: 'PRODUCT_NOT_FOUND' })
// return apiError('Rate limit exceeded', 429, { retryAfter: 60 })
// ── Rate-limit response with Retry-After ──────────────────────────────
export async function POST(request: NextRequest) {
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'unknown'
const limited = await rateLimit(ip) // returns true if over limit
if (limited) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429, headers: { 'Retry-After': '60' } }
)
}
const body = await request.json()
const product = await db.product.create({ data: body as any })
return NextResponse.json(product, { status: 201 })
}Never expose internal error details (stack traces, database error messages) in 500 responses — log them server-side and return a generic "Internal server error" message to the client. For JSON API design conventions including consistent error envelope schemas, see the dedicated guide.
fetch() JSON with Cache Strategies: no-store vs revalidate
Next.js extends the native fetch() in Server Components and Route Handlers with a Data Cache layer. Three cache modes control behavior: cache: 'force-cache' (default in Next.js 13/14) caches indefinitely; cache: 'no-store' bypasses all caching and fetches fresh data on every request; and next: { revalidate: N } implements ISR — serves cached data and regenerates in the background after N seconds. Tag-based on-demand revalidation (next: { tags: ['...'] } with revalidateTag()) provides fine-grained cache invalidation without waiting for a TTL to expire. For a deep-dive, see the guide on JSON caching strategies.
// app/dashboard/page.tsx — Server Component (no 'use client')
import { revalidateTag } from 'next/cache'
export default async function DashboardPage() {
// ── no-store: fresh on every request ─────────────────────────────
// Use for: user-specific data, real-time dashboards, session data
const me = await fetch('https://api.example.com/me', {
cache: 'no-store',
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
}).then(r => r.json())
// ── revalidate: ISR — serve cached, refresh in background ────────
// Use for: product listings, blog posts, public data that changes
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }, // stale for up to 60 seconds
}).then(r => r.json())
// ── force-cache: indefinitely cached ─────────────────────────────
// Use for: static config, rarely-changing reference data
const config = await fetch('https://api.example.com/config', {
cache: 'force-cache',
}).then(r => r.json())
// ── Tag-based revalidation ────────────────────────────────────────
// Cache with tags so you can invalidate on-demand
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'], revalidate: 300 },
}).then(r => r.json())
// ── Parallel fetches — avoid sequential waterfall ─────────────────
const [stats, categories] = await Promise.all([
fetch('https://api.example.com/stats', { next: { revalidate: 30 } }).then(r => r.json()),
fetch('https://api.example.com/categories', { next: { revalidate: 3600 } }).then(r => r.json()),
])
return <div>{/* render me, products, posts, stats, categories */}</div>
}
// ── On-demand revalidation in a Route Handler or Server Action ────────
// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const { tag } = await request.json()
// Purge all cache entries tagged with 'tag'
revalidateTag(tag) // e.g., revalidateTag('posts') after a new post is published
return NextResponse.json({ revalidated: true, tag })
}Next.js deduplicates identical fetch() calls within a single render pass — multiple Server Components requesting the same URL and options share one network request per render. This is separate from the persistent Data Cache: deduplication is per-request (in-memory memoization), while the Data Cache persists across requests on the same server instance. In Next.js 15, the default behavior changed to cache: 'no-store' for most routes — opt in to caching explicitly with force-cache or revalidate.
Server Actions: JSON Mutations Without fetch()
Server Actions are async functions marked with 'use server' that run exclusively on the server. When called from a Client Component, Next.js automatically serializes arguments and return values as JSON over an internal HTTP POST request — no manual fetch(), no API route, no Content-Type header needed. Return a plain serializable object (not a Response); Next.js handles the transport. Use useActionState (React 19) or useFormState (React 18 with the Next.js canary) to consume the return value in a Client Component. Server Actions are the recommended pattern for form submissions and mutations in the App Router.
// app/actions.ts — file-level 'use server' applies to all exports
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).optional().default([]),
})
// Server Action — return a serializable object, NOT a Response
export async function createPost(
_prevState: unknown,
formData: FormData
): Promise<{ success: boolean; post?: { id: string; title: string }; errors?: Record<string, string[]> }> {
const raw = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.getAll('tags'),
}
const result = CreatePostSchema.safeParse(raw)
if (!result.success) {
// Return validation errors as JSON-serializable object
return { success: false, errors: result.error.flatten().fieldErrors }
}
const post = await db.post.create({ data: result.data })
revalidatePath('/posts')
revalidateTag('posts')
// Next.js serializes this return value to JSON automatically
return { success: true, post: { id: post.id, title: post.title } }
}
// app/posts/create-form.tsx — Client Component
'use client'
import { useActionState } from 'react' // React 19
import { createPost } from '@/app/actions'
export function CreatePostForm() {
const [state, action, isPending] = useActionState(createPost, null)
return (
<form action={action}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Publishing...' : 'Publish'}
</button>
{state?.errors && (
<ul>
{Object.entries(state.errors).map(([field, msgs]) => (
<li key={field}><strong>{field}</strong>: {(msgs as string[]).join(', ')}</li>
))}
</ul>
)}
{state?.success && <p>Post published! ID: {state.post?.id}</p>}
</form>
)
}Server Action return values must be JSON-serializable — plain objects, arrays, strings, numbers, booleans, and null. Do not return Date objects, Map, Set, class instances, or functions — they cannot be serialized over the wire. If you need to pass a date, convert it to an ISO string first: createdAt: post.createdAt.toISOString(). The bodySizeLimit for Server Action payloads defaults to 1MB; increase it in next.config.ts under experimental.serverActions.bodySizeLimit for large form payloads.
Middleware: Transforming JSON Requests and Responses
Next.js middleware (middleware.ts at the project root) runs on the Edge Runtime before every matched request. NextRequest provides .json() for reading the body, but consuming the body stream in middleware means the downstream Route Handler receives an empty body. The safe pattern is to inspect routing decisions from headers, URL parameters, or JWT claims — and leave JSON body parsing to the Route Handler. Middleware excels at CORS headers, auth checks, request rewrites, and injecting data into request headers for downstream use.
// middleware.ts (project root — next to app/)
import { type NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
// ── CORS preflight ─────────────────────────────────────────────────
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
})
}
// ── Auth check — inspect Authorization header, not JSON body ───────
if (pathname.startsWith('/api/protected')) {
const auth = request.headers.get('Authorization') ?? ''
if (!auth.startsWith('Bearer ')) {
// Short-circuit: Route Handler is never called
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Decode JWT claim and forward as a header (safe, does not read body)
const userId = decodeUserIdFromToken(auth.slice(7))
const response = NextResponse.next()
response.headers.set('x-user-id', userId) // available in Route Handler
return response
}
// ── Add CORS headers to all /api/* responses ───────────────────────
if (pathname.startsWith('/api/')) {
const response = NextResponse.next()
response.headers.set('Access-Control-Allow-Origin', '*')
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
return response
}
// ── Version rewrite: /api/v1/* → /api/v2/* ─────────────────────────
if (pathname.startsWith('/api/v1/')) {
const url = request.nextUrl.clone()
url.pathname = url.pathname.replace('/api/v1/', '/api/v2/')
return NextResponse.rewrite(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/api/:path*'],
}
// ── In the Route Handler: read the injected header ─────────────────────
// app/api/protected/profile/route.ts
import { type NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const userId = request.headers.get('x-user-id') // set by middleware
const profile = await db.user.findUnique({ where: { id: userId! } })
return NextResponse.json(profile)
}If you must read the JSON body in middleware (not recommended), clone the request before reading: const body = await request.clone().json(). However, even with cloning, the original request body stream is consumed — NextResponse.next() will forward the request without the body, causing Route Handlers to receive an empty body. Reserve body parsing for Route Handlers; use middleware only for header-based decisions and routing logic.
Type-Safe JSON with Zod and TypeScript in Route Handlers
Zod is the standard validation library for Next.js Route Handlers. schema.safeParse(body) never throws — it returns a discriminated union { success: true, data } or { success: false, error }, making it safe to call on request.json() output without additional try/catch. The parsed data is fully typed: TypeScript infers the type from the schema, so downstream code gets autocomplete and type safety. Use z.infer<typeof Schema> to extract the TypeScript type for reuse in service functions and database calls. For more patterns, see the guide on TypeScript JSON patterns.
// app/api/products/route.ts
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
// ── Define schema ─────────────────────────────────────────────────────
const CreateProductSchema = z.object({
name: z.string().min(1, 'Name is required').max(200),
price: z.number().positive('Price must be positive'),
category: z.enum(['electronics', 'clothing', 'food', 'other']),
description: z.string().optional(),
tags: z.array(z.string().max(50)).max(10).default([]),
inStock: z.boolean().default(true),
})
// Extract TypeScript type from schema — single source of truth
type CreateProductInput = z.infer<typeof CreateProductSchema>
// ── Service function with full type safety ────────────────────────────
async function createProductInDb(data: CreateProductInput) {
return db.product.create({ data })
}
export async function POST(request: NextRequest) {
// Step 1: parse body (handle malformed JSON)
let raw: unknown
try {
raw = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
// Step 2: validate with Zod — never throws, returns discriminated union
const result = CreateProductSchema.safeParse(raw)
if (!result.success) {
return NextResponse.json(
{
error: 'Validation failed',
// flatten() gives per-field error arrays — ideal for form error display
fields: result.error.flatten().fieldErrors,
// issues gives full Zod error detail with paths
issues: result.error.issues.map(i => ({ path: i.path, message: i.message })),
},
{ status: 422 }
)
}
// Step 3: result.data is fully typed as CreateProductInput
const product = await createProductInDb(result.data)
return NextResponse.json(product, { status: 201 })
}
// ── GET with query param validation ───────────────────────────────────
const ListQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
category: z.enum(['electronics', 'clothing', 'food', 'other']).optional(),
})
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
// Convert URLSearchParams to a plain object for Zod
const params = Object.fromEntries(searchParams.entries())
const parsed = ListQuerySchema.safeParse(params)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid query parameters', fields: parsed.error.flatten().fieldErrors },
{ status: 400 }
)
}
// parsed.data.page, .limit, .category are typed
const { page, limit, category } = parsed.data
const products = await db.product.findMany({
where: category ? { category } : undefined,
skip: (page - 1) * limit,
take: limit,
})
return NextResponse.json({ page, limit, data: products })
}
// ── Shared response type for API clients ──────────────────────────────
// Export the schema and inferred type for use in client-side type checking
export type { CreateProductInput }
export { CreateProductSchema }Use z.coerce.number() for query parameters — URL search params are always strings, and coercion converts "20" to 20 automatically. Without coercion, z.number() would fail on string query params. For nested JSON payloads with deeply typed structures, Zod's z.object(), z.array(), z.union(), and z.discriminatedUnion() compose naturally. See the dedicated guide on JSON API design for error envelope conventions and response shape standards.
Key Terms
- Route Handler
- A Next.js App Router file (
route.tsorroute.js) placed inside theapp/directory that exports named async functions for HTTP methods (GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS). Route Handlers replace the Pages Routerpages/api/*.tsAPI Routes. They receive aRequest(orNextRequest) and must return aResponse. They support both the Node.js runtime (default) and Edge Runtime (export const runtime = 'edge'). Dynamic segments are received as the second parameter:{ params: { id: string } }. - NextResponse.json()
- A static method on the Next.js
NextResponseclass (fromnext/server) that creates a JSON HTTP response in a single call. It callsJSON.stringify()on the first argument, setsContent-Type: application/jsonautomatically, and accepts an optional secondResponseInitargument withstatus,statusText, andheaders. It is functionally equivalent toResponse.json()(standard Web API) but additionally provides cookie helpers and Next.js routing methods. Always prefer it overnew Response(JSON.stringify(data))which requires manualContent-Typeheader setting. - Server Action
- An async function marked with the
'use server'directive that executes exclusively on the server. When invoked from a Client Component, Next.js serializes the call as an HTTP POST to an internal endpoint, executes the function server-side, and serializes the return value back as JSON to the client. No explicitfetch(), URL, or API route is needed. Arguments and return values must be JSON-serializable. Server Actions are the idiomatic Next.js App Router pattern for form submissions, mutations, and any operation requiring server-side execution from Client Components. - ISR (Incremental Static Regeneration)
- A Next.js rendering and caching strategy that serves a statically cached response immediately and regenerates it in the background after a specified number of seconds. Configured via
{ next: { revalidate: N } }infetch()options or therevalidateoption inunstable_cache. ISR implements the stale-while-revalidate HTTP cache pattern: the stale cached response is served to the current request while the regeneration happens asynchronously. TheN-second window means at most one background revalidation per interval, preventing thundering-herd cache stampedes. - revalidate
- The number of seconds after which a cached
fetch()response orunstable_cacheentry is considered stale and scheduled for background revalidation. Set via{ next: { revalidate: 60 } }infetch()options. A value of0disables caching (equivalent tocache: 'no-store').falsemeans cache indefinitely (equivalent tocache: 'force-cache'). On-demand revalidation before the TTL expires is possible viarevalidateTag(tag)orrevalidatePath(path)— both imported fromnext/cacheand callable from Server Actions and Route Handlers. - middleware
- A Next.js file (
middleware.ts) at the project root that exports amiddleware(request: NextRequest)function running on the Edge Runtime before every matched request. Middleware can modify request and response headers, perform redirects and rewrites viaNextResponse.redirect()andNextResponse.rewrite(), short-circuit requests withNextResponse.json(), or forward them withNextResponse.next(). Theconfig.matcherexport controls which paths trigger the middleware. Middleware runs before Route Handlers — avoid reading the request JSON body in middleware because doing so consumes the stream and leaves Route Handlers with an empty body.
FAQ
How do I create a JSON API route in Next.js App Router?
Create a file at app/api/[name]/route.ts and export a named async function for each HTTP method: GET, POST, PUT, or DELETE. Each function receives a Request or NextRequest object and returns a Response. Use NextResponse.json(data, { status }) to return JSON — it sets Content-Type: application/json and serializes the body automatically. Example: export async function GET() { return NextResponse.json({ ok: true }) }. Route Handlers replace the pages/api directory and support both the Node.js runtime and the Edge Runtime via export const runtime = 'edge'.
How do I read a JSON request body in a Next.js Route Handler?
Call await request.json() inside your Route Handler. Always wrap it in try/catch — it throws a SyntaxError if the body is not valid JSON or is empty. Return a 400 Bad Request response on failure. The body stream can only be consumed once — if you need to read it more than once, clone the request first: const clone = request.clone(); const body = await clone.json(). After parsing, always validate the shape of the body (with Zod or manual checks) before using it, because request.json() returns unknown.
How do I return a JSON error response in Next.js?
Use NextResponse.json(errorObject, { status: N }) with the appropriate HTTP status code. For malformed JSON bodies, use 400; for validation failures, use 422; for missing resources, use 404; for unauthorized requests, use 401; for forbidden, use 403; for rate limiting, use 429 with a Retry-After header. Include a human-readable error message and optionally a machine-readable code field: NextResponse.json({ error: "Not found", code: "USER_NOT_FOUND" }, { status: 404 }). Never expose stack traces or internal error details in 500 responses — log them server-side only.
What is the difference between NextResponse.json() and Response?
Both NextResponse.json() and Response.json() serialize data to JSON and set Content-Type: application/json automatically — the behavior is equivalent for returning JSON from Route Handlers. The difference is that NextResponse (from next/server) is a Next.js subclass of Response that adds cookie helpers (response.cookies.set()) and middleware routing methods (NextResponse.next(), NextResponse.redirect(), NextResponse.rewrite()). In middleware, you must use NextResponse because Response.next() does not exist. In Route Handlers, either works — prefer NextResponse.json() when you also need to set cookies, otherwise Response.json() requires no import.
How does Next.js cache fetch() JSON responses?
Next.js extends fetch() in Server Components with a Data Cache. Three modes: cache: 'force-cache' caches indefinitely (the historical default in Next.js 13/14); cache: 'no-store' fetches fresh on every request (the new default in Next.js 15); { next: { revalidate: N } } caches for N seconds (ISR — serves stale, regenerates in background). Use { next: { tags: ["my-tag"] } } combined with revalidateTag('my-tag') in a Server Action or Route Handler for on-demand cache invalidation triggered by data mutations.
How do I use Server Actions to submit JSON data?
Mark an async function with 'use server' and return a plain serializable object — Next.js handles JSON serialization and transport automatically. Bind the action to a form via the action prop: <form action={myServerAction}>. No manual fetch(), URL, or Content-Type header needed. In React 19, use useActionState(action, initialState) to get the return value in a Client Component. Return values must be JSON-serializable — no Date, Map, Set, or class instances. Convert dates to ISO strings before returning.
How do I validate JSON input in a Next.js Route Handler?
Use Zod. Define a schema with z.object({ ... }), then call schema.safeParse(body) on the parsed request body. safeParse() never throws — it returns { success: true, data } (fully typed) or { success: false, error }. On failure, return a 422 Unprocessable Entity response with result.error.flatten().fieldErrors for per-field error arrays. On success, result.data is TypeScript-typed from the schema — no type assertions needed. Use z.coerce.number() for query parameters since URL search params are always strings. Use z.infer<typeof Schema> to reuse the inferred TypeScript type in service functions.
How do I handle CORS in Next.js Route Handlers?
Three approaches: (1) Per-Route-Handler: add CORS headers to the NextResponse second argument and export an OPTIONS function for preflight. (2) Middleware: add CORS headers to all /api/* responses in middleware.ts via NextResponse.next() and handle OPTIONS by returning a 204 response — this avoids repeating headers in every Route Handler. (3) next.config.ts headers() function: apply CORS headers to matched routes statically. For production APIs with specific origins, replace '*' with the allowed origin and include Vary: Origin in the response headers to prevent CDN cache collisions between origins.
Further reading and primary sources
- Next.js Route Handlers Docs — Official Next.js documentation for Route Handlers in the App Router
- Next.js Server Actions and Mutations — Official guide to Server Actions, useActionState, and mutations in Next.js App Router
- Next.js Data Fetching: Fetching and Caching — fetch() cache options, ISR revalidate, tags, and Data Cache reference
- Zod Documentation — TypeScript-first schema validation library used for Route Handler input validation
- Next.js Middleware Docs — Official reference for middleware.ts — Edge Runtime, matchers, and NextResponse methods