| | |

Next.js 15 ▲ Route Handlers — Custom API endpoints with Web Request/Response

⏱️ Estimated read: 12 min | |

A Route Handler is a file that exports async functions named after HTTP methods. The function receives a standard Web Request object and returns a standard Web Response object. There is no Express-style middleware, no res.send(), and no separate server to start. The file lives inside the app directory at the URL path it should serve, and Next.js maps the exported functions to the corresponding HTTP methods .

This chapter covers the Route Handler convention: the route.ts file, the supported HTTP methods, the NextRequest and NextResponse extensions, dynamic route segments with awaited params, query parameter access, request body parsing, and the caching change in Next.js 15 that made GET handlers dynamic by default. It also covers the one rule that prevents the most common configuration error: a route.ts file cannot exist at the same route segment as a page.tsx file.

Key point: Route Handlers are not cached by default in Next.js 15. A GET handler that was implicitly static in Next.js 14 must now opt in with export const dynamic = 'force-static' to be prerendered.


Why Route Handlers replaced API Routes

The Pages Router API problem. In the Pages Router, API endpoints lived in pages/api/ and received Node.js req and res objects. They were tied to the Node runtime, required manual body parsing, and could not use the Web standard Request and Response types. Route Handlers use the Web APIs directly, which means the same handler code can run in Node.js or in an environment that supports the Web standard .

The colocation problem. An API route in the Pages Router lives at a fixed path under pages/api/. A Route Handler can live anywhere inside app, at the same route segment as a page. This allows a route to have both a UI (page.tsx) and an API (route.ts) at different paths, or an API endpoint nested deep inside a feature directory without a separate top-level api/ folder .

The caching inversion problem. In Next.js 14, GET Route Handlers were cached by default. This meant a handler that read from a database or an external API could return stale data without the developer realizing it. Next.js 15 changed the default: GET handlers are dynamic unless explicitly opted into static caching. The change makes the behavior predictable, but it breaks applications that relied on the old implicit caching .


a. The route file and supported methods

A Route Handler is defined in a route.ts (or route.js) file. The file exports async functions named GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. If a request arrives for a method that is not exported, Next.js returns a 405 Method Not Allowed response .

// app/api/health/route.ts
export async function GET() {
  return Response.json({ status: 'ok' })
}

export async function POST(request: Request) {
  const body = await request.json()
  return Response.json({ received: body }, { status: 201 })
}

The route.ts file cannot exist at the same route segment as a page.tsx. If app/dashboard/page.tsx exists, app/dashboard/route.ts is a conflict. The API endpoint must be at a different segment, such as app/api/dashboard/route.ts .

b. NextRequest and NextResponse

The native Request and Response objects are sufficient for most handlers. Next.js extends them with NextRequest and NextResponse for convenience. NextRequest provides nextUrl, which is a parsed URL object with a searchParams property. NextResponse provides static methods like json() and redirect() .

// app/api/search/route.ts
import type { NextRequest } from 'next/server'

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

The request.nextUrl.searchParams is a URLSearchParams object. It is the standard way to read query parameters in a Route Handler .

For redirects, the redirect() function from next/navigation can be called inside the handler. It throws a special error that Next.js catches and converts into a redirect response.

import { redirect } from 'next/navigation'

export async function GET() {
  redirect('https://nextjs.org/')
}

c. Dynamic route segments and awaited params

A Route Handler can use dynamic segments. The segment name is enclosed in brackets in the directory name, and the value is passed to the handler as the params property of the second argument. In Next.js 15, params is a Promise and must be awaited .

// app/api/users/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  return Response.json({ userId: id })
}

The synchronous form { params }: { params: { id: string } } produces a warning in Next.js 15 and will be an error in a future version. The codemod npx @next/codemod@latest async-params migrates existing handlers to the awaited form .

For a catch-all segment, the params value is an array. A route at app/api/files/[...path]/route.ts receives params.path as string[]. The optional catch-all form [[...path]] also matches the segment without any additional path .

d. Request body and response construction

The request body is read with the standard Web API methods. request.json() parses a JSON body and returns a Promise. request.text() returns the raw string. request.formData() parses a form submission .

export async function POST(request: Request) {
  const body = await request.json()
  // validate body
  return Response.json({ created: true }, { status: 201 })
}

The response can be constructed with Response.json() for JSON, or with the Response constructor for other content types. The second argument to Response.json() is an init object that accepts status and headers.

return new Response('<h1>Hello</h1>', {
  status: 200,
  headers: { 'Content-Type': 'text/html' },
})

e. Caching in Next.js 15

The caching default changed in Next.js 15. A GET handler is dynamic unless it is explicitly marked static. The dynamic = 'force-static' route segment config opts the handler into static generation .

// app/api/products/route.ts
export const dynamic = 'force-static'

export async function GET() {
  const products = await db.query('SELECT * FROM products')
  return Response.json(products)
}

