Skip to main content
Next.js learning series · Blog 10

Next.js 16 Route Handlers Explained

Build standards-based HTTP endpoints with route.ts, validate every request, secure protected data, and choose the right boundary between your app and external clients.

An incoming HTTP request reaches route.ts, branches into five HTTP methods, and returns a JSON response

Server Actions are ideal for many mutations inside a Next.js UI, but sometimes you need a real HTTP endpoint for a mobile app, webhook, external service, or public API. That is where Next.js Route Handlers fit. They use the Web Request and Response APIs and live inside the App Router.

This tutorial continues from Blog #9 on error handling. You will build GET, POST, PUT, PATCH, and DELETE endpoints; read JSON, forms, parameters, headers, and cookies; then add validation, authentication, CORS, webhook verification, caching, and consistent responses.

Route Handlers at a Glance

QuestionAnswer
Where?route.ts inside app
Input?Standard Request or extended NextRequest
Output?Standard Response or NextResponse
Methods?GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
Default GET caching?Dynamic, not cached by default
Project version check

The downloadable Next.js starter declares "next": "latest" and has no lockfile or installed dependency tree in this website backup. Therefore, an exact patch version cannot be verified from the repository. The examples below target the current official Next.js 16 App Router documentation; pin a real version and commit its lockfile before using “exact version” in deployment records.

Diagram 1: File system to API endpointThe route file at app/api/posts/route.ts owns the /api/posts URL and dispatches requests to the matching exported HTTP method.

What Is route.ts?

route.ts is the App Router file convention for a custom request handler. Export a function named for each supported HTTP method. A route.ts file cannot share the same route segment level as page.tsx, because both would own the same URL.

app/api/hello/route.ts
export async function GET() {
  return Response.json({
    message: 'Hello from Next.js',
  })
}

Response.json() serializes the value, sets a JSON content type, and returns a standard Web Response. Use NextResponse only when its Next.js-specific helpers improve the endpoint.

Your First GET Route

Tutorial mock data
export async function GET() {
  const posts = [
    { id: 1, title: 'Next.js' },
    { id: 2, title: 'React' },
  ]

  return Response.json({ data: posts })
}

The exported name selects the HTTP method. The returned Response becomes the network response. This array is deliberately mock data; production handlers should call a reusable server-only data function.

Diagram 2: GET request flowA client GET reaches route.ts, runs GET, reads trusted server data, and returns JSON to the caller.
  1. Client
  2. GET /api/posts
  3. route.ts → GET()
  4. Read data
  5. 200 JSON
A browser sends GET and POST requests through route.ts to 200 JSON and 201 Created responses
One route, method-specific contracts. The URL stays the same while the HTTP method selects read or create behavior.

Handling POST Requests and JSON

Minimal teaching example
export async function POST(request: Request) {
  const body: unknown = await request.json()

  // Validate body before using it in production.
  return Response.json(
    { received: body },
    { status: 201 }
  )
}

request.json() parses JSON; it does not validate required fields, types, lengths, ownership, or permission. A request body is untrusted even when your own form sent it. Do not pass the whole body into a database insert: mass assignment can let callers set fields such as role, ownerId, or published.

Diagram 3: POST request flowJSON is parsed, validated, authorized, written through a server data function, and acknowledged with 201 Created.
  1. POST JSON
  2. Parse once
  3. Validate
  4. Authorize + write
  5. 201 Created

Reading FormData

Form body
export async function POST(request: Request) {
  const formData = await request.formData()
  const name = formData.get('name')

  if (typeof name !== 'string' || name.trim().length < 2) {
    return Response.json({ error: 'Invalid name' }, { status: 400 })
  }

  return Response.json({ data: { accepted: true } }, { status: 201 })
}

JSON is convenient for application APIs. FormData matches browser forms and file uploads. Both require validation and input-size limits. Read a request body only once; its stream is consumed.

URL Search Parameters and Safe Pagination

GET /api/posts?page=2&limit=10
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const rawPage = Number(searchParams.get('page') ?? '1')
  const rawLimit = Number(searchParams.get('limit') ?? '20')

  const page = Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1
  const limit = Number.isInteger(rawLimit)
    ? Math.min(100, Math.max(1, rawLimit))
    : 20

  return Response.json({ data: [], meta: { page, limit } })
}

