Dev Logs
/Next.js/ Chapter 13: Route Handlers
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
    • Plain English Explanation
    • The basics
    • Reading the request
    • Query parameters
    • Dynamic segments
    • Body
    • Headers and cookies
    • NextRequest — the extended version
    • Returning responses
    • JSON
    • Plain text, XML, anything
    • Files
    • Redirects
    • Setting cookies
    • Webhooks — the canonical use case
    • Streaming responses
    • Caching and segment config
    • CORS
    • Authentication
    • Route Handler or Server Action?
    • Common Pitfalls
    • . route.ts next to page.tsx
    • . Reading the body twice
    • . Not verifying webhook signatures
    • . Parsing a webhook body before verifying
    • . Forgetting await on params
    • . Doing slow work before responding to a webhook
    • . Returning raw errors
    • . Building an API for your own frontend
    • . Assuming layout auth applies
    • Mini Practice Problems
    • Problem 1: Fix the webhook
    • Problem 2: Action or handler?
    • Problem 3: Security review
    • Problem 4: Build it
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🔌 Chapter 13: Route Handlers

Building real HTTP endpoints when a Server Action isn't the right shape — webhooks, public APIs, streaming, and file responses.

📖 Plain English Explanation

Server Actions cover mutations initiated by your own UI. But some things genuinely need an HTTP endpoint:

  • Stripe needs a URL to POST payment events to
  • Your mobile app needs a JSON API
  • A cron job needs something to hit
  • You need to return a PDF, an image, or a CSV
  • You need to stream tokens from an LLM

A Route Handler is a route.ts file that exports functions named after HTTP methods. That's the entire concept.

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

Visit /api/health and you get {"status":"ok"}.

The key thing to internalize: Route Handlers use the Web platform Request and Response objects — the same ones you use with fetch in a browser. No custom framework abstractions to learn.

🛣️ The basics

app/api/users/route.ts      →  /api/users
app/api/users/[id]/route.ts →  /api/users/123
app/rss.xml/route.ts        →  /rss.xml

The file must be named route.ts (or .js), and cannot live in the same folder as a page.tsx — they'd claim the same URL.

Supported exports: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Anything else returns 405.

ts
// app/api/posts/route.ts
import { db } from '@/lib/db'

export async function GET() {
  const posts = await db.post.findMany()
  return Response.json(posts)
}

export async function POST(request: Request) {
  const body = await request.json()
  const post = await db.post.create({ data: body })
  return Response.json(post, { status: 201 })
}
js
// app/api/posts/route.js
import { db } from '@/lib/db'

export async function GET() {
  const posts = await db.post.findMany()
  return Response.json(posts)
}

export async function POST(request) {
  const body = await request.json()
  const post = await db.post.create({ data: body })
  return Response.json(post, { status: 201 })
}

Note there's no app/api/ requirement — that's just convention. app/rss.xml/route.ts serving /rss.xml works fine.

📥 Reading the request

Query parameters

ts
// app/api/search/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const query = searchParams.get('q') ?? ''
  const page = Number(searchParams.get('page') ?? 1)

  const results = await search(query, page)
  return Response.json({ results, page })
}
js
// app/api/search/route.js
export async function GET(request) {
  const { searchParams } = new URL(request.url)
  const query = searchParams.get('q') ?? ''
  const page = Number(searchParams.get('page') ?? 1)

  const results = await search(query, page)
  return Response.json({ results, page })
}

Route Handlers don't receive a searchParams prop — parse request.url yourself.

Dynamic segments

