Home JavaScript Route Handlers vs Server Actions — Why 3 Cached GET Routes Cost $0 and 3 Uncacheable Ones Cost $400/mo
Intermediate 5 min · July 12, 2026
Route Handlers: Building API Endpoints in Next.js 16

Route Handlers vs Server Actions — Why 3 Cached GET Routes Cost $0 and 3 Uncacheable Ones Cost $400/mo

Next.js route handlers cache GET by default.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
Before you start⏱ 30 min
  • Next.js App Router
  • Understanding of HTTP methods and REST APIs
  • Node.js 18+
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
ChromeFirefoxSafariEdge

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/cookies

import { NextResponse } from 'next/server'

export async function GET(
  request: Request,
  { params }: { params: Promise<{ userId: string }> }
) {
  const { userId } = await params

  // This fetch is cacheable — depends only on userId
  const stats = await fetch(
    `https://api.internal.io/users/${userId}/stats`,
    { next: { revalidate: 300 } } // Cache for 5 minutes
  )

  if (!stats.ok) {
    return NextResponse.json(
      { error: 'Stats not found' },
      { status: 404 }
    )
  }

  const data = await stats.json()

  return NextResponse.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 header

export async function GET(request: Request) {
  // Reading headers makes this route dynamic
  const authHeader = request.headers.get('authorization')

  if (!authHeader) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    )
  }

  const userId = await authenticate(authHeader)

  // Even though fetch is the same, the route is dynamic
  const stats = await fetch(
    `https://api.internal.io/users/${userId}/stats`,
    { next: { revalidate: 300 } }
  )

  return NextResponse.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
Try it live
Cacheable = No Request Inspection
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.
nextjs-route-handlers-api THECODEFORGE.IO Route Handler Caching Architecture Layered components affecting cacheability and cost Request Layer GET Request | Dynamic Functions | Static Export Caching Layer Data Cache | Full Route Cache | Revalidation Cost Layer Cached: $0 | Uncacheable: $400/mo | Edge Functions Security Layer Auth Middleware | CORS Headers | Rate Limiting THECODEFORGE.IO
thecodeforge.io
Nextjs Route Handlers Api

Data Cache Revalidation: Time-Based vs Event-Driven

  1. 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.
  2. 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.ts
export async function GET() {
  const products = await fetch('https://api.internal.io/products', {
    next: { revalidate: 3600 } // Refresh every hour
  })

  return NextResponse.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 invalidation

export async function GET(
  request: Request,
  { params }: { params: Promise<{ userId: string }> }
) {
  const { userId } = await params

  const orders = await fetch(
    `https://api.internal.io/orders/${userId}`,
    {
      next: {
        tags: [`orders-${userId}`, 'orders-global'],
        revalidate: 86400, // Max cache 24h, but tags refresh sooner
      }
    }
  )

  return NextResponse.json(await orders.json())
}

// app/orders/actions.ts (Server Action)
// Invalidate tag when data changes
'use server'

import { revalidateTag } from 'next/cache'

export async function createOrder(formData: FormData) {
  const order = await fetch('https://api.internal.io/orders', {
    method: 'POST',
    body: formData,
  })

  if (order.ok) {
    // Immediately invalidate all order caches
    revalidateTag('orders-global')
  }

  return { success: order.ok }
}

// ============================================
// No Cache (Dynamic Data)
// ============================================

// app/api/live-prices/route.ts
export const dynamic = 'force-dynamic'
// OR
export const revalidate = 0

// Forces every request to run fresh
// Use for real-time data where cache is unacceptable
Try it live
Prefer Tag-Based Revalidation for Dynamic Data
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 Action

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

  // Validate
  if (!body.userId || !body.items || body.items.length === 0) {
    return NextResponse.json(
      { error: 'Invalid order' },
      { status: 400 }
    )
  }

  // Create order in database
  const order = await createOrderInDb(body)

  // Invalidate cache
  revalidateTag(`orders-${body.userId}`)
  revalidateTag('orders-global')

  return NextResponse.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'

export async function submitOrder(formData: FormData) {
  const items = JSON.parse(formData.get('items') as string)

  // 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'

export function OrderForm({ userId }: { userId: string }) {
  return (
    <form action={submitOrder}>
      <input type="hidden" name="userId" value={userId} />
      <input type="hidden" name="items" />
      <button type="submit">Place Order</button>
    </form>
  )
}
Try it live
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 Handlers Cost and performance trade-offs for GET routes Cached Route Uncacheable Route Cost per 1M requests $0 $400 Dynamic data support No Yes Revalidation method Time-based or event-driven N/A Use case Static content, blog posts User-specific data, real-time Performance Fast (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

app/api/stream/events/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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// ============================================
// Streaming Response Example (SSE)
// ============================================

// app/api/stream/events/route.ts

export async function GET() {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      // Send initial connection event
      controller.enqueue(
        encoder.encode('data: {"event": "connected"}\n\n')
      )

      // Simulate event stream
      const events = [
        { type: 'order_created', id: 'ORD-001' },
        { type: 'payment_received', id: 'PAY-001' },
        { type: 'inventory_updated', id: 'INV-001' },
      ]

      for (const event of events) {
        // Wait between events (simulate real-time)
        await new Promise(resolve => setTimeout(resolve, 1000))

        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
        )
      }

      // Close stream
      controller.close()
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  })
}

