Next.js 15 ▲ Route Handlers — Custom API endpoints with Web Request/Response
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
| Element | Example |
|---|---|
| File name | route.ts or route.js |
| Location | Anywhere inside app/ |
| URL path | Directory path relative to app/ |
| Conflict | Cannot coexist with page.tsx at the same segment |
Supported methods
| Method | Export |
|---|---|
| GET | export async function GET(request) |
| POST | export async function POST(request) |
| PUT | export async function PUT(request) |
| PATCH | export async function PATCH(request) |
| DELETE | export async function DELETE(request) |
| HEAD | export async function HEAD(request) |
| OPTIONS | export async function OPTIONS(request) |
Request properties
| Property | Type | Purpose |
|---|---|---|
request.json() | Promise | Parse JSON body |
request.text() | Promise | Raw string body |
request.formData() | Promise | Form data |
request.nextUrl.searchParams | URLSearchParams | Query parameters (NextRequest) |
request.cookies | RequestCookies | Read cookies (NextRequest) |
Dynamic params
| Route | params type | Value |
|---|---|---|
[id] | Promise<{ id: string }> | { id: '123' } |
[...slug] | Promise<{ slug: string[] }> | { slug: ['a', 'b'] } |
[[...slug]] | Promise<{ slug?: string[] }> | { slug: undefined } at base |
Caching
| Behavior | Configuration |
|---|---|
| Dynamic (default in 15) | No config |
| Static | export const dynamic = 'force-static' |
| Revalidate | export 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
params warning | Synchronous access in Next.js 15 | Await the params Promise |
| 405 Method Not Allowed | Method not exported | Add the missing export |
| Route conflict error | route.ts next to page.tsx | Move the API route to a different segment |
| GET returns stale data | Cached when dynamic was expected | Remove force-static or add revalidate |
| GET is slow on every request | Not cached when static was expected | Add export const dynamic = 'force-static' |
| Body parse error | Wrong Content-Type or double read | Check 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
| Item | Value |
|---|---|
| File convention | route.ts inside app/ |
| HTTP methods | GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS |
| Request object | Web Request or NextRequest |
| Response object | Web Response or NextResponse |
| Dynamic params | params Promise, must await |
| Query params | request.nextUrl.searchParams |
| GET caching default | Dynamic (Next.js 15) |
| Static opt-in | export const dynamic = 'force-static' |
| Route conflict | Cannot coexist with page.tsx |
Key takeaways:
- Route Handlers use the Web
RequestandResponseAPIs. There is no Node-specificreq/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 . paramsis a Promise in Next.js 15. A dynamic segment handler must declareparams: 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.tsfile cannot exist at the same segment as apage.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
NextRequestextensions are optional but convenient.request.nextUrl.searchParamsis the standard way to read query parameters, andrequest.cookiesprovides 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.
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!