When Cache Components is enabled, the model changes again. A GET handler runs at request time by default, can be prerendered if it does not access uncached or runtime data, and can use use cache inside a helper function to include uncached data in the static response. The use cache directive cannot be placed directly in the Route Handler body; it must be extracted to a helper .


Complete Example Session

// ============================================
// PART 1: THE BASIC ROUTE FILE
// ============================================
// app/api/hello/route.ts
export async function GET() {
  return Response.json({ message: 'Hello' })
}

// Accessible at /api/hello.
// No page.tsx at the same segment.
// ============================================
// PART 2: MULTIPLE METHODS
// ============================================
export async function GET() {
  return Response.json({ method: 'GET' })
}

export async function POST(request: Request) {
  const body = await request.json()
  return Response.json({ method: 'POST', body }, { status: 201 })
}

// Unsupported methods return 405 automatically.
// ============================================
// PART 3: QUERY PARAMETERS
// ============================================
import type { NextRequest } from 'next/server'

export function GET(request: NextRequest) {
  const page = request.nextUrl.searchParams.get('page') ?? '1'
  return Response.json({ page })
}

// GET /api/items?page=2 → { page: "2" }
// ============================================
// PART 4: DYNAMIC SEGMENT (AWAITED)
// ============================================
// app/api/users/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  return Response.json({ userId: id })
}

// params is a Promise in Next.js 15.
// Synchronous access produces a warning.
// ============================================
// PART 5: CATCH-ALL SEGMENT
// ============================================
// app/api/files/[...path]/route.ts
export async function GET(
  request: Request,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const { path } = await params
  return Response.json({ segments: path })
}

// /api/files/a/b/c → { segments: ["a", "b", "c"] }
// ============================================
// PART 6: REQUEST BODY
// ============================================
export async function POST(request: Request) {
  const data = await request.json()
  return Response.json({ received: data })
}

// request.json() can only be called once.
// ============================================
// PART 7: CUSTOM RESPONSE HEADERS
// ============================================
export async function GET() {
  return new Response('Cached', {
    headers: { 'Cache-Control': 'public, max-age=3600' },
  })
}
// ============================================
// PART 8: STATIC OPT-IN (NEXT.JS 15)
// ============================================
export const dynamic = 'force-static'

export async function GET() {
  return Response.json({ built: 'at build time' })
}

// Without this, the GET is dynamic.
// ============================================
// PART 9: REDIRECT
// ============================================
import { redirect } from 'next/navigation'

export async function GET() {
  redirect('https://nextjs.org/')
}

// redirect() throws; do not wrap in try/catch.
// ============================================
// PART 10: TYPED CONTEXT WITH RouteContext
// ============================================
// app/api/orders/[orderId]/route.ts
export async function GET(
  request: Request,
  ctx: RouteContext<'/api/orders/[orderId]'>
) {
  const { orderId } = await ctx.params
  return Response.json({ orderId })
}

// RouteContext is globally available after type generation.

The ten parts covered the basic route file, multiple methods, query parameters, dynamic segments, catch-all segments, request bodies, custom headers, static opt-in, redirect, and typed context.


Quick Reference

File convention

ElementExample
File nameroute.ts or route.js
LocationAnywhere inside app/
URL pathDirectory path relative to app/
ConflictCannot coexist with page.tsx at the same segment

Supported methods

MethodExport
GETexport async function GET(request)
POSTexport async function POST(request)
PUTexport async function PUT(request)
PATCHexport async function PATCH(request)
DELETEexport async function DELETE(request)
HEADexport async function HEAD(request)
OPTIONSexport async function OPTIONS(request)

Request properties

PropertyTypePurpose
request.json()PromiseParse JSON body
request.text()PromiseRaw string body
request.formData()PromiseForm data
request.nextUrl.searchParamsURLSearchParamsQuery parameters (NextRequest)
request.cookiesRequestCookiesRead cookies (NextRequest)

Dynamic params

Routeparams typeValue
[id]Promise<{ id: string }>{ id: '123' }
[...slug]Promise<{ slug: string[] }>{ slug: ['a', 'b'] }
[[...slug]]Promise<{ slug?: string[] }>{ slug: undefined } at base

Caching

BehaviorConfiguration
Dynamic (default in 15)No config
Staticexport const dynamic = 'force-static'
Revalidateexport const revalidate = 3600

Best Practices

✅ Do This:

// Await params in Next.js 15
const { id } = await params                                      // ✅
// Use Response.json() for JSON responses
return Response.json({ data }, { status: 200 })                  // ✅
// Opt into static caching explicitly
export const dynamic = 'force-static'                            // ✅
// Use RouteContext for typed params
ctx: RouteContext<'/api/users/[id]'>                             // ✅

❌ Don’t Do This:

// Access params synchronously
const id = params.id                                             // ❌ (warning)
// Place route.ts next to page.tsx
app/dashboard/page.tsx + app/dashboard/route.ts                  // ❌ (conflict)
// Assume GET is cached by default in Next.js 15
export async function GET() { return Response.json(data) }       // ❌ (dynamic)
// Read request body twice
await request.json(); await request.json()                       // ❌ (error)