ts
// app/api/posts/[id]/route.ts
export async function GET(
  request: Request,
  context: RouteContext<'/api/posts/[id]'>
) {
  const { id } = await context.params        // ← Promise in Next.js 16
  const post = await db.post.findUnique({ where: { id } })

  if (!post) {
    return Response.json({ error: 'Not found' }, { status: 404 })
  }
  return Response.json(post)
}
js
// app/api/posts/[id]/route.js
export async function GET(request, context) {
  const { id } = await context.params
  const post = await db.post.findUnique({ where: { id } })

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

⚠️ Changed in Next.js 16

params in Route Handlers is a Promise, same as in pages.

ts
// ❌ Next.js 14
export async function GET(request, { params }) {
  const id = params.id
}

// ✅ Next.js 16
export async function GET(request, context: RouteContext<'/api/posts/[id]'>) {
  const { id } = await context.params
}

Body

ts
const json = await request.json()          // JSON
const text = await request.text()          // raw string
const form = await request.formData()      // multipart / urlencoded
const buffer = await request.arrayBuffer() // binary

You can only read the body once. If you need it twice (common with webhook signature verification), read it as text and parse manually:

ts
const raw = await request.text()
verifySignature(raw, signature)
const body = JSON.parse(raw)

Headers and cookies

Two ways — the request object, or the Next.js helpers:

ts
// app/api/me/route.ts
import { cookies, headers } from 'next/headers'

export async function GET(request: Request) {
  // From the request
  const auth = request.headers.get('authorization')

  // Or via the helpers (async in Next.js 16)
  const cookieStore = await cookies()
  const session = cookieStore.get('session')?.value

  const headerList = await headers()
  const ua = headerList.get('user-agent')

  return Response.json({ session: !!session })
}

NextRequest — the extended version

Next.js adds a NextRequest with conveniences over the standard Request:

ts
// app/api/example/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const query = request.nextUrl.searchParams.get('q')   // parsed URL
  const path = request.nextUrl.pathname
  const token = request.cookies.get('token')?.value     // typed cookie access

  return NextResponse.json({ query, path })
}
js
// app/api/example/route.js
import { NextResponse } from 'next/server'

export async function GET(request) {
  const query = request.nextUrl.searchParams.get('q')
  const path = request.nextUrl.pathname
  const token = request.cookies.get('token')?.value

  return NextResponse.json({ query, path })
}

Use plain Request/Response when the standard API is enough. Reach for NextRequest/NextResponse when you want nextUrl or cookie helpers.

📤 Returning responses

JSON

ts
return Response.json({ ok: true })
return Response.json({ error: 'Bad request' }, { status: 400 })
return Response.json(data, {
  status: 200,
  headers: { 'Cache-Control': 's-maxage=60, stale-while-revalidate=300' },
})

Plain text, XML, anything

ts
// app/rss.xml/route.ts
import { getPosts } from '@/lib/posts'

export async function GET() {
  const posts = await getPosts()

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Acme Blog</title>
    <link>https://acme.com</link>
    ${posts
      .map(
        (p) => `<item>
      <title>${escapeXml(p.title)}</title>
      <link>https://acme.com/blog/${p.slug}</link>
      <pubDate>${new Date(p.createdAt).toUTCString()}</pubDate>
    </item>`
      )
      .join('')}
  </channel>
</rss>`

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 's-maxage=3600, stale-while-revalidate',
    },
  })
}
js
// app/rss.xml/route.js
import { getPosts } from '@/lib/posts'

export async function GET() {
  const posts = await getPosts()

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Acme Blog</title>
    ${posts.map((p) => `<item><title>${escapeXml(p.title)}</title></item>`).join('')}
  </channel>
</rss>`

  return new Response(xml, {
    headers: { 'Content-Type': 'application/xml' },
  })
}

Files

ts
// app/api/invoices/[id]/route.ts
export async function GET(
  request: Request,
  context: RouteContext<'/api/invoices/[id]'>
) {
  const { id } = await context.params
  const session = await verifySession()
  if (!session) return new Response('Unauthorized', { status: 401 })

  const invoice = await db.invoice.findUnique({ where: { id } })
  if (!invoice || invoice.userId !== session.userId) {
    return new Response('Not found', { status: 404 })
  }

  const pdf = await generatePdf(invoice)

  return new Response(pdf, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${id}.pdf"`,
    },
  })
}

Redirects

ts
import { redirect } from 'next/navigation'

export async function GET() {
  redirect('/new-location')
}

// or explicitly
return Response.redirect(new URL('/new-location', request.url), 307)

Setting cookies

ts
// app/api/login/route.ts
import { NextResponse } from 'next/server'

export async function POST(request: Request) {
  const { email, password } = await request.json()
  const session = await authenticate(email, password)

  if (!session) {
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
  }

  const response = NextResponse.json({ ok: true })
  response.cookies.set('session', session.token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
    path: '/',
  })
  return response
}
js
// app/api/login/route.js
import { NextResponse } from 'next/server'

export async function POST(request) {
  const { email, password } = await request.json()
  const session = await authenticate(email, password)

  if (!session) {
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
  }

  const response = NextResponse.json({ ok: true })
  response.cookies.set('session', session.token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
    path: '/',
  })
  return response
}

httpOnly and sameSite are not optional for session cookies.

