HTTP Methods & Dynamic Routes in Route Handlers
HTTP Methods & Dynamic Routes in Route Handlers
Section titled “HTTP Methods & Dynamic Routes in Route Handlers”Introduction
Section titled “Introduction”Route Handlers support all standard HTTP methods — GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS — each mapped to an exported function of the same name. When combined with dynamic route segments ([id], [slug]), Route Handlers can create powerful RESTful APIs. Understanding how to use each HTTP method and how to structure dynamic Route Handlers is essential for building production-ready APIs in Next.js.
Why do we need this?
Section titled “Why do we need this?”Building a real API requires more than just returning data. You need to:
- Create resources with POST
- Read resources with GET (list and single)
- Update resources with PUT or PATCH
- Delete resources with DELETE
- Handle CORS preflight with OPTIONS
- Access dynamic parameters like IDs, slugs, and composite keys
- Parse query strings for filtering, sorting, and pagination
Route Handlers support all of these requirements natively within the App Router’s file-system structure.
Problem Statement
Section titled “Problem Statement”Developers building APIs need to map HTTP methods to specific actions, validate dynamic parameters, handle request bodies, and return appropriate status codes. Without a clear understanding of how Route Handlers process different methods and route patterns, APIs become disorganized, insecure, and difficult to maintain.
Real World Story
Section titled “Real World Story”Maria is building a customer management API for her SaaS product. She needs endpoints like:
GET /api/customers— List all customers (with pagination)POST /api/customers— Create a new customerGET /api/customers/[id]— Get one customerPUT /api/customers/[id]— Update customer detailsDELETE /api/customers/[id]— Delete a customer
By creating app/api/customers/route.ts (list/create) and app/api/customers/[id]/route.ts (read/update/delete), she follows REST conventions and keeps her API organized. Each HTTP method maps clearly to a function in the appropriate handler file.
Real World Analogy
Section titled “Real World Analogy”Think of HTTP methods as verbs in a language:
- GET = “Look at this” (reading, no side effects)
- POST = “Create this” (new resource, unknown URL)
- PUT = “Replace this entirely” (full update, known URL)
- PATCH = “Update this part” (partial update)
- DELETE = “Remove this” (delete resource)
- OPTIONS = “What can I do here?” (describe available methods)
Dynamic routes [id] are like post office boxes — each box has a unique number (the ID), and you can check what’s inside (GET), replace the contents (PUT), or remove them (DELETE).
Visual Explanation
Section titled “Visual Explanation”Route Handler Organization:
app/api/├── route.ts → GET /api (health check)│ POST /api (create root resource)│├── customers/│ ├── route.ts → GET /api/customers (list)│ │ POST /api/customers (create)│ ││ └── [id]/│ └── route.ts → GET /api/customers/123 (read)│ PUT /api/customers/123 (update)│ PATCH /api/customers/123 (partial)│ DELETE /api/customers/123 (delete)│├── products/│ ├── route.ts → GET /api/products (list)│ │ POST /api/products (create)│ ││ └── [category]/│ └── [productId]/│ └── route.ts → GET /api/products/electronics/123│ PUT /api/products/electronics/123Mermaid Diagram 1: HTTP Methods in Route Handlers
Section titled “Mermaid Diagram 1: HTTP Methods in Route Handlers”flowchart TD subgraph "Route Handler File" RH["route.ts"] --> GET["export GET()"] RH --> POST["export POST()"] RH --> PUT["export PUT()"] RH --> PATCH["export PATCH()"] RH --> DELETE["export DELETE()"] RH --> HEAD["export HEAD()"] RH --> OPTIONS["export OPTIONS()"] end
subgraph "Method Behaviors" GET -->|"Read resource"| R1["Return data<br/>Status 200"] POST -->|"Create resource"| R2["Return created<br/>Status 201"] PUT -->|"Replace resource"| R3["Return updated<br/>Status 200"] PATCH -->|"Partial update"| R4["Return updated<br/>Status 200"] DELETE -->|"Delete resource"| R5["Return confirmation<br/>Status 200/204"] HEAD -->|"Headers only"| R6["Return same headers as GET<br/>No body"] OPTIONS -->|"Describe methods"| R7["Return allowed methods<br/>Status 204"] end
style RH fill:#7c3aed,color:#fff style GET fill:#22c55e,color:#fff style POST fill:#f59e0b,color:#000 style PUT fill:#4f46e5,color:#fff style DELETE fill:#ef4444,color:#fffInternal Working
Section titled “Internal Working”When a request arrives at a Route Handler:
- Route matching — The request path is matched against the file system
- Method detection — The HTTP method is extracted from the request
- Function lookup — Next.js looks for an exported function matching the method name
- 405 handling — If no matching function exists, Next.js automatically returns 405 Method Not Allowed
- Dynamic params — For dynamic routes (
[id]), the extracted value is passed asparams - Execution — The matched function runs with
requestandparamsas arguments - Response — The function must return a Response or NextResponse object
Mermaid Diagram 2: Request Flow by Method
Section titled “Mermaid Diagram 2: Request Flow by Method”sequenceDiagram participant C as Client participant N as Next.js participant R as Route Handler
C->>N: POST /api/customers N->>N: Match route pattern N->>R: Look for POST export R->>R: Parse JSON body R->>R: Validate & create customer R-->>N: Return NextResponse(customer, {status: 201}) N-->>C: 201 Created + JSON body
Note over C,N: Different request, same handler
C->>N: GET /api/customers N->>R: Look for GET export R->>R: Query database R-->>N: Return NextResponse(customers) N-->>C: 200 OK + JSON array
Note over C,N: Unsupported method
C->>N: PATCH /api/customers N->>R: Look for PATCH export → Not found N-->>C: 405 Method Not AllowedArchitecture
Section titled “Architecture”The relationship between HTTP methods, route structure, and CRUD operations:
flowchart TD subgraph "Route Structure" C["app/api/customers/route.ts"] C_ID["app/api/customers/[id]/route.ts"] end
subgraph "CRUD Operations" L["GET → List all"] CR["POST → Create"] R["GET → Read one"] U["PUT → Update all"] PU["PATCH → Partial update"] D["DELETE → Delete"] end
C --> L C --> CR C_ID --> R C_ID --> U C_ID --> PU C_ID --> D
style C fill:#7c3aed,color:#fff style C_ID fill:#4f46e5,color:#fff style L fill:#22c55e,color:#fff style CR fill:#f59e0b,color:#000 style R fill:#22c55e,color:#fff style U fill:#4f46e5,color:#fff style D fill:#ef4444,color:#fffMermaid Diagram 4: Dynamic Route Parameter Flow
Section titled “Mermaid Diagram 4: Dynamic Route Parameter Flow”flowchart LR URL["URL: /api/products/electronics/123"] --> P1["Segment: electronics"] URL --> P2["Segment: 123"] P1 --> F1["app/api/products/[category]/[productId]/route.ts"] F1 --> P["params = { category: 'electronics', productId: '123' }"] P --> H["Handler receives params"]
style URL fill:#7c3aed,color:#fff style F1 fill:#f59e0b,color:#000 style P fill:#22c55e,color:#fffStep-by-Step Flow
Section titled “Step-by-Step Flow”- Plan your API structure — Determine endpoints, methods, and response formats
- Create route files — Create
route.tsfiles matching your desired URL structure - Export method handlers — Export
GET,POST,PUT,PATCH,DELETEas named functions - Access request data — Parse JSON body, query strings, and headers
- Access dynamic params — Use
paramsfrom the second argument of the handler - Implement business logic — Validate, process, and store/retrieve data
- Return responses — Use
NextResponse.json()with appropriate status codes
Syntax
Section titled “Syntax”// List & Create — app/api/customers/route.tsimport { NextResponse } from 'next/server'
// GET /api/customers — List all customersexport async function GET(request: Request) { const { searchParams } = new URL(request.url) const page = searchParams.get('page') || '1' const customers = await db.customer.findMany({ skip: (+page - 1) * 10, take: 10 }) return NextResponse.json(customers)}
// POST /api/customers — Create a customerexport async function POST(request: Request) { const body = await request.json() const customer = await db.customer.create({ data: body }) return NextResponse.json(customer, { status: 201 })}// Read, Update, Delete — app/api/customers/[id]/route.tsimport { NextResponse } from 'next/server'
export async function GET(request: Request, { params }: { params: { id: string } }) { const customer = await db.customer.findUnique({ where: { id: params.id } }) if (!customer) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json(customer)}
export async function PUT(request: Request, { params }: { params: { id: string } }) { const body = await request.json() const customer = await db.customer.update({ where: { id: params.id }, data: body }) return NextResponse.json(customer)}
export async function DELETE(request: Request, { params }: { params: { id: string } }) { await db.customer.delete({ where: { id: params.id } }) return NextResponse.json({ message: 'Deleted' })}Basic Example
Section titled “Basic Example”Complete CRUD for a notes API:
// app/api/notes/route.ts — List and create notesimport { NextResponse } from 'next/server'
// In-memory storelet notes = [ { id: '1', title: 'Meeting Notes', content: 'Discuss Q1 goals', createdAt: new Date() }, { id: '2', title: 'Shopping List', content: 'Milk, eggs, bread', createdAt: new Date() },]let nextId = 3
// GET /api/notes — List all notesexport async function GET() { return NextResponse.json(notes.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() ))}
// POST /api/notes — Create a noteexport async function POST(request: Request) { const body = await request.json()
// Basic validation if (!body.title) { return NextResponse.json({ error: 'Title is required' }, { status: 400 }) }
const note = { id: String(nextId++), title: body.title, content: body.content || '', createdAt: new Date(), }
notes.push(note) return NextResponse.json(note, { status: 201 })}
// POST /api/notes with search — Demonstrate query params// Also handle HEAD for header-only requestsexport async function HEAD(request: Request) { return new Response(null, { headers: { 'X-Total-Count': String(notes.length), 'Content-Type': 'application/json', }, })}// app/api/notes/[id]/route.ts — Single note operationsimport { NextResponse } from 'next/server'
interface RouteParams { params: { id: string }}
// Reuse the same in-memory storeconst notes = [ { id: '1', title: 'Meeting Notes', content: 'Discuss Q1 goals', createdAt: new Date() }, { id: '2', title: 'Shopping List', content: 'Milk, eggs, bread', createdAt: new Date() },]
function findNote(id: string) { return notes.find(n => n.id === id)}
// GET /api/notes/1 — Get single noteexport async function GET( request: Request, { params }: RouteParams) { const note = findNote(params.id)
if (!note) { return NextResponse.json( { error: 'Note not found' }, { status: 404 } ) }
return NextResponse.json(note)}
// PUT /api/notes/1 — Replace entire noteexport async function PUT( request: Request, { params }: RouteParams) { const note = findNote(params.id)
if (!note) { return NextResponse.json( { error: 'Note not found' }, { status: 404 } ) }
const body = await request.json()
// PUT replaces the entire resource note.title = body.title note.content = body.content || ''
return NextResponse.json(note)}
// PATCH /api/notes/1 — Partial updateexport async function PATCH( request: Request, { params }: RouteParams) { const note = findNote(params.id)
if (!note) { return NextResponse.json( { error: 'Note not found' }, { status: 404 } ) }
const body = await request.json()
// PATCH only updates provided fields if (body.title !== undefined) note.title = body.title if (body.content !== undefined) note.content = body.content
return NextResponse.json(note)}
// DELETE /api/notes/1 — Delete noteexport async function DELETE( request: Request, { params }: RouteParams) { const index = notes.findIndex(n => n.id === params.id)
if (index === -1) { return NextResponse.json( { error: 'Note not found' }, { status: 404 } ) }
notes.splice(index, 1)
// 204 No Content for successful deletion return new Response(null, { status: 204 })}// app/api/notes/options/route.ts — CORS preflight handlerimport { NextResponse } from 'next/server'
export async function OPTIONS(request: Request) { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400', }, })}What’s happening:
GETon/api/notesreturns all notes sorted by datePOSTcreates a new note with validationGETon/api/notes/[id]returns a single notePUTreplaces the entire note resourcePATCHonly updates provided fieldsDELETEremoves a note and returns 204HEADreturns headers without body (useful for checking existence)OPTIONSreturns CORS headers for preflight requests
Intermediate Example
Section titled “Intermediate Example”Multi-segment dynamic routes with query parameters and conditional methods:
import { NextResponse } from 'next/server'
interface RouteParams { params: { year: string month: string slug: string }}
// GET /api/posts/2024/01/hello-worldexport async function GET( request: Request, { params }: RouteParams) { const { year, month, slug } = params
// Validate date segments const yearNum = parseInt(year) const monthNum = parseInt(month)
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2030) { return NextResponse.json( { error: 'Invalid year' }, { status: 400 } ) }
if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) { return NextResponse.json( { error: 'Invalid month' }, { status: 400 } ) }
// Get query parameters const { searchParams } = new URL(request.url) const includeComments = searchParams.get('include_comments') === 'true' const version = searchParams.get('v')
// Fetch the post const post = await db.post.findUnique({ where: { slug_year_month: { slug, year, month } }, include: includeComments ? { comments: true } : undefined, })
if (!post) { return NextResponse.json( { error: 'Post not found' }, { status: 404 } ) }
return NextResponse.json(post)}
// PUT /api/posts/2024/01/hello-worldexport async function PUT( request: Request, { params }: RouteParams) { const body = await request.json()
const post = await db.post.update({ where: { slug_year_month: { slug: params.slug, year: params.year, month: params.month } }, data: { title: body.title, content: body.content, tags: body.tags, }, })
return NextResponse.json(post)}
// DELETE /api/posts/2024/01/hello-worldexport async function DELETE( request: Request, { params }: RouteParams) { await db.post.delete({ where: { slug_year_month: { slug: params.slug, year: params.year, month: params.month } }, })
return NextResponse.json({ message: 'Post deleted' })}Advanced Example
Section titled “Advanced Example”File upload handler with FormData, multiple methods, and streaming:
// app/api/documents/route.ts — Document managementimport { NextResponse } from 'next/server'import { writeFile, mkdir } from 'fs/promises'import path from 'path'
export async function GET(request: Request) { const { searchParams } = new URL(request.url) const type = searchParams.get('type') || 'all' const documents = await db.document.findMany({ where: type !== 'all' ? { type } : undefined, orderBy: { uploadedAt: 'desc' }, }) return NextResponse.json(documents)}
export async function POST(request: Request) { try { const formData = await request.formData() const file = formData.get('file') as File const description = formData.get('description') as string
if (!file) { return NextResponse.json({ error: 'No file provided' }, { status: 400 }) }
// Validate file type const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'] if (!allowedTypes.includes(file.type)) { return NextResponse.json( { error: 'Invalid file type. Allowed: JPEG, PNG, PDF' }, { status: 400 } ) }
// Validate file size (max 10MB) const maxSize = 10 * 1024 * 1024 if (file.size > maxSize) { return NextResponse.json( { error: 'File too large. Maximum size is 10MB' }, { status: 400 } ) }
// Save file const uploadDir = path.join(process.cwd(), 'public/uploads') await mkdir(uploadDir, { recursive: true })
const filename = `${Date.now()}-${file.name}` const filepath = path.join(uploadDir, filename) const bytes = await file.arrayBuffer() await writeFile(filepath, Buffer.from(bytes))
// Save to database const document = await db.document.create({ data: { filename: file.name, storedPath: `/uploads/${filename}`, size: file.size, type: file.type, description, }, })
return NextResponse.json(document, { status: 201 }) } catch (error) { return NextResponse.json({ error: 'Upload failed' }, { status: 500 }) }}
// OPTIONS handler for CORSexport async function OPTIONS() { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }, })}Production Example
Section titled “Production Example”Production-ready API with authentication, pagination, filtering, and error handling:
// app/api/users/route.ts — Production users APIimport { NextResponse } from 'next/server'import { getServerSession } from 'next-auth'import { z } from 'zod'
const CreateUserSchema = z.object({ name: z.string().min(2).max(100), email: z.string().email(), role: z.enum(['user', 'admin', 'moderator']).default('user'),})
export async function GET(request: Request) { try { const session = await getServerSession() if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
const { searchParams } = new URL(request.url) const page = Math.max(1, parseInt(searchParams.get('page') || '1')) const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20'))) const search = searchParams.get('search') const role = searchParams.get('role')
const where: any = {} if (search) { where.OR = [ { name: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } }, ] } if (role) where.role = role
const [users, total] = await Promise.all([ db.user.findMany({ where, skip: (page - 1) * limit, take: limit, orderBy: { createdAt: 'desc' }, select: { id: true, name: true, email: true, role: true, createdAt: true }, }), db.user.count({ where }), ])
return NextResponse.json({ data: users, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), hasNext: page * limit < total, hasPrev: page > 1, }, }, { headers: { 'Cache-Control': 'private, max-age=30', 'X-Total-Count': String(total), }, }) } catch (error) { console.error('Error fetching users:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) }}
export async function POST(request: Request) { try { const session = await getServerSession() if (!session || session.user.role !== 'admin') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }
const body = await request.json() const validation = CreateUserSchema.safeParse(body)
if (!validation.success) { return NextResponse.json({ error: 'Validation failed', details: validation.error.flatten().fieldErrors, }, { status: 400 }) }
const existing = await db.user.findUnique({ where: { email: validation.data.email } }) if (existing) { return NextResponse.json({ error: 'Email already in use' }, { status: 409 }) }
const user = await db.user.create({ data: validation.data }) return NextResponse.json(user, { status: 201 }) } catch (error) { console.error('Error creating user:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) }}Folder Structure
Section titled “Folder Structure”app/api/├── route.ts → /api (API overview/health)├── auth/│ └── [...nextauth]/│ └── route.ts → NextAuth handler├── users/│ ├── route.ts → GET, POST /api/users│ └── [id]/│ └── route.ts → GET, PUT, PATCH, DELETE /api/users/123├── products/│ ├── route.ts → GET, POST /api/products│ └── [category]/│ ├── route.ts → GET /api/products/electronics│ └── [productId]/│ └── route.ts → GET /api/products/electronics/123├── posts/│ ├── route.ts → GET, POST /api/posts│ └── [year]/│ └── [month]/│ └── [slug]/│ └── route.ts → GET /api/posts/2024/01/hello-world├── webhooks/│ └── stripe/│ └── route.ts → POST /api/webhooks/stripe└── upload/ └── route.ts → POST /api/upload
// Static routes take priority over dynamic routes// /api/users/me → app/api/users/me/route.ts (static)// /api/users/[id] → app/api/users/[id]/route.ts (dynamic)🚀 Best Practices
Section titled “🚀 Best Practices”- Use standard HTTP methods — GET for reads, POST for creates, PUT for full updates, PATCH for partial, DELETE for removal
- Return appropriate status codes — 200 (success), 201 (created), 204 (no content), 400 (bad request), 401 (unauthorized), 403 (forbidden), 404 (not found), 409 (conflict), 500 (server error)
- Use PUT vs PATCH correctly — PUT replaces the entire resource; PATCH only applies partial changes
- Validate dynamic params — Parse and validate
params.idearly (isNaN, range checks) - Handle OPTIONS explicitly — Return proper CORS headers for cross-origin requests
- Use searchParams for filtering — Query strings for pagination, sorting, and filtering
- Separate list and detail routes —
route.tsfor collection,[id]/route.tsfor individual - Static routes over dynamic —
/api/users/me(static) takes priority over/api/users/[id](dynamic)
⚠ Common Mistakes
Section titled “⚠ Common Mistakes”- Forgetting
awaitforrequest.json()— Body parsing is async - PUT vs PATCH confusion — Using PUT for partial updates (should use PATCH)
- Not validating route params —
params.idarrives as a string; parse and validate it - Missing OPTIONS handler — CORS preflight fails without it
- Exposing stack traces in errors — Never return error.stack in production
- Not HEAD handler — HEAD requests fall through to GET method automatically in Next.js
- 405 Method Not Allowed — Next.js returns this automatically for unhandled methods
📦 Performance Notes
Section titled “📦 Performance Notes”- Route Handlers are dynamic by default (not cached)
- Use
NextResponse.json()with caching headers for GET endpoints - Edge Runtime can reduce cold starts for public API endpoints
- Streaming responses with
ReadableStreamfor large datasets - Batch database queries with
Promise.allfor related data - Use HTTP caching (ETag, Last-Modified) for immutable resources
🔒 Security Notes
Section titled “🔒 Security Notes”- Always validate params before using them in database queries
- Use parameterized queries to prevent SQL injection
- Check authorization for each method independently
- Rate limit public endpoints
- Validate file types and sizes for upload endpoints
- Use CORS headers restrictively (specify origins, don’t use wildcards with credentials)
🌍 SEO Considerations
Section titled “🌍 SEO Considerations”- API responses don’t directly affect SEO
- Use structured API responses that client components can render as SEO-friendly content
- Consider server-side fetching in Server Components over client-side API calls for critical content
Interview Questions
Section titled “Interview Questions”- What’s the difference between PUT and PATCH in Route Handlers?
- How does Next.js handle unsupported HTTP methods?
- How do you access dynamic route parameters in a Route Handler?
- How do you handle CORS preflight requests?
- What status code should you return for a successful creation? Deletion?
- How do you handle pagination in a Route Handler?
- Can you have both
route.tsandpage.tsxin the same folder?
-
What HTTP method should you use to update only a few fields of a resource? a) PUT b) PATCH c) POST d) UPDATE
Answer
b) PATCH — Used for partial updates; PUT replaces the entire resource. -
What status code indicates a resource was created successfully? a) 200 b) 201 c) 204 d) 202
Answer
b) 201 Created — Standard response for successful resource creation. -
What does Next.js return when a method handler isn’t exported? a) 404 Not Found b) 405 Method Not Allowed c) 501 Not Implemented d) It throws an error
Answer
b) 405 Method Not Allowed — Next.js automatically returns this for unhandled methods. -
How do you access URL query parameters in a Route Handler? a)
request.queryb)new URL(request.url).searchParamsc)request.searchParamsd)params.queryAnswer
b) `new URL(request.url).searchParams` — Construct a URL object and access searchParams. -
What’s the correct way to handle a file upload in a Route Handler? a)
request.json()then access file property b)request.formData()then access File object c)request.file()d)request.blob()Answer
b) `await request.formData()` — File uploads use multipart form data.
Practice Exercise
Section titled “Practice Exercise”-
Build a Blog API:
GET /api/posts— List posts with pagination and searchPOST /api/posts— Create post (auth required)GET /api/posts/[id]— Get single post with commentsPUT /api/posts/[id]— Replace postPATCH /api/posts/[id]— Publish/unpublish postDELETE /api/posts/[id]— Delete postPOST /api/posts/[id]/comments— Add comment
-
Implement proper error handling for each endpoint
-
Add query parameter support for filtering and sorting
Debugging Exercise
Section titled “Debugging Exercise”Find and fix the bugs:
export async function GET() { return NextResponse.json(products)}
export async function POST(request) { // Bug 1: Missing type const body = request.json() // Bug 2: Missing await products.push(body) return NextResponse.json(body) // Bug 3: Should return 201}Bug 1: Missing TypeScript type for request.
Fix: Add request: Request.
Bug 2: request.json() is not awaited.
Fix: Add await: const body = await request.json()
Bug 3: Successful creation should return status 201.
Fix: Add { status: 201 } to the response.
Real-world Scenario
Section titled “Real-world Scenario”Problem: Your SaaS app needs a webhook endpoint for Stripe that handles multiple event types, verifies signatures, and updates the database. Different events need different HTTP methods conceptually, but Stripe only sends POST requests.
Solution: A single POST handler with event type routing:
export async function POST(request: Request) { const event = await verifyStripeWebhook(request)
switch (event.type) { case 'checkout.session.completed': ... case 'customer.subscription.updated': ... case 'invoice.paid': ... }
return NextResponse.json({ received: true })}Interview Coding Question
Section titled “Interview Coding Question”Build a search API endpoint with filtering and caching:
export async function GET(request: Request) { const { searchParams } = new URL(request.url) const query = searchParams.get('q') const type = searchParams.get('type') || 'all'
if (!query || query.length < 2) { return NextResponse.json({ error: 'Query must be at least 2 characters' }, { status: 400 }) }
const results = await searchDatabase(query, type)
return NextResponse.json(results, { headers: { 'Cache-Control': 'public, max-age=60' } })}Mini Project
Section titled “Mini Project”Build a Full-featured REST API
Create a complete REST API for a task management system:
-
Authentication:
POST /api/auth/loginPOST /api/auth/registerPOST /api/auth/logout
-
Projects:
GET /api/projects— List with paginationPOST /api/projects— CreateGET /api/projects/[id]— Get with tasksPUT /api/projects/[id]— UpdateDELETE /api/projects/[id]— Delete
-
Tasks (nested under projects):
GET /api/projects/[id]/tasks— List tasksPOST /api/projects/[id]/tasks— Create taskPATCH /api/projects/[id]/tasks/[taskId]— Update statusDELETE /api/projects/[id]/tasks/[taskId]— Delete task
-
Features:
- Input validation with Zod
- Authentication middleware
- Proper HTTP status codes
- CORS headers
- Error handling
- Pagination metadata
- Caching headers for GET endpoints
Summary
Section titled “Summary”Route Handlers support all standard HTTP methods — GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS — each exported as a named function in route.ts. Dynamic route segments ([id], [slug]) are accessed through the params argument. Route Handlers follow REST conventions: collection routes (route.ts) handle list/create operations, while detail routes ([id]/route.ts) handle read/update/delete operations. Always validate input, return appropriate status codes, handle CORS with OPTIONS, and remember that Route Handlers are not cached by default. Use PUT for full replacements and PATCH for partial updates.
Cheat Sheet
Section titled “Cheat Sheet”// HTTP Methods & Route Handlers Quick Reference
// List: GET /api/resourceapp/api/resource/route.tsexport async function GET() { return NextResponse.json([]) }
// Create: POST /api/resourceexport async function POST(request: Request) { const body = await request.json() return NextResponse.json(body, { status: 201 })}
// Read: GET /api/resource/[id]app/api/resource/[id]/route.tsexport async function GET(request: Request, { params }: { params: { id: string } }) { return NextResponse.json({ id: params.id })}
// Replace: PUT /api/resource/[id]export async function PUT(request: Request, { params }: { params: { id: string } }) { const body = await request.json() return NextResponse.json(body)}
// Partial: PATCH /api/resource/[id]export async function PATCH(request: Request, { params }: { params: { id: string } }) { const body = await request.json() return NextResponse.json(body)}
// Delete: DELETE /api/resource/[id]export async function DELETE(request: Request, { params }: { params: { id: string } }) { return new Response(null, { status: 204 })}
// CORS: OPTIONS /api/resourceexport async function OPTIONS() { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*' } })}
// Status codes200 OK 201 Created 204 No Content400 Bad Request 401 Unauthorized 403 Forbidden404 Not Found 409 Conflict 500 Server Error
// Query paramsconst { searchParams } = new URL(request.url)const page = searchParams.get('page')Related Topics
Section titled “Related Topics”- Route Handlers Basics (Previous Topic)
- Middleware Basics (Next Topic)
- Server Actions (Phase 4)
- Data Fetching (Phase 3)