Query values are strings. Parse them, handle NaN, reject or normalize invalid values, and cap limits so ?limit=1000000 cannot turn one request into an abusive database scan.

Dynamic Route Parameters

Put the item endpoint at app/api/posts/[id]/route.ts. In current Next.js 16, params is a Promise. The globally generated RouteContext helper can provide route-literal types after next dev, next build, or next typegen.

app/api/posts/[id]/route.ts
export async function GET(
  _request: Request,
  context: RouteContext<'/api/posts/[id]'>
) {
  const { id } = await context.params
  const post = await getPostById(id)

  if (!post) {
    return Response.json({ error: 'Post not found' }, { status: 404 })
  }

  return Response.json({ data: post })
}
Diagram 4: Dynamic route parametersThe URL value 42 matches the id folder segment and becomes the awaited params.id string.
/api/posts/42app/api/posts/[id]/route.tsawait params → id = "42"

PUT vs PATCH vs DELETE

MethodTypical purposeCommon success
GETRead200
POSTCreate or trigger an action201 or 200
PUTReplace a complete representation200 or 204
PATCHUpdate selected fields200 or 204
DELETERemove200 or 204

These are HTTP conventions, not business rules enforced by Next.js. Document your contract. A 204 No Content response must not include a JSON body. When a supported method is not exported, Next.js returns 405 Method Not Allowed. It can automatically implement OPTIONS with an appropriate Allow header when you do not define one.

Request, Response, NextRequest, and NextResponse

Prefer Web APIs when they are enough: request.url, request.headers, request.json(), request.formData(), and Response.json(). NextRequest extends Request with features such as parsed nextUrl and cookie access. NextResponse extends Response with Next.js helpers. They are useful, not mandatory.

Next-specific request URL
import type { NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  const q = request.nextUrl.searchParams.get('q')
  return Response.json({ data: { q } })
}

Headers and Cookies

Standard request and response headers
export async function GET(request: Request) {
  const requestId = request.headers.get('x-request-id')

  return Response.json(
    { data: { requestId } },
    { headers: { 'X-Content-Type-Options': 'nosniff' } }
  )
}

Header names are case-insensitive. Never log raw authorization headers. For cookies, Next.js 16 uses the asynchronous cookies() API. Let your existing authentication system create session cookies; do not hand-roll auth tokens.

Async cookies in a Route Handler
import { cookies } from 'next/headers'

export async function POST() {
  const cookieStore = await cookies()
  cookieStore.set('preferences', 'compact', {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
  })
  return Response.json({ data: { saved: true } })
}

Status Codes and Response Shapes

CodeMeaningExample
200OKSuccessful read or update
201CreatedNew resource
204No ContentSuccessful bodyless response
400Bad RequestInvalid input
401UnauthorizedAuthentication required
403ForbiddenKnown caller lacks permission
404Not FoundResource absent
409ConflictDuplicate or state conflict
429Too Many RequestsRate limit
500Internal Server ErrorUnexpected failure

Do not return 200 for every outcome. Keep success and error envelopes consistent, for example { "data": ... } and { "error": { "code": "INVALID_INPUT", "message": "..." } }. Consistency matters more than one universal shape.

Validate Every API Request

The downloadable starter does not include a validation package, so this tutorial does not pretend Zod is installed. Reuse the project schema library when one exists. Otherwise, validate an unknown value and create a new safe object containing only allowed fields.

Dependency-free boundary validation
type CreatePostInput = { title: string; content: string }

function readCreatePost(value: unknown): CreatePostInput | null {
  if (!value || typeof value !== 'object') return null
  const input = value as Record<string, unknown>
  if (typeof input.title !== 'string' || typeof input.content !== 'string') {
    return null
  }

  const title = input.title.trim()
  const content = input.content.trim()
  if (title.length < 3 || title.length > 120 || content.length === 0) {
    return null
  }
  return { title, content }
}
Diagram 5: API validation flowA parsed body either becomes a narrow trusted input for business logic or produces a 400 response.
Request bodyParse JSONValidate allowed fields
Valid → logicInvalid → 400

Authentication and Authorization

