Route handlers (route.ts) cache GET requests by default using the Data Cache — automatic deduplication and persistent caching across deployments
A cached GET route costs $0 in server compute — the response is served from CDN edge with sub-10ms latency
An uncacheable GET route (dynamic data, auth headers, cookies) runs on every request — 3 such routes cost $400/month at 1M requests each
Server Actions bypass route handlers entirely — they handle mutations directly without the cache layer
Route handlers are for external API consumption (third-party integrations, mobile apps); Server Actions are for internal form/button mutations
Streaming responses (ReadableStream) in route handlers enable real-time data but disable caching — use only when you need live data
✦ Definition~90s read
What is Route Handlers?
Route handlers in Next.js App Router are API endpoints defined in route.ts files. They export named functions for HTTP methods (GET, POST, PUT, DELETE, PATCH, OPTIONS) and receive standard Web Request objects. Route handlers can be cached (GET only, under specific conditions), stream responses, and handle the full range of HTTP interactions.
★
Route handlers are like a convenience store with a locked cabinet.
Server Actions are functions marked with 'use server' that run on the server but are called directly from client components — no HTTP endpoint is created. They handle form submissions, data mutations, and can revalidate caches. Server Actions are simpler to use for internal mutations but cannot be called from external clients.
The key distinction: route handlers serve external API consumers (mobile apps, third parties) and cacheable GET data. Server Actions serve internal frontend mutations. Both can coexist — a Server Action can call a route handler internally for shared business logic.
Plain-English First
Route handlers are like a convenience store with a locked cabinet. Cached GET routes are items the store clerk keeps ready — they hand them out instantly with zero effort, costing nothing. Uncacheable routes are custom orders — the clerk has to go to the back room every time, find the item, and bring it back. Each trip costs time and money. Server Actions are like a customer walking behind the counter and writing directly in the order book — no clerk needed, but no cabinet either.
⚙ Browser compatibility
Latest versions — ✓ supported
Chrome
Firefox
Safari
Edge
✓
✓
✓
✓
Route handlers in Next.js (route.ts files) handle HTTP requests at specific API endpoints. GET requests are cached by default via the Data Cache — the response is stored after the first request and served from cache for subsequent requests. This means the first request pays the compute cost, and the next 999,999 requests cost $0.
But not all GET routes can be cached. Routes that read cookies, authorization headers, or search params are dynamic by default — they run on every request. Three such routes handling 1M requests each can add $400/month in server costs.
This article covers route handler caching in detail: what makes a route cacheable, how to opt out of caching, when to use Server Actions instead of route handlers, streaming responses, security considerations, and the production cost analysis that drives the "cached vs uncached" decision.
Tested on Next.js 16.0.0-canary. Route handler caching behavior is consistent with Next.js 15+.
GET Route Caching: What Makes a Route Cacheable vs Dynamic?
Next.js route handlers use the Data Cache for GET requests. A GET route is automatically cached if it does not use any dynamic APIs: - No reading request.headers (authorization, cookie, user-agent, etc.) - No reading request.cookies (individual cookies or cookies() API) - No reading searchParams (URL query parameters) - No dynamic functions like cookies(), headers(), or draftMode()
If a GET route only uses URL path parameters (params from the route.ts file name) and returns static data, it is cacheable. The response is stored in the Data Cache after the first request and served from cache for subsequent requests — across all users, across deployments.
If a GET route reads ANY dynamic API, it becomes dynamic — runs on every request. The response is NOT cached. Each request pays full compute cost.
The practical threshold: a route that reads an auth header for user context is always uncacheable. Move the user identifier to the URL path (/api/users/[userId]/data) and the route becomes cacheable. The tradeoff: the user ID is now in the URL, which may be visible in logs and analytics.
app/api/users/[userId]/stats/route.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// ============================================// Cacheable GET Route// ============================================// app/api/users/[userId]/stats/route.ts// ✅ CACHEABLE — only reads URL params, no headers/cookiesimport { NextResponse } from'next/server'exportasyncfunctionGET(
request: Request,
{ params }: { params: Promise<{ userId: string }> }
) {
const { userId } = await params
// This fetch is cacheable — depends only on userIdconst stats = awaitfetch(
`https://api.internal.io/users/${userId}/stats`,
{ next: { revalidate: 300 } } // Cache for 5 minutes
)
if (!stats.ok) {
returnNextResponse.json(
{ error: 'Stats not found' },
{ status: 404 }
)
}
const data = await stats.json()
returnNextResponse.json(data, {
status: 200,
headers: {
'Cache-Control': 'public, max-age=300, s-maxage=300',
}
})
}
// ============================================// UNCACHEABLE GET Route (reads auth header)// ============================================// app/api/dashboard/stats/route.ts// ❌ UNCACHEABLE — reads authorization headerexportasyncfunctionGET(request: Request) {
// Reading headers makes this route dynamicconst authHeader = request.headers.get('authorization')
if (!authHeader) {
returnNextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const userId = awaitauthenticate(authHeader)
// Even though fetch is the same, the route is dynamicconst stats = awaitfetch(
`https://api.internal.io/users/${userId}/stats`,
{ next: { revalidate: 300 } }
)
returnNextResponse.json(await stats.json())
}
// Cost difference:// Cacheable: 1 compute unit for first request, $0 for next 999,999// Uncacheable: 1 compute unit for EVERY request — 1M = ~$130 at Vercel pricing
A route is cacheable if its response depends ONLY on URL path params. If your route reads headers (auth, cookies) or search params, it is dynamic and uncacheable. Move user context to URL path to make it cacheable.
Production Insight
A dashboard app with 3 GET routes reading auth headers cost $400/month at 3M requests. After moving userId to URL path, same routes cost near $0 — the Data Cache served cached responses for every subsequent request.
Rule: if a GET route reads auth context from headers, restructure to path-based user IDs.
Key Takeaway
GET routes are cached by default if they do not read headers, cookies, or search params.
Reading request.headers('authorization') makes the route dynamic — every request pays compute cost.
Move user ID to URL path for cacheable routes: /api/[userId]/data instead of /api/data with auth header.
thecodeforge.io
Nextjs Route Handlers Api
Data Cache Revalidation: Time-Based vs Event-Driven
Cached GET routes can be revalidated in two ways:
Time-based (revalidate): The cache expires after N seconds. Use export const revalidate = 3600 or next: { revalidate: 3600 } in fetch calls. Simple, predictable, but may serve stale data for up to N seconds.
Event-driven (tags): Tag the response with revalidateTag() on data mutation. Use next: { tags: ['user-orders'] } in fetch calls within the route handler, then call revalidateTag('user-orders') from a Server Action or another route handler when data changes. Consistent, immediate invalidation.
Time-based is easier to implement but guarantees some staleness. Event-driven is more work but keeps data fresh. The right choice depends on your data's staleness tolerance.
The 'use cache' directive (Next.js 16) provides component-level caching that can supplement route handler caching. Use route handler caching for API endpoints consumed externally. Use 'use cache' for component data fetching in Server Components.
Route handler revalidation patternsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// ============================================// Time-Based Revalidation// ============================================// app/api/products/route.tsexportasyncfunctionGET() {
const products = await fetch('https://api.internal.io/products', {
next: { revalidate: 3600 } // Refresh every hour
})
returnNextResponse.json(await products.json())
}
// Alternative: segment-level revalidation
export const revalidate = 3600// All fetch calls in this route use 3600// ============================================// Event-Driven Revalidation (Tags)// ============================================// app/api/orders/[userId]/route.ts// Fetch with tags for targeted invalidationexportasyncfunctionGET(
request: Request,
{ params }: { params: Promise<{ userId: string }> }
) {
const { userId } = await params
const orders = awaitfetch(
`https://api.internal.io/orders/${userId}`,
{
next: {
tags: [`orders-${userId}`, 'orders-global'],
revalidate: 86400, // Max cache 24h, but tags refresh sooner
}
}
)
returnNextResponse.json(await orders.json())
}
// app/orders/actions.ts (Server Action)// Invalidate tag when data changes'use server'import { revalidateTag } from'next/cache'exportasyncfunctioncreateOrder(formData: FormData) {
const order = await fetch('https://api.internal.io/orders', {
method: 'POST',
body: formData,
})
if (order.ok) {
// Immediately invalidate all order cachesrevalidateTag('orders-global')
}
return { success: order.ok }
}
// ============================================// No Cache (Dynamic Data)// ============================================// app/api/live-prices/route.tsexportconst dynamic = 'force-dynamic'// ORexportconst revalidate = 0// Forces every request to run fresh// Use for real-time data where cache is unacceptable
Time-based revalidation guarantees staleness (up to N seconds). Tag-based revalidation invalidates immediately on mutation. Use tags when your data changes unpredictably — use time when data changes on a known schedule.
Production Insight
An e-commerce site used 1-hour revalidation for product inventory. During a flash sale, inventory was stale for up to 1 hour — customers saw 'in stock' for items that sold out 45 minutes ago.
Rule: for inventory, pricing, and availability data, use tag-based revalidation triggered by the inventory mutation endpoint.
Key Takeaway
Time-based revalidation (revalidate: N) is simple but guarantees staleness.
Tag-based revalidation (revalidateTag) is immediate — use for dynamic data like inventory.
Use 'dynamic = force-dynamic' or 'revalidate = 0' for real-time data that cannot be cached.
Route Handlers vs Server Actions: The Decision Matrix
Route handlers and Server Actions serve different purposes. The confusion comes from overlap — both can handle mutations, both can return JSON, both run on the server. The differences:
Route Handlers (route.ts)
Exposed as regular HTTP endpoints (GET, POST, PUT, DELETE at /api/*)
Cacheable GET responses (Data Cache)
Accessible from any HTTP client (mobile apps, third-party services, curl)
Need CORS configuration for cross-origin access
Rate limiting, request validation, and auth handled manually
Server Actions ('use server' functions)
NOT exposed as HTTP endpoints — directly callable from client components
No caching (every call runs the function)
Accessible only from within the same Next.js application (client components)
No CORS needed (same-origin only)
Automatic form data parsing, progressive enhancement works natively
The decision: use route handlers when external clients need access. Use Server Actions when only your own frontend needs to mutate data. Use route handlers for cacheable GET data (even for internal use — caching saves cost).
Server Actions CAN call route handlers internally — they are not mutually exclusive. A common pattern: Server Action handles the form submission, validates data, then calls an internal route handler for the actual business logic.
Route Handler + Server Action hybrid patternTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// ============================================// Hybrid: Route Handler + Server Action// ============================================// app/api/orders/route.ts// Route handler — accessible from mobile app AND internal Server ActionexportasyncfunctionPOST(request: Request) {
const body = await request.json()
// Validateif (!body.userId || !body.items || body.items.length === 0) {
returnNextResponse.json(
{ error: 'Invalid order' },
{ status: 400 }
)
}
// Create order in databaseconst order = awaitcreateOrderInDb(body)
// Invalidate cacherevalidateTag(`orders-${body.userId}`)
revalidateTag('orders-global')
returnNextResponse.json(order, { status: 201 })
}
// app/orders/actions.ts// Server Action — called from form submission in the web app'use server'import { redirect } from'next/navigation'exportasyncfunctionsubmitOrder(formData: FormData) {
const items = JSON.parse(formData.get('items') asstring)
// Call the same route handler internally
const response = await fetch('https://internal.url/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: formData.get('userId'),
items,
}),
})
if (!response.ok) {
const error = await response.json()
return { error: error.message }
}
redirect('/orders/confirmation')
}
// app/components/OrderForm.tsx// Client component calling Server Action'use client'import { submitOrder } from'@/app/orders/actions'exportfunctionOrderForm({ userId }: { userId: string }) {
return (
<form action={submitOrder}>
<input type="hidden" name="userId" value={userId} />
<input type="hidden" name="items" />
<button type="submit">PlaceOrder</button>
</form>
)
}
Route Handlers = External API, Server Actions = Internal Only
Route handlers are regular HTTP endpoints — use when external clients call your API. Server Actions are internal-only — use when your own frontend mutates data. Overlapping both via internal fetch is a solid pattern for shared business logic.
Production Insight
A team built all mutations as route handlers called from client-side fetch. They paid for API Gateway + serverless compute + double the network hops. Switching internal mutations to Server Actions cut costs by 60%.
Rule: if only your Next.js frontend calls a mutation endpoint, use a Server Action — cheaper, simpler, type-safe.
Key Takeaway
Route handlers: HTTP endpoints for external/mobile clients with GET caching.
Server Actions: Internal-only mutations from client components with automatic form handling.
Use route handlers for cacheable GET data — caching makes them cheaper than Server Actions for reads.
Cached vs Uncacheable Route HandlersCost and performance trade-offs for GET routesCached RouteUncacheable RouteCost per 1M requests$0$400Dynamic data supportNoYesRevalidation methodTime-based or event-drivenN/AUse caseStatic content, blog postsUser-specific data, real-timePerformanceFast (edge cached)Slower (origin fetch)THECODEFORGE.IO
thecodeforge.io
Nextjs Route Handlers Api
Streaming Responses in Route Handlers: When and How
Route handlers can stream responses using ReadableStream. This enables real-time data delivery without WebSocket setup — Server-Sent Events (SSE) style or chunked JSON responses. Streaming is useful for: - Large dataset delivery (stream records as they are available) - Real-time event streaming (notifications, live updates) - Progress reporting (processing status updates)
Tradeoffs: streaming responses CANNOT be cached by the Data Cache. Once you stream, the response is dynamic. The route handler runs for the duration of the stream — serverless timeout limits apply (10s-300s depending on plan).
For most use cases, a non-streaming cached GET response is better and cheaper. Only stream when: - The data set is too large to fit in memory - Real-time updates are a product requirement (not a developer preference) - The response time exceeds acceptable latency for a single fetch
Streaming responses are never cached by the Data Cache. Each streamed response runs the route handler from start to finish for every client. Use streaming only for real-time data or large datasets that cannot be cached. For cacheable data, a regular JSON response is cheaper and faster.
Production Insight
A team streamed a 50MB JSON dataset because they thought it was more efficient. The stream took 12 seconds per request and cost $0.30 per execution. A cached paginated JSON endpoint would have cost $0 after the first request.
Rule: stream when latency matters (real-time events), not when data is large (cached + paginated is better).
Key Takeaway
Streaming responses are never cached — every request runs the full handler.
Use streaming for real-time events (SSE) or progress reporting, not for large data downloads.
Prefer cached paginated JSON responses for large datasets — cheaper and faster.
Security: Route Handler Auth, CORS, Rate Limiting, and Input Validation
Route handlers are regular HTTP endpoints — they have no built-in auth, CORS, or rate limiting. Every security feature must be implemented manually.
Authentication: read the authorization header, validate JWT/session, return 401 if invalid. Do NOT rely on middleware for auth — route handlers can be called from anywhere (mobile app, curl, Postman) and may not pass through middleware.
CORS: route handlers do not add CORS headers automatically. For cross-origin access, set Access-Control-Allow-Origin and handle OPTIONS preflight. Create a helper function that wraps all responses with CORS headers.
Rate limiting: no built-in support in Next.js. Use Vercel Rate Limiting (WAF rules), Cloudflare rate limiting, or implement in-memory rate limiting with a KV store (Vercel KV, Upstash Redis).
Input validation: parse and validate request bodies before processing. Use Zod schemas for type-safe validation. Return 400 with descriptive error messages for invalid input.
A route handler without these protections is a production incident waiting to happen — it is a public API endpoint.
Route Handlers Are Public Endpoints — Treat Them As Such
Next.js route handlers have no built-in auth, CORS, or rate limiting. Every route handler is a public HTTP endpoint by default. Add auth, input validation, CORS, and rate limiting to every route handler — not just the ones you think are 'exposed'.
Production Insight
A team deployed a route handler for internal use without auth. A developer accidentally pushed the URL to a public GitHub repo. Within 2 hours, bots were hitting the endpoint, causing $2,000 in serverless compute overage.
Rule: every route handler needs auth — even 'internal only' ones. Use a shared validateAuth wrapper to avoid forgetting.
Key Takeaway
Route handlers have no built-in auth, CORS, or rate limiting — implement all three.
Create shared security helpers (validateAuth, corsHeaders, rateLimit) and use them in every route handler.
Treat every route handler as an externally accessible endpoint — because it is.
Route Handlers and the App Router: Export Conventions and HTTP Methods
Route handlers in the App Router export named functions for each HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Each function receives the standard Web Request object and returns a Web Response object (NextResponse extends Response).
The conventions
File must be named route.ts (or route.js, route.tsx) inside the app directory
Export a function named after the HTTP method: export async function GET(request, { params })
The function must be async (route handlers are always async)
Return NextResponse.json(), NextResponse.redirect(), NextResponse.next(), or a custom Response
Access URL params via the second argument: { params }: { params: Promise<{ id: string }> }
Request body: await request.json() for JSON, await request.formData() for form data
Search params: use NextRequest (extends Request) with nextUrl.searchParams
Static route handlers (no dynamic APIs) are automatically cached for GET. All other methods (POST, PUT, DELETE, PATCH) are never cached — they run on every request.
Named Export = HTTP Method, route.ts = File Convention
route.ts with named exports (GET, POST, PUT, DELETE, PATCH, OPTIONS) defines the API surface. The file name determines the URL path. Only route.ts files create API routes — page.tsx creates pages, not API endpoints.
Production Insight
A team accidentally named their file api-handler.ts instead of route.ts. The route never worked — Next.js only recognizes route.ts (or route.js, route.tsx). 2 hours debugging for a file rename.
Rule: API endpoints must be route.ts files. Any other filename is ignored by the App Router.
Key Takeaway
Route handlers use route.ts with named GET, POST, PUT, DELETE, PATCH, OPTIONS exports.
GET routes are cacheable by default — other methods always run dynamically.
Use NextRequest for search param access, request.json() for body parsing, NextResponse.json() for responses.
Cost Analysis: Cached vs Uncacheable Route Handlers at Scale
Let's run the numbers on a real SaaS application with 1 million requests per month per endpoint.
Cached GET route (no dynamic APIs)
First request: 1 compute unit (~$0.000002 on Vercel)
Subsequent 999,999 requests: $0 (served from Data Cache at CDN edge)
Monthly cost: ~$0.002
Latency: sub-10ms (CDN cache hit)
Server load: 1 invocation per deployment, then none
Uncacheable GET route (reads auth header)
Each request: 1 compute unit
1M requests × $0.000002 = $2.00 (Vercel Pro compute)
Plus Edge Function cost (~$130/M on Vercel Pro)
Monthly cost: ~$132
Latency: 100-200ms (server execution)
Server load: 1M invocations
Uncacheable POST route (mutation)
Each request: 1 compute unit + DB write
1M requests × $0.000002 + DB costs = ~$150-200/month
Latency: 100-300ms
Three uncacheable GET routes at 1M requests each: ~$400/month. Three cached GET routes at 1M requests each: ~$0.006/month.
The difference: $400/month vs nearly $0. For a startup, that is $4,800/year saved by moving user IDs from headers to URL paths.
Cache the cacheable. Only pay for what must be dynamic.
Cost calculation scriptTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// ============================================// Route Handler Cost Calculator// ============================================// Using Vercel Pro pricing (approximate Q2 2026)// Edge Functions: included 500K invocations, $2/M after// Serverless: included 100GB-hours, $0.18/GB-hour afterinterfaceRouteCost {
type: 'cached-get' | 'uncached-get' | 'post'
monthlyRequests: number
avgExecutionMs: number
}
functioncalculateMonthlyCost(route: RouteCost): number {
const { type, monthlyRequests, avgExecutionMs } = route
if (type === 'cached-get') {
// Only first request per cache miss pays
const revalidationInterval = 3600// 1 hourconst cacheMissesPerMonth = (30 * 24 * 3600) / revalidationInterval
const executions = cacheMissesPerMonth
return (executions * avgExecutionMs * 0.000002) / 1000
}
if (type === 'uncached-get') {
// Every request paysconst computeCost = (monthlyRequests * avgExecutionMs * 0.000002) / 1000// Edge function cost: first 500K free, then $2/Mconst edgeCost = Math.max(0, (monthlyRequests - 500000) / 1000000) * 2return computeCost + edgeCost
}
// POST - every request pays + DB writeconst computeCost = (monthlyRequests * avgExecutionMs * 0.000002) / 1000
const dbCost = monthlyRequests * 0.0001// Approximate DB write costreturn computeCost + dbCost
}
// Example: 3 uncacheable GET routesconst uncacheable: RouteCost[] = [
{ type: 'uncached-get', monthlyRequests: 1_000_000, avgExecutionMs: 120 },
{ type: 'uncached-get', monthlyRequests: 1_000_000, avgExecutionMs: 150 },
{ type: 'uncached-get', monthlyRequests: 1_000_000, avgExecutionMs: 100 },
]
const uncacheableTotal = uncacheable
.map(calculateMonthlyCost)
.reduce((a, b) => a + b, 0)
console.log(`3 uncacheable GET routes: $${uncacheableTotal.toFixed(2)}/month`)
// Output: ~$396.00// Same data as cacheable (userId in URL path)const cacheable: RouteCost[] = [
{ type: 'cached-get', monthlyRequests: 1_000_000, avgExecutionMs: 120 },
{ type: 'cached-get', monthlyRequests: 1_000_000, avgExecutionMs: 150 },
{ type: 'cached-get', monthlyRequests: 1_000_000, avgExecutionMs: 100 },
]
const cacheableTotal = cacheable
.map(calculateMonthlyCost)
.reduce((a, b) => a + b, 0)
console.log(`3 cached GET routes: $${cacheableTotal.toFixed(3)}/month`)
// Output: ~$0.006
3 uncacheable GET routes at 1M requests/month each cost ~$400. Same routes cached cost $0.006. The only difference is whether the route reads request headers. Moving user ID from header to URL path saves $4,800/year.
Production Insight
The SaaS dashboard incident: 3 routes reading auth header = $400/month. After moving userId to URL path: $0.006/month. The fix took 30 minutes and saved $4,800/year.
Rule: run this cost analysis for every route handler. If it is GET and reads headers, restructure to path-based parameters.
Key Takeaway
Cached GET routes cost nearly $0 at any scale — the Data Cache absorbs all subsequent requests.
Uncacheable GET routes cost ~$132/month per 1M requests — from serverless compute + edge invocations.
Move user context from headers to URL paths to make GET routes cacheable.
● Production incidentPOST-MORTEMseverity: high
Three Uncacheable GET Routes Cost $400/Month — Rewrote as Server Actions and Cut to $15
Symptom
Monthly cloud bill showed $400 for serverless function compute across 3 API routes: /api/dashboard/stats, /api/dashboard/recent-orders, /api/dashboard/activity. Each route handled ~1M requests/month. Response times averaged 120ms. The team assumed this was normal API hosting cost. A senior engineer noticed the routes were all GET requests that could be cached — but were not because they read the authorization header for user identification.
Assumption
The team assumed API routes always cost money to run — they did not know Next.js caches GET responses by default and that their routes were missing the cache because of the auth header read.
Root cause
Each route read the authenticated user ID from the authorization header (req.headers.get('authorization')). This made the route dynamic — Next.js cannot cache responses that depend on request headers because the cached response for user A is different from user B. The routes were effectively uncacheable in their current design. The team could either restructure to use path-based user IDs (making them cacheable) or switch to Server Actions (which handle auth context internally).
Fix
Moved the user ID from header to URL path: /api/dashboard/[userId]/stats. The route became cacheable since the response depends only on the URL path, not request headers. Three cached GET routes serving 1M requests each cost near $0 in server compute. Mutations (update stats, create order) moved to Server Actions which have a different cost model. Monthly API cost dropped from $400 to $15 (mostly for the Server Action executions).
Key lesson
GET routes that read request headers (auth, cookies) are dynamic and uncacheable — move user context to URL path
Three uncacheable GET routes at 1M requests each = $400/month. Same routes cached = near $0
Server Actions handle mutations with less overhead than route handlers — use them for internal mutations
Route handlers are for external API consumers (mobile apps, third parties) — not for internal first-party mutations
Profile API costs after every major feature — 'it is just a GET route' hides $400/month cost
Production debug guideDiagnose why your GET route is not caching5 entries
Symptom · 01
GET route always returns dynamic response — never cached
→
Fix
Check if the route reads request headers (authorization, cookie), reads search params, or accesses cookies(). Any of these make the route dynamic. Move user context to URL path: /api/users/[userId]/data instead of /api/data with auth header.
Symptom · 02
Route handler returning stale data in production
→
Fix
The Data Cache cached the response and is not invalidating. Add revalidation: export const revalidate = 3600 for time-based, or use unstable_noStore for dynamic data. Check for next: { tags: [...] } and use revalidateTag() for event-driven invalidation.
Symptom · 03
POST route not accepting request body
→
Fix
POST routes are never cached — they always run on every request. Check for missing await request.json() or request.formData(). POST routes expect request body parsing.
Symptom · 04
Route handler times out on heavy computation
→
Fix
Route handlers on serverless have a timeout (10s on Vercel Hobby, 60s on Pro, 300s on Enterprise). Move heavy computation to background jobs or use streaming responses for incremental delivery.
Symptom · 05
CORS errors from mobile app to route handler
→
Fix
Route handlers do not add CORS headers by default. Add them manually: set Access-Control-Allow-Origin, Access-Control-Allow-Methods, and handle OPTIONS preflight requests.
Add CORS headers to every response and handle OPTIONS method
⚙ Quick Reference
7 commands from this guide
File
Command / Code
Purpose
appapiusers[userId]statsroute.ts
export async function GET(
GET Route Caching
Route handler revalidation patterns
export async function GET() {
Data Cache Revalidation
Route Handler + Server Action hybrid pattern
export async function POST(request: Request) {
Route Handlers vs Server Actions
appapistreameventsroute.ts
export async function GET() {
Streaming Responses in Route Handlers
libapi-utils.ts — security helpers
export interface ApiResponse {
Security
Complete route handler reference
export async function GET(
Route Handlers and the App Router
Cost calculation script
interface RouteCost {
Cost Analysis
Key takeaways
1
GET route handlers are cached by default if they don't read headers, cookies, or searchParams
cacheable routes cost nearly $0
2
Moving user ID from auth header to URL path makes GET routes cacheable
saves ~$400/month per 3 routes at 1M requests each
3
Use route handlers for external API consumption and cacheable GET data
use Server Actions for internal mutations
4
Route handlers have no built-in auth, CORS, or rate limiting
implement all three on every endpoint
5
Streaming responses disable caching
use only for real-time events, not for large datasets
6
POST/PUT/DELETE routes are never cached
they always run on every request
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
How does Next.js route handler caching work and when does a GET route be...
Q02SENIOR
When should I use a route handler vs a Server Action?
Q03SENIOR
A team's 3 GET route handlers cost $400/month. How do you diagnose and f...
Q04SENIOR
How do you implement security for route handlers?
Q01 of 04SENIOR
How does Next.js route handler caching work and when does a GET route become uncacheable?
ANSWER
Next.js automatically caches GET route handlers using the Data Cache. A GET route is cacheable when it does not use any dynamic APIs: request.headers, request.cookies, searchParams, cookies(), headers(), or draftMode(). If the route only uses URL path parameters (from the route.ts file name) and returns data from a fetch call or direct computation, the response is cached after the first request and served from cache for all subsequent requests.
A GET route becomes uncacheable (dynamic) when it reads any request-header-specific data:
- request.headers.get('authorization') — user-specific auth
- request.cookies.get('session') — session-based context
- request.nextUrl.searchParams — query parameter dependency
- cookies() or headers() from next/headers — dynamic function use
The practical impact: if your route reads an auth header to get the user ID, the response is different per user and cannot be cached. Moving the user ID to the URL path (/api/[userId]/data instead of /api/data with auth header) makes the route cacheable — the response for /api/user-123/data is cached independently of /api/user-456/data.
Cost impact: three uncacheable GET routes at 1M requests each cost approximately $400/month in serverless compute. The same routes restructured as cacheable cost nearly $0.
Q02 of 04SENIOR
When should I use a route handler vs a Server Action?
ANSWER
The decision depends on who needs to call the endpoint:
Route handlers: use when external clients need access — mobile apps, third-party integrations, other services, curl/Postman. Route handlers are also the right choice for cacheable GET data because the Data Cache provides automatic caching that Server Actions do not have. If you have a GET endpoint that returns the same data for all users and needs caching, a route handler is the only option.
Server Actions: use when only your own Next.js frontend needs to call the mutation. Server Actions are called directly from client components, handle form data automatically, support progressive enhancement (form submission without JavaScript), and have built-in error handling with useActionState. They are simpler to write than route handlers for the same mutation logic.
The hybrid pattern works well: use Server Actions for the frontend form submission, then internally call the route handler (via fetch) for business logic that is also consumed by mobile apps. The route handler is the single source of truth for the business logic; the Server Action is the frontend convenience layer.
Q03 of 04SENIOR
A team's 3 GET route handlers cost $400/month. How do you diagnose and fix the cost issue?
ANSWER
First, verify that the routes are actually uncacheable. Check if each GET route reads request.headers, request.cookies, or searchParams. These make the route dynamic — every request pays compute cost. Also check if the route uses export const dynamic = 'force-dynamic' or revalidate = 0 which explicitly disable caching.
The fix: restructure the routes to be cacheable. Move the user ID from the authorization header to the URL path. Change from /api/dashboard/stats (reads auth header) to /api/users/[userId]/dashboard/stats (path-based). The route becomes cacheable because the response now depends only on the URL path.
For routes that genuinely need dynamic data (real-time prices, live inventory), consider:
- Tag-based revalidation: cache the response but invalidate it immediately when data changes using revalidateTag()
- Short revalidation: use revalidate = 60 (1 minute) to limit staleness to 60 seconds while still caching 99.9% of requests
- If real-time is truly required, accept the dynamic cost but implement rate limiting and caching at the client level
After restructuring, monitor costs for the next billing cycle. Expected: $400/month → near $0 for the cached routes, with only mutation endpoints showing compute costs.
Q04 of 04SENIOR
How do you implement security for route handlers?
ANSWER
Route handlers have no built-in security — they are raw HTTP endpoints. Security must be implemented manually:
1. Authentication: read the Authorization header (Bearer token) and validate JWT/session using jose (Edge-compatible) or jsonwebtoken (Node.js runtime). Create a shared validateAuth helper used by every route handler. Do not rely on middleware for auth — route handlers can be called directly.
2. CORS: set Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers headers. Handle OPTIONS preflight requests separately. Create a shared corsHeaders function that returns the headers object.
3. Rate limiting: use a KV store (Upstash Redis, Vercel KV) with sliding window rate limiting. Create a checkRateLimit helper that returns { success: boolean }. Return 429 Too Many Requests when rate limited.
4. Input validation: use Zod schemas to parse and validate request bodies. Return 400 with descriptive error messages for invalid input. Never trust request body types — validate every field.
Create a shared utility module (lib/api-utils.ts) with all these helpers. Every route handler should use them on every endpoint — 'internal' endpoints are still externally accessible.
01
How does Next.js route handler caching work and when does a GET route become uncacheable?
SENIOR
02
When should I use a route handler vs a Server Action?
SENIOR
03
A team's 3 GET route handlers cost $400/month. How do you diagnose and fix the cost issue?
SENIOR
04
How do you implement security for route handlers?
SENIOR
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
Are route handlers cached by default?
GET route handlers are cached by default if they do not use any dynamic APIs (reading headers, cookies, searchParams). POST, PUT, DELETE, and PATCH routes are NEVER cached — they always run on every request. Cacheability is automatic based on API usage, not opt-in.
Was this helpful?
02
What is the difference between a route handler and a Server Action?
Route handlers are regular HTTP endpoints defined in route.ts files — they are accessible from any HTTP client (mobile apps, third-party services, curl). Server Actions are functions marked with 'use server' that can be called directly from client components — they are NOT exposed as HTTP endpoints and only work within the same Next.js application. Use route handlers for external API consumption and cacheable GET data. Use Server Actions for internal form/button mutations.
Was this helpful?
03
How do I make an uncacheable GET route cacheable?
Identify what makes the route dynamic — usually reading request.headers('authorization') or request.cookies. Move the user identifier from the header to the URL path: change /api/data (reads auth header) to /api/users/[userId]/data (uses URL param). The route becomes cacheable because the response depends only on the URL path, not request headers. User-specific responses are now cached per user path.
Was this helpful?
04
Can route handlers stream responses?
Yes — return a ReadableStream as the Response body. This enables Server-Sent Events (SSE) and chunked JSON delivery. However, streaming responses cannot be cached by the Data Cache — every stream runs the route handler from start to finish. Use streaming only for real-time data or datasets too large to fit in memory. For large data, prefer cached + paginated responses.
Was this helpful?
05
Do route handlers have built-in rate limiting?
No — route handlers have no built-in rate limiting, authentication, or CORS support. These must be implemented manually. Rate limiting is typically done with a KV store (Upstash Redis, Vercel KV) or a WAF (Cloudflare, Vercel Firewall). Authentication requires manual JWT/session validation in each route handler or via a shared helper function.
Was this helpful?
06
Can I use Edge Runtime for route handlers?
Yes — add 'export const runtime = "edge"' to route.ts. Edge runtime provides lower latency (geographically distributed) but has the same limitations as middleware: no database connections, limited Node.js APIs, and 25-second timeout. Use Node.js runtime (default) for most route handlers — it has full Node.js API access and longer timeouts.