🪝 Webhooks — the canonical use case

This is what Route Handlers are for. A Stripe webhook, done properly:

ts
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { db } from '@/lib/db'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const secret = process.env.STRIPE_WEBHOOK_SECRET!

export async function POST(request: Request) {
  const signature = request.headers.get('stripe-signature')
  if (!signature) {
    return new Response('Missing signature', { status: 400 })
  }

  // Read as TEXT — signature verification needs the exact raw bytes
  const raw = await request.text()

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(raw, signature, secret)
  } catch (err) {
    console.error('Invalid webhook signature', err)
    return new Response('Invalid signature', { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object
      await db.order.update({
        where: { stripeSessionId: session.id },
        data: { status: 'paid' },
      })
      break
    }
    case 'customer.subscription.deleted': {
      const sub = event.data.object
      await db.subscription.update({
        where: { stripeId: sub.id },
        data: { status: 'cancelled' },
      })
      break
    }
    default:
      console.log('Unhandled event type', event.type)
  }

  // Always 200 quickly, or the provider retries
  return Response.json({ received: true })
}
js
// app/api/webhooks/stripe/route.js
import Stripe from 'stripe'
import { db } from '@/lib/db'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const secret = process.env.STRIPE_WEBHOOK_SECRET

export async function POST(request) {
  const signature = request.headers.get('stripe-signature')
  if (!signature) return new Response('Missing signature', { status: 400 })

  const raw = await request.text()

  let event
  try {
    event = stripe.webhooks.constructEvent(raw, signature, secret)
  } catch (err) {
    return new Response('Invalid signature', { status: 400 })
  }

  if (event.type === 'checkout.session.completed') {
    await db.order.update({
      where: { stripeSessionId: event.data.object.id },
      data: { status: 'paid' },
    })
  }

  return Response.json({ received: true })
}

Four rules for webhooks:

  1. Verify the signature. The URL is public; anyone can POST to it.
  2. Read the raw body as text. Parsing JSON first breaks signature verification.
  3. Respond fast. Providers time out and retry. Offload slow work with after().
  4. Be idempotent. Retries are normal — the same event will arrive twice.

For slow processing:

ts
import { after } from 'next/server'

export async function POST(request: Request) {
  const event = verifyAndParse(await request.text())

  after(async () => {
    await sendReceiptEmail(event)      // runs after the 200 is sent
    await syncToWarehouse(event)
  })

  return Response.json({ received: true })
}

🌊 Streaming responses

For LLM output or long-running progress:

ts
// app/api/chat/route.ts
export async function POST(request: Request) {
  const { prompt } = await request.json()

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder()
      try {
        for await (const chunk of callModel(prompt)) {
          controller.enqueue(encoder.encode(chunk))
        }
      } finally {
        controller.close()
      }
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'no-cache',
    },
  })
}
js
// app/api/chat/route.js
export async function POST(request) {
  const { prompt } = await request.json()

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder()
      try {
        for await (const chunk of callModel(prompt)) {
          controller.enqueue(encoder.encode(chunk))
        }
      } finally {
        controller.close()
      }
    },
  })

  return new Response(stream, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  })
}

Consumed on the client:

tsx
'use client'
import { useState } from 'react'

export function Chat() {
  const [output, setOutput] = useState('')

  async function send(prompt: string) {
    setOutput('')
    const res = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ prompt }),
    })

    const reader = res.body!.getReader()
    const decoder = new TextDecoder()

    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      setOutput((prev) => prev + decoder.decode(value))
    }
  }

  return <div>{output}</div>
}

Streaming is the clearest case where a Route Handler beats a Server Action — actions return a single value, not a stream.

⚙️ Caching and segment config

By default, GET handlers with no dynamic data may be cached. Anything reading request, cookies(), or headers() is automatically dynamic.

ts
// app/api/time/route.ts
export const dynamic = 'force-dynamic'    // never cache
export const revalidate = 60              // or: cache for 60 seconds
export const maxDuration = 30             // seconds before timeout

export async function GET() {
  return Response.json({ now: Date.now() })
}

For public data, an explicit Cache-Control header is usually clearer:

ts
return Response.json(data, {
  headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' },
})

🌐 CORS

Route Handlers are same-origin by default. To allow cross-origin calls, handle OPTIONS and set headers:

ts
// app/api/public/route.ts
const corsHeaders = {
  'Access-Control-Allow-Origin': 'https://trusted-partner.com',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}