Authentication answers “who is calling?” Authorization answers “may this caller perform this operation on this resource?” A valid session does not permit every delete or admin read. Resolve identity through the project's real auth utility, then check role, ownership, tenant, and resource policy on the server. Never trust a client body such as { "role": "admin" } as proof.

A secure HTTP request pipeline with authentication, authorization, validation, business logic, database access, and a safe response
Every protected write crosses explicit gates. Authentication, authorization, and validation failures use different statuses before business logic touches the database.
Diagram 6: Secure Route Handler pipelineA protected request passes authentication, authorization, and validation before business logic can access the database and return a safe response.
  1. Request
  2. Authenticate
  3. Authorize
  4. Validate
  5. Business logic
  6. Database
  7. Safe response

Handling Route Handler Errors

Translate expected failures close to the boundary: invalid input to 400, missing data to 404, and conflicts to 409. Log unexpected failures on the server with safe context, then return a generic 500 response. The Next.js error handling guide explains why raw database errors, stack traces, tokens, and request headers must stay out of public responses.

Safe unexpected-error response
export async function GET() {
  try {
    return Response.json({ data: await getPosts() })
  } catch (error) {
    console.error('GET /api/posts failed', {
      errorType: error instanceof Error ? error.name : 'UnknownError',
    })
    return Response.json(
      { error: { code: 'INTERNAL_ERROR', message: 'Unable to load posts.' } },
      { status: 500 }
    )
  }
}

Keep framework control-flow helpers such as redirects outside broad catch blocks so they are not accidentally swallowed.

CORS Is Not Authentication

CORS tells browsers which origins may read a cross-origin response. It does not stop servers, scripts, or command-line clients from calling the endpoint. Allow specific trusted origins, methods, and headers; avoid reflecting arbitrary origins, especially with credentials.

Explicit preflight example
const corsHeaders = {
  'Access-Control-Allow-Origin': 'https://app.example.com',
  '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 })
}

Return the matching CORS headers on the actual GET or POST response too; a successful preflight alone does not make the final browser response readable.

Diagram 7: CORS and authentication solve different problemsCORS controls which browser origin can read a response; authentication establishes caller identity; authorization decides the permitted operation.
CORSWhich browser origin?
AuthenticationWho is calling?
AuthorizationWhat may they do?

Secure Webhook Endpoints

A Route Handler is a natural webhook receiver, but the sender is outside your application. Read the raw body with request.text() before JSON parsing when the provider signs raw bytes. Verify the signature using the provider's official library or documented algorithm, use constant-time comparison where required, check timestamp tolerance, reject replayed event IDs, and make processing idempotent.

Provider-agnostic webhook skeleton
export async function POST(request: Request) {
  const rawBody = await request.text()
  const signature = request.headers.get('provider-signature')

  if (!signature || !verifyProviderSignature(rawBody, signature)) {
    return Response.json({ error: 'Invalid signature' }, { status: 401 })
  }

  const event = parseVerifiedEvent(rawBody)
  await processEventOnce(event.id, event)
  return Response.json({ received: true })
}
Diagram 8: Secure webhook flowThe raw provider payload and signature are verified before parsing, replay checks, idempotent processing, and acknowledgement.
  1. Provider POST
  2. Read raw body
  3. Verify signature
  4. Reject replay
  5. Process once
  6. 2xx acknowledgement

Caching and Revalidation

GET Route Handlers are dynamic by default in current Next.js 16. Do not repeat the old assumption that every GET endpoint is automatically static. Opt into caching only for responses that are safe to share. Personalized or cookie-dependent responses should remain dynamic. Depending on whether the project enables Cache Components, use the matching current caching model rather than mixing examples.

Simple explicit static GET
export const dynamic = 'force-static'

export async function GET() {
  return Response.json({ data: await getPublicCatalog() })
}

After mutations, revalidateTag(tag, 'max') can mark tagged cached data stale with stale-while-revalidate behavior, while revalidatePath() invalidates data associated with a path. Route Handlers can call both. See Blog #8 on caching and revalidation before adding cache policy.

Server Actions vs Route Handlers