// ============================================
// Streaming JSON (Chunked)
// ============================================

// app/api/stream/large-dataset/route.ts

export async function GET() {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      // Stream header
      controller.enqueue(encoder.encode('{"data": ['))

      for (let i = 0; i < 10000; i++) {
        const item = JSON.stringify({ id: i, name: `Item ${i}` })
        controller.enqueue(encoder.encode(item))

        if (i < 9999) {
          controller.enqueue(encoder.encode(','))
        }

        // Yield every 100 items to avoid blocking
        if (i % 100 === 0) {
          await new Promise(resolve => setTimeout(resolve, 0))
        }
      }

      // Stream footer
      controller.enqueue(encoder.encode(']}'))
      controller.close()
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'application/json',
    },
  })
}
Try it live
Streaming Disables All Caching
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.

lib/api-utils.ts — security helpersTYPESCRIPT
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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
// ============================================
// Route Handler Security Helpers
// ============================================

// lib/api-utils.ts
import { NextResponse } from 'next/server'
import { z } from 'zod'

export interface ApiResponse<T = unknown> {
  data?: T
  error?: string
}

// CORS headers helper
export function corsHeaders(origin?: string | null) {
  const allowedOrigins = [
    'https://example.com',
    'https://app.example.com',
  ]

  const origin_ = origin && allowedOrigins.includes(origin)
    ? origin
    : 'https://example.com'

  return {
    'Access-Control-Allow-Origin': origin_,
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '86400',
  }
}

// Auth helper — validate bearer token
export async function validateAuth(
  request: Request
): Promise<{ userId: string } | NextResponse> {
  const authHeader = request.headers.get('authorization')

  if (!authHeader?.startsWith('Bearer ')) {
    return NextResponse.json(
      { error: 'Missing or invalid authorization header' },
      { status: 401, headers: corsHeaders(request.headers.get('origin')) }
    ) as NextResponse<ApiResponse>
  }

  const token = authHeader.slice(7)

  try {
    const { payload } = await jwtVerify(
      token,
      new TextEncoder().encode(process.env.JWT_SECRET!)
    )

    return { userId: payload.sub as string }
  } catch {
    return NextResponse.json(
      { error: 'Invalid or expired token' },
      { status: 401, headers: corsHeaders(request.headers.get('origin')) }
    ) as NextResponse<ApiResponse>
  }
}

// Rate limiter helper (requires Upstash Redis or similar)
// import { Ratelimit } from '@upstash/ratelimit'
// import { Redis } from '@upstash/redis'
//
// const ratelimit = new Ratelimit({
//   redis: Redis.fromEnv(),
//   limiter: Ratelimit.slidingWindow(10, '10 s'),
// })
//
// export async function checkRateLimit(
//   identifier: string
// ): Promise<{ success: boolean }> {
//   const { success } = await ratelimit.limit(identifier)
//   return { success }
// }

// Response helpers
export function successResponse<T>(
  data: T,
  status = 200,
  origin?: string | null
) {
  return NextResponse.json(
    { data },
    { status, headers: corsHeaders(origin) }
  )
}

export function errorResponse(
  message: string,
  status = 400,
  origin?: string | null
) {
  return NextResponse.json(
    { error: message },
    { status, headers: corsHeaders(origin) }
  )
}

// Example route using helpers:
// app/api/orders/route.ts
// export async function POST(request: Request) {
//   const auth = await validateAuth(request)
//   if (auth instanceof NextResponse) return auth
//
//   const body = await request.json()
//   const schema = z.object({
//     items: z.array(z.object({
//       productId: z.string(),
//       quantity: z.number().min(1).max(100),
//     })).min(1).max(50),
//   })
//
//   const parsed = schema.safeParse(body)
//   if (!parsed.success) {
//     return errorResponse(parsed.error.message, 400)
//   }
//
//   const order = await createOrder(auth.userId, parsed.data)
//   return successResponse(order, 201)
// }
Try it live
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.

Complete route handler referenceTYPESCRIPT
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
74
75
76
77
78
79
80
81
// ============================================
// Route Handler Complete Reference
// ============================================