export async function OPTIONS() {
  return new Response(null, { status: 204, headers: corsHeaders })
}

export async function GET() {
  const data = await getPublicData()
  return Response.json(data, { headers: corsHeaders })
}

Don't reflexively use '*'. Name the origins you actually trust.

🔐 Authentication

Route Handlers do not go through your layouts, so a layout auth check protects nothing here.

ts
// app/api/admin/users/route.ts
import { verifySession } from '@/lib/dal'

export async function GET() {
  const session = await verifySession()

  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
  if (session.role !== 'admin') {
    return Response.json({ error: 'Forbidden' }, { status: 403 })
  }

  const users = await db.user.findMany({
    select: { id: true, email: true, role: true },
  })
  return Response.json(users)
}

For token-based APIs (mobile apps, partners):

ts
export async function GET(request: Request) {
  const auth = request.headers.get('authorization')
  if (!auth?.startsWith('Bearer ')) {
    return Response.json({ error: 'Missing token' }, { status: 401 })
  }

  const user = await verifyApiToken(auth.slice(7))
  if (!user) {
    return Response.json({ error: 'Invalid token' }, { status: 401 })
  }

  return Response.json(await getDataFor(user))
}

🆚 Route Handler or Server Action?

Server Action when:
  ✅ Your own UI is submitting a form
  ✅ A button in your app mutates data
  ✅ You want progressive enhancement
  ✅ You'd otherwise write an endpoint only your own UI calls

Route Handler when:
  ✅ An external service calls you (webhooks, cron)
  ✅ A mobile app or third party needs an API
  ✅ You need GET, PUT, DELETE, or custom headers/status
  ✅ You're returning a file, image, XML, or CSV
  ✅ You're streaming a response
  ✅ You need CORS

The most common mistake in App Router codebases is writing a Route Handler that only the app's own frontend calls. If it's your UI talking to your server, that's a Server Action.

⚠️ Common Pitfalls

1. route.ts next to page.tsx

app/api/users/
├── page.tsx    ❌
└── route.ts    ❌

They claim the same URL. Move one.

2. Reading the body twice

ts
// ❌ TypeError: body already read
const text = await request.text()
const json = await request.json()

// ✅
const text = await request.text()
const json = JSON.parse(text)

3. Not verifying webhook signatures

The endpoint is public. Without verification, anyone can mark orders as paid.

4. Parsing a webhook body before verifying

ts
// ❌ signature check will fail — JSON round-trip changes the bytes
const body = await request.json()
verify(JSON.stringify(body), signature)

// ✅
const raw = await request.text()
verify(raw, signature)

5. Forgetting await on params

Route Handler params is a Promise in Next.js 16, same as pages.

6. Doing slow work before responding to a webhook

Providers time out at a few seconds and retry. Use after() for anything slow.

7. Returning raw errors

ts
// ❌ leaks stack traces, queries, connection strings
catch (error) {
  return Response.json({ error: String(error) }, { status: 500 })
}

// ✅
catch (error) {
  console.error(error)
  return Response.json({ error: 'Internal server error' }, { status: 500 })
}

8. Building an API for your own frontend

ts
// ❌ app/api/posts/route.ts, called only by app/posts/page.tsx
// ✅ just query the database in the Server Component

9. Assuming layout auth applies

It doesn't. Check the session inside every handler.

🏋️ Mini Practice Problems

Problem 1: Fix the webhook

ts
export async function POST(request: Request) {
  const event = await request.json()
  await processPayment(event)              // takes 8 seconds
  await sendEmails(event)                  // takes 4 seconds
  return Response.json({ ok: true })
}

Four problems. Name and fix them all.

Problem 2: Action or handler?

  • A. A contact form on your marketing site
  • B. A GitHub webhook for CI status
  • C. A "download my data as CSV" button
  • D. An admin deleting a user
  • E. Your React Native app fetching a product list
  • F. Streaming an AI-generated summary
  • G. A nightly cron that cleans up expired sessions

Problem 3: Security review

ts
// app/api/users/[id]/route.ts
export async function DELETE(request: Request, context) {
  const { id } = await context.params
  await db.user.delete({ where: { id } })
  return Response.json({ deleted: id })
}

What's wrong? Rewrite it.

Problem 4: Build it

An endpoint at /api/export that:

  • Requires a valid session
  • Accepts ?format=csv|json
  • Returns the user's own orders only
  • Sets a filename download header
  • Is rate-limited to 3 requests per minute
  • Returns proper status codes for every failure mode