A decision tree sends same-app forms to Server Actions and mobile, webhook, and service callers to Route Handlers
Choose by caller and contract. Same-app UI mutations often fit Server Actions; independently callable HTTP boundaries fit Route Handlers.
NeedUsually choose
Form mutation inside one Next.js UIServer Action
Mobile or third-party clientRoute Handler
Webhook receiverRoute Handler
Public REST endpointRoute Handler
Progressive-enhancement formServer Action
Diagram 9: Server Action or Route Handler?A same-application Next.js UI can use a Server Action, while external clients require a documented HTTP Route Handler.
Who calls it?
Next.js UI → Server ActionExternal client → Route Handler

Do not call your own Route Handler from a Server Component merely to reach the database. Import the same server-only service or repository function instead. This avoids a redundant HTTP hop and keeps data logic reusable, as explained in Blog #6 on data fetching.

If the caller boundary is unclear, revisit Server and Client Components: browser-only interactions and external consumers need different contracts from server-only code that can import a trusted data module directly.

Pages Router API Routes vs Route Handlers

Legacy Pages Router endpoints use pages/api/*.ts with Node-style request and response objects. App Router Route Handlers use route.ts and Web APIs. Keep stable existing Pages Router APIs until you have a reason and tests to migrate; do not mix both conventions for the same URL.

Complete CRUD API Architecture

A maintainable posts API uses two route files and shared server functions. This project has no ORM or database helper, so the example intentionally does not invent Prisma, Drizzle, or Mongoose calls.

Diagram 10: Posts API architectureThe collection endpoint lists and creates posts; the dynamic item endpoint reads, partially updates, and removes one post.
/api/postsGET → listPOST → create
/api/posts/:idGET → onePATCH → updateDELETE → remove
app/api/posts/route.ts
import { createPost, listPosts } from '@/server/posts'
import { readCreatePost } from '@/server/posts-validation'

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const posts = await listPosts({ cursor: searchParams.get('cursor') })
  return Response.json({ data: posts })
}

export async function POST(request: Request) {
  const user = await requireApiUser(request)
  if (!user) return Response.json({ error: 'Authentication required' }, { status: 401 })

  const input = readCreatePost(await request.json())
  if (!input) return Response.json({ error: 'Invalid request' }, { status: 400 })

  const post = await createPost(user.id, input)
  return Response.json({ data: post }, { status: 201 })
}
app/api/posts/[id]/route.ts
export async function PATCH(
  request: Request,
  context: RouteContext<'/api/posts/[id]'>
) {
  const user = await requireApiUser(request)
  if (!user) return Response.json({ error: 'Authentication required' }, { status: 401 })

  const { id } = await context.params
  const input = readPostPatch(await request.json())
  if (!isValidPostId(id) || !input) {
    return Response.json({ error: 'Invalid request' }, { status: 400 })
  }
  if (!(await canEditPost(user, id))) {
    return Response.json({ error: 'Forbidden' }, { status: 403 })
  }

  const post = await updatePost(id, input)
  return post
    ? Response.json({ data: post })
    : Response.json({ error: 'Post not found' }, { status: 404 })
}
DELETE the same dynamic resource
export async function DELETE(
  request: Request,
  context: RouteContext<'/api/posts/[id]'>
) {
  const user = await requireApiUser(request)
  if (!user) return Response.json({ error: 'Authentication required' }, { status: 401 })

  const { id } = await context.params
  if (!isValidPostId(id)) {
    return Response.json({ error: 'Invalid post ID' }, { status: 400 })
  }
  if (!(await canDeletePost(user, id))) {
    return Response.json({ error: 'Forbidden' }, { status: 403 })
  }

  const deleted = await deletePost(id)
  return deleted
    ? new Response(null, { status: 204 })
    : Response.json({ error: 'Post not found' }, { status: 404 })
}

Notice that 204 returns no JSON body. A PUT handler can reuse the same identity, ID, authorization, and validation pipeline but should require the documented complete resource representation. Public APIs may use a stable prefix such as /api/v1/posts; internal handlers do not need versioning by default.

Production Controls and Thin Handlers

Validation protects meaning, but production APIs also need resource controls. Enforce body and upload limits at the earliest reliable layer in your deployment, cap pagination, time out expensive upstream work, and rate-limit routes according to their cost and abuse risk. A password attempt, AI generation request, search query, and public cached GET do not need identical policies. Return 413 for a body that is too large and 429 when the caller exceeds a documented rate policy.

Keep route.ts focused on transport concerns: parse the request, resolve identity, validate allowed fields, call a service or domain function, and translate its result to HTTP. Put reusable business rules and database access in server-only modules. That architecture lets a Route Handler, Server Action, scheduled job, and Server Component call the same trusted function without making HTTP requests to one another.

Before deployment, test malformed JSON, wrong content types, missing and invalid credentials, denied ownership, duplicate requests, huge query limits, absent records, database failures, webhook replay, preflight requests, and 204 responses. Also confirm that logs redact authorization headers, cookies, tokens, signed webhook payloads, personal data, and database connection details.

Common Next.js Route Handler Mistakes

  • Using page.tsx when the URL should return an HTTP response rather than UI.
  • Placing page.tsx and route.ts at the same route segment level.
  • Treating request.json() as validation.
  • Returning 200 for authentication, validation, and server failures.
  • Exposing raw database errors or logging secrets.
  • Authenticating a user without authorizing the resource.
  • Calling an internal Route Handler unnecessarily from a Server Component.
  • Using a Route Handler for every same-app form mutation.
  • Assuming GET caching behavior from an older Next.js version.
  • Using CORS as security or accepting arbitrary credentialed origins.
  • Verifying a webhook after modifying its signed raw body.
  • Allowing unlimited pages, bodies, uploads, or request rates.

Next.js Route Handler Best Practices

  • Use standard Request and Response APIs when they are sufficient.
  • Keep handlers thin: parse, authenticate, validate, call a service, map the result.
  • Validate every external value, including path and query parameters.
  • Authenticate private endpoints and authorize each protected resource.
  • Return meaningful status codes and consistent safe envelopes.
  • Limit body, upload, pagination, and execution costs.
  • Verify webhook signatures before parsing and make delivery idempotent.
  • Rate-limit endpoints according to abuse and cost risk.
  • Share server-only data functions instead of making internal HTTP calls.
  • Cache only responses that are safe to share and deliberately revalidate them.
  • Document public request, response, error, and version contracts.

FAQ

What is a Route Handler in Next.js 16?

A Route Handler is an App Router endpoint defined in route.ts or route.js. It receives an HTTP request and returns a Web Response.

What is route.ts used for?

It defines custom HTTP handlers such as GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS for one route segment.

How do I create a GET API in the App Router?

Create app/api/posts/route.ts, export a GET function, and return a Response. Response.json is a convenient standards-based JSON response helper.

How do I handle POST requests in Next.js?

Export POST, read the body once with request.json, request.formData, or request.text, validate it, authorize the operation, and return an appropriate status.

What is the difference between Route Handlers and Server Actions?

Server Actions are designed for server-side mutations invoked by a Next.js UI. Route Handlers expose an HTTP contract for browsers, mobile apps, webhooks, and external services.

Can a Route Handler access a database?

Yes. It runs on the server and can call server-only data modules. Keep database and domain logic outside route.ts so pages, actions, and handlers can reuse it.

How do dynamic API routes work?

Place route.ts inside a dynamic segment such as app/api/posts/[id]. In Next.js 16, context.params is a Promise and must be awaited.

Are GET Route Handlers cached in Next.js 16?

No, not by default. GET Route Handlers are dynamic by default. Opt into caching deliberately only when the response is safe to share and the project caching model supports it.

How do I secure a Next.js API endpoint?

Validate all input, authenticate the caller, authorize the resource operation, limit abuse, protect secrets, and return only safe response data.

Can Route Handlers receive webhooks?

Yes. Read the raw body, verify the provider signature using its official SDK or documented algorithm, reject invalid or replayed events, and make processing idempotent.

Should Server Components call internal Route Handlers?

Usually not when both can call the same server-only data function. An internal HTTP hop adds latency and can complicate caching and deployment.

Do Route Handlers support CORS?

You can set CORS response headers and export OPTIONS for preflight behavior. CORS controls browser access; it is not authentication or authorization.

Official Resources

Next Steps

You can now design a real HTTP boundary with current Next.js 16 APIs, safe input handling, deliberate permissions, meaningful statuses, secure webhooks, and explicit caching. Continue with Blog #11 to describe every important page accurately for search engines and social platforms.

WhatsApp