// app/api/products/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params

  // Read search params (makes route dynamic)
  const { searchParams } = request.nextUrl
  const includeReviews = searchParams.get('includeReviews')

  const product = await fetch(`https://api.internal.io/products/${id}`)

  if (!product.ok) {
    return NextResponse.json(
      { error: 'Product not found' },
      { status: 404 }
    )
  }

  return NextResponse.json(await product.json())
}

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const body = await request.json()

  // POST is never cached — always runs
  const updated = await fetch(`https://api.internal.io/products/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })

  return NextResponse.json(await updated.json(), { status: 200 })
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params

  await fetch(`https://api.internal.io/products/${id}`, {
    method: 'DELETE',
  })

  return NextResponse.json(
    { success: true },
    { status: 200 }
  )
}

// Handle OPTIONS preflight for CORS
export async function OPTIONS(request: NextRequest) {
  return new Response(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    },
  })
}

// Segment config options
export const dynamic = 'auto' // default — cache if possible
export const dynamic = 'force-dynamic' // never cache
export const dynamic = 'force-static' // force cache (even with dynamic APIs — error if used)
export const revalidate = 3600 // time-based revalidation for all fetches
export const runtime = 'nodejs' // 'nodejs' | 'edge'
export const preferredRegion = 'iad1' // deploy to specific region
Try it live
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 after

interface RouteCost {
  type: 'cached-get' | 'uncached-get' | 'post'
  monthlyRequests: number
  avgExecutionMs: number
}

function calculateMonthlyCost(route: RouteCost): number {
  const { type, monthlyRequests, avgExecutionMs } = route

  if (type === 'cached-get') {
    // Only first request per cache miss pays
    const revalidationInterval = 3600 // 1 hour
    const cacheMissesPerMonth = (30 * 24 * 3600) / revalidationInterval
    const executions = cacheMissesPerMonth
    return (executions * avgExecutionMs * 0.000002) / 1000
  }

  if (type === 'uncached-get') {
    // Every request pays
    const computeCost = (monthlyRequests * avgExecutionMs * 0.000002) / 1000
    // Edge function cost: first 500K free, then $2/M
    const edgeCost = Math.max(0, (monthlyRequests - 500000) / 1000000) * 2
    return computeCost + edgeCost
  }

  // POST - every request pays + DB write
  const computeCost = (monthlyRequests * avgExecutionMs * 0.000002) / 1000
  const dbCost = monthlyRequests * 0.0001 // Approximate DB write cost
  return computeCost + dbCost
}

// Example: 3 uncacheable GET routes
const 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
Try it live
Cache Saves $400/Month Per 3 Routes
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.
★ Route Handler Quick Debug ReferenceFast commands for route handler issues
GET route not caching — always dynamic
Immediate action
Check for request header reads
Commands
grep -E 'headers|cookies|searchParams' app/**/route.ts
grep -E 'export const dynamic|unstable_noStore' app/**/route.ts
Fix now
Remove request.header/request.cookie reads and use path-based params: /api/[userId]/data instead
Cached route returning stale data+
Immediate action
Add revalidation or tag-based invalidation
Commands
Check if revalidation is configured: grep 'revalidate' app/**/route.ts
Check if revalidateTag is called on data mutations
Fix now
Add export const revalidate = 300 or use next: { tags: ['data'] } in fetch calls
CORS errors from external client+
Immediate action
Add CORS headers to each route handler
Commands
grep -E 'Access-Control-Allow-Origin|OPTIONS' app/**/route.ts
Check if OPTIONS handler exists for preflight
Fix now
Add CORS headers to every response and handle OPTIONS method
⚙ Quick Reference
7 commands from this guide
FileCommand / CodePurpose
appapiusers[userId]statsroute.tsexport async function GET(GET Route Caching
Route handler revalidation patternsexport async function GET() {Data Cache Revalidation
Route Handler + Server Action hybrid patternexport async function POST(request: Request) {Route Handlers vs Server Actions
appapistreameventsroute.tsexport async function GET() {Streaming Responses in Route Handlers
libapi-utils.ts — security helpersexport interface ApiResponse {Security
Complete route handler referenceexport async function GET(Route Handlers and the App Router
Cost calculation scriptinterface 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.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Are route handlers cached by default?
02
What is the difference between a route handler and a Server Action?
03
How do I make an uncacheable GET route cacheable?
04
Can route handlers stream responses?
05
Do route handlers have built-in rate limiting?
06
Can I use Edge Runtime for route handlers?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
🔥

That's Next.js. Mark it forged?

5 min read · try the examples if you haven't

Previous
Middleware in Next.js 16: Complete Guide with Auth Patterns
33 / 56 · Next.js
Next
Authentication in Next.js 16: Auth.js, Middleware, and Route Protection