💼 Interview Notes

Common Questions

Q: What is a Route Handler? A route.ts file exporting functions named after HTTP methods. Each receives a Web Request and returns a Web Response. It's the App Router replacement for pages/api.

Q: When would you use a Route Handler instead of a Server Action? When something outside your UI needs the endpoint: webhooks, mobile apps, third parties, cron jobs. Also for non-POST methods, file and XML responses, streaming, and custom CORS or cache headers.

Q: How do you handle a webhook safely? Verify the signature against the raw text body before parsing. Respond 200 quickly and move slow work into after(). Make the handler idempotent, because providers retry.

Q: Why must you read a webhook body as text rather than JSON? Signature verification hashes the exact bytes that were sent. Parsing to JSON and re-stringifying changes whitespace and key order, so the hash won't match.

Q: Do Route Handlers go through layouts or proxy? Not layouts — a layout auth check protects nothing. They do go through proxy.ts, which is where you can apply cross-cutting checks.

Q: Response or NextResponse? Response is the Web standard and enough for most cases. NextResponse adds cookies.set() and other conveniences; NextRequest adds nextUrl and typed cookie access.

Q: How is caching handled? GET handlers can be cached, but reading request, cookies(), or headers() makes them dynamic automatically. Control it with export const dynamic/revalidate, or by setting Cache-Control headers directly.

🏢 Asked at Companies

  • Stripe: "Implement a webhook receiver. Walk me through every security consideration."
  • Vercel: "When is a Route Handler the wrong tool, and what would you write instead?"
  • OpenAI: "Stream tokens from a model to the browser. Show both ends."
  • Shopify: "Design a public API endpoint for partners — auth, CORS, rate limiting, caching."

📊 Visual Memory Aid

              FILE → URL

  app/api/users/route.ts        →  /api/users
  app/api/users/[id]/route.ts   →  /api/users/:id
  app/rss.xml/route.ts          →  /rss.xml
  app/sitemap.xml/route.ts      →  /sitemap.xml

  ⚠️  route.ts and page.tsx cannot share a folder


              REQUEST → RESPONSE

  export async function POST(request, context) {
    await request.json()          body (once only!)
    await request.text()          raw body (webhooks)
    await request.formData()      uploads
    request.headers.get(...)      headers
    await context.params          dynamic segments (Promise!)
    new URL(request.url)          query string

    return Response.json(data, { status, headers })
  }


              ACTION vs HANDLER

  ┌─────────────────────┬──────────┬──────────┐
  │                     │  Action  │ Handler  │
  ├─────────────────────┼──────────┼──────────┤
  │ Your own form       │    ✅    │    ⚠️    │
  │ Webhook             │    ❌    │    ✅    │
  │ Mobile / 3rd party  │    ❌    │    ✅    │
  │ GET / DELETE verbs  │    ❌    │    ✅    │
  │ File / XML response │    ❌    │    ✅    │
  │ Streaming           │    ❌    │    ✅    │
  │ Works without JS    │    ✅    │    ❌    │
  └─────────────────────┴──────────┴──────────┘


              WEBHOOK CHECKLIST

  □ verify signature against the RAW text body
  □ respond 200 fast (< 1s)
  □ move slow work into after()
  □ handle duplicate deliveries idempotently
  □ log unhandled event types, don't 500 on them

🎯 Key Takeaways

  1. route.ts exports HTTP method functions and uses the standard Web Request/Response — no framework-specific API to learn.
  2. Use handlers for external consumers: webhooks, mobile apps, cron, files, streaming, CORS. Use Server Actions for your own UI.
  3. params is a Promise here too in Next.js 16 — await context.params, typed with RouteContext<'/api/...'>.
  4. Webhooks need the raw text body for signature verification, a fast 200, and idempotent handling. after() moves slow work off the critical path.
  5. Route Handlers bypass layouts entirely. Every handler authenticates and authorizes itself, or it's unprotected.

Next Chapter: Caching & use cache →

Practice: Build a webhook receiver with signature verification, a public JSON API with token auth and CORS, and an RSS feed at /rss.xml. Test the webhook with an intentionally wrong signature and confirm it's rejected.


PreviousChapter 12: Server Actions & MutationsNextChapter 14: Caching & use cache

Open source, free forever. Built by iammhador.

Contribute on GitHub