Common Pitfalls

PitfallWhy It HappensFix
params warningSynchronous access in Next.js 15Await the params Promise
405 Method Not AllowedMethod not exportedAdd the missing export
Route conflict errorroute.ts next to page.tsxMove the API route to a different segment
GET returns stale dataCached when dynamic was expectedRemove force-static or add revalidate
GET is slow on every requestNot cached when static was expectedAdd export const dynamic = 'force-static'
Body parse errorWrong Content-Type or double readCheck header; read once

Real-World Examples

1. Health check endpoint

export async function GET() { return Response.json({ status: 'ok' }) }

2. Create resource

export async function POST(request: Request) {
  const body = await request.json()
  return Response.json({ id: 1 }, { status: 201 })
}

3. Dynamic user lookup

const { id } = await params
return Response.json({ id })

4. Query parameters

const page = request.nextUrl.searchParams.get('page')

5. Catch-all file path

const { path } = await params // string[]

6. Custom header

return new Response('ok', { headers: { 'X-Custom': 'value' } })

7. Static opt-in

export const dynamic = 'force-static'

8. Redirect

redirect('https://example.com')

9. Typed context

ctx: RouteContext<'/api/items/[id]'>

10. Cookie read

const token = request.cookies.get('token')

Visual

The Route Handler file mapping

┌─────────────────────────────────────────────────────────────┐
│  FILE PATH → URL                                             │
│                                                              │
│  app/api/hello/route.ts        → /api/hello                 │
│  app/api/users/[id]/route.ts   → /api/users/:id             │
│  app/api/files/[...path]/route.ts → /api/files/*            │
│                                                              │
│  Cannot coexist:                                             │
│    app/dashboard/page.tsx                                    │
│    app/dashboard/route.ts      → CONFLICT                   │
└─────────────────────────────────────────────────────────────┘

Method dispatch

┌─────────────────────────────────────────────────────────────┐
│  METHOD DISPATCH                                             │
│                                                              │
│  GET /api/items                                              │
│    → export async function GET()                            │
│                                                              │
│  POST /api/items                                             │
│    → export async function POST()                           │
│                                                              │
│  PUT /api/items                                              │
│    → not exported                                            │
│    → 405 Method Not Allowed                                  │
└─────────────────────────────────────────────────────────────┘

Dynamic params in Next.js 15

┌─────────────────────────────────────────────────────────────┐
│  PARAMS IS A PROMISE                                         │
│                                                              │
│  export async function GET(                                  │
│    request,                                                  │
│    { params }: { params: Promise<{ id: string }> }          │
│  ) {                                                         │
│    const { id } = await params                               │
│  }                                                           │
│                                                              │
│  Synchronous access: params.id                               │
│    → warning in 15, error in future                          │
└─────────────────────────────────────────────────────────────┘

Caching change (14 → 15)

┌─────────────────────────────────────────────────────────────┐
│  NEXT.JS 14                      NEXT.JS 15                 │
│                                                              │
│  GET cached by default           GET dynamic by default      │
│  Implicit static                 Explicit opt-in required    │
│                                                              │
│  To cache in 15:                                             │
│    export const dynamic = 'force-static'                     │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
File conventionroute.ts inside app/
HTTP methodsGET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
Request objectWeb Request or NextRequest
Response objectWeb Response or NextResponse
Dynamic paramsparams Promise, must await
Query paramsrequest.nextUrl.searchParams
GET caching defaultDynamic (Next.js 15)
Static opt-inexport const dynamic = 'force-static'
Route conflictCannot coexist with page.tsx

Key takeaways:

  • Route Handlers use the Web Request and Response APIs. There is no Node-specific req/res, no manual body parsing, and no framework-specific response wrapper required. The handler exports functions named after HTTP methods, and Next.js maps the incoming request to the correct function .
  • params is a Promise in Next.js 15. A dynamic segment handler must declare params: Promise<{ id: string }> and await it before accessing the value. The synchronous form is deprecated and will become an error .
  • GET handlers are dynamic by default in Next.js 15. This is a reversal from Next.js 14. A handler that was implicitly static must now add export const dynamic = 'force-static' to be prerendered .
  • A route.ts file cannot exist at the same segment as a page.tsx. If a route needs both a UI and an API endpoint, they must be at different paths. This is the most common file structure mistake .
  • The NextRequest extensions are optional but convenient. request.nextUrl.searchParams is the standard way to read query parameters, and request.cookies provides parsed cookie access .
  • The request body can only be read once. Calling await request.json() twice produces an error. Parse the body once, store the result, and use it throughout the handler .

Remember: A Route Handler is a file that exports async functions named after HTTP methods and returns a Web Response. It lives inside app, at the path it serves, and cannot share a segment with a page.tsx. In Next.js 15, dynamic params are Promises and GET handlers are dynamic by default. If you need static output, opt in explicitly. If you need query parameters, use request.nextUrl.searchParams. If you need the body, read it once with request.json() or request.text(). The rest is standard Web API code that runs on the server.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!