{"slug":"nextjs-api-routes-and-middleware","title":"Next.js API Routes, Middleware, and Edge Runtime Patterns","tags":["nextjs","api-routes","middleware","edge-runtime","typescript","zod"],"agent_summary":"Next.js 14/15 App Router API patterns — route handlers, middleware for auth/rate limiting, edge runtime functions, Zod validation at boundary, streaming responses, error handling conventions, and CORS configuration.","trigger_phrases":["Next.js API route","route.ts","Next.js middleware","edge runtime","Next.js App Router API","Next.js route handler","Next.js CORS","Next.js Zod validation"],"runnable":false,"markdown":"\n## Overview\n\nNext.js 14/15 App Router API routes use the Route Handler pattern (`route.ts` files). Middleware runs before routes for auth, rate limiting, and redirects. Edge Runtime for latency-sensitive paths.\n\n## Route Handler Structure\n\n```typescript\n// src/app/api/projects/route.ts\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { z } from \"zod\";\n\nconst CreateProjectSchema = z.object({\n  name: z.string().min(1).max(100),\n  description: z.string().optional(),\n});\n\nexport async function GET(request: NextRequest) {\n  const { searchParams } = new URL(request.url);\n  const status = searchParams.get(\"status\") ?? \"active\";\n\n  try {\n    const projects = await db.from(\"projects\").select().eq(\"status\", status);\n    return NextResponse.json({ projects });\n  } catch (error) {\n    console.error(\"[GET /api/projects]\", error);\n    return NextResponse.json(\n      { error: \"Failed to fetch projects\" },\n      { status: 500 }\n    );\n  }\n}\n\nexport async function POST(request: NextRequest) {\n  const body = await request.json().catch(() => null);\n  if (!body) {\n    return NextResponse.json({ error: \"Invalid JSON\" }, { status: 400 });\n  }\n\n  const parsed = CreateProjectSchema.safeParse(body);\n  if (!parsed.success) {\n    return NextResponse.json(\n      { error: \"Validation failed\", details: parsed.error.flatten() },\n      { status: 422 }\n    );\n  }\n\n  const project = await db.from(\"projects\").insert(parsed.data).select().single();\n  return NextResponse.json({ project }, { status: 201 });\n}\n```\n\n## Dynamic Route Segments\n\n```typescript\n// src/app/api/projects/[id]/route.ts\nexport async function GET(\n  request: NextRequest,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const { id } = await params;\n  const project = await db.from(\"projects\").select().eq(\"id\", id).single();\n\n  if (!project) {\n    return NextResponse.json({ error: \"Not found\" }, { status: 404 });\n  }\n\n  return NextResponse.json({ project });\n}\n\nexport async function PATCH(\n  request: NextRequest,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const { id } = await params;\n  const body = await request.json();\n  // ... update logic\n}\n\nexport async function DELETE(\n  request: NextRequest,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const { id } = await params;\n  await db.from(\"projects\").delete().eq(\"id\", id);\n  return new NextResponse(null, { status: 204 });\n}\n```\n\n## Middleware\n\n```typescript\n// middleware.ts (project root)\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport const config = {\n  matcher: [\"/api/:path*\", \"/dashboard/:path*\"],\n};\n\nexport function middleware(request: NextRequest) {\n  const { pathname } = request.nextUrl;\n\n  // Auth check\n  const token = request.cookies.get(\"session-token\")?.value;\n  if (pathname.startsWith(\"/dashboard\") && !token) {\n    return NextResponse.redirect(new URL(\"/login\", request.url));\n  }\n\n  // CORS for API routes\n  if (pathname.startsWith(\"/api/\")) {\n    const response = NextResponse.next();\n    const origin = request.headers.get(\"origin\") ?? \"\";\n    const allowed = [\"https://yourdomain.com\", \"http://localhost:3000\"];\n\n    if (allowed.includes(origin)) {\n      response.headers.set(\"Access-Control-Allow-Origin\", origin);\n      response.headers.set(\"Access-Control-Allow-Methods\", \"GET, POST, PUT, DELETE, OPTIONS\");\n      response.headers.set(\"Access-Control-Allow-Headers\", \"Content-Type, Authorization\");\n    }\n\n    // Handle preflight\n    if (request.method === \"OPTIONS\") {\n      return new NextResponse(null, { status: 204, headers: response.headers });\n    }\n\n    return response;\n  }\n\n  return NextResponse.next();\n}\n```\n\n## Rate Limiting (Edge-Compatible)\n\n```typescript\n// lib/rate-limit.ts\nimport { NextRequest, NextResponse } from \"next/server\";\n\nconst requestCounts = new Map<string, { count: number; reset: number }>();\n\nexport function rateLimit(\n  request: NextRequest,\n  limit = 100,\n  windowMs = 60_000\n): NextResponse | null {\n  const ip = request.ip ?? request.headers.get(\"x-forwarded-for\") ?? \"unknown\";\n  const now = Date.now();\n  const entry = requestCounts.get(ip);\n\n  if (!entry || now > entry.reset) {\n    requestCounts.set(ip, { count: 1, reset: now + windowMs });\n    return null;\n  }\n\n  if (entry.count >= limit) {\n    return NextResponse.json(\n      { error: \"Rate limit exceeded\" },\n      {\n        status: 429,\n        headers: {\n          \"Retry-After\": String(Math.ceil((entry.reset - now) / 1000)),\n          \"X-RateLimit-Limit\": String(limit),\n          \"X-RateLimit-Remaining\": \"0\",\n        },\n      }\n    );\n  }\n\n  entry.count++;\n  return null;\n}\n```\n\n## Edge Runtime\n\n```typescript\n// src/app/api/geo/route.ts\nexport const runtime = \"edge\";  // runs in V8 isolates globally\n\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport async function GET(request: NextRequest) {\n  const country = request.geo?.country ?? \"US\";\n  const city = request.geo?.city ?? \"Unknown\";\n\n  return NextResponse.json({ country, city });\n}\n```\n\nEdge restrictions: no Node.js APIs (no `fs`, `crypto`, `path`). Use Web APIs only.\n\n## Streaming Response\n\n```typescript\nexport const runtime = \"edge\";\n\nexport async function POST(request: NextRequest) {\n  const { prompt } = await request.json();\n\n  const stream = new TransformStream();\n  const writer = stream.writable.getWriter();\n  const encoder = new TextEncoder();\n\n  // Start async work\n  (async () => {\n    const response = await fetch(\"https://api.anthropic.com/v1/messages\", {\n      method: \"POST\",\n      headers: {\n        \"x-api-key\": process.env.ANTHROPIC_API_KEY!,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({ model: \"claude-opus-4-6\", max_tokens: 2048, stream: true }),\n    });\n\n    const reader = response.body!.getReader();\n    while (true) {\n      const { done, value } = await reader.read();\n      if (done) break;\n      await writer.write(value);\n    }\n\n    await writer.close();\n  })();\n\n  return new Response(stream.readable, {\n    headers: { \"Content-Type\": \"text/event-stream\" },\n  });\n}\n```\n\n## Auth Helper Pattern\n\n```typescript\n// lib/auth.ts\nimport { cookies } from \"next/headers\";\n\nexport async function getAuthenticatedUser() {\n  const cookieStore = await cookies();\n  const token = cookieStore.get(\"session-token\")?.value;\n  if (!token) return null;\n\n  // verify token...\n  return user;\n}\n\n// In route handler\nexport async function GET() {\n  const user = await getAuthenticatedUser();\n  if (!user) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n  // ...\n}\n```\n\n## Error Response Conventions\n\n```typescript\n// Consistent error shape across all routes\nfunction errorResponse(message: string, status: number, details?: unknown) {\n  return NextResponse.json(\n    { error: message, ...(details ? { details } : {}) },\n    { status }\n  );\n}\n\n// Use in routes\nreturn errorResponse(\"Not found\", 404);\nreturn errorResponse(\"Validation failed\", 422, parsed.error.flatten());\nreturn errorResponse(\"Internal server error\", 500);\n```\n\n## Clerk Auth Integration\n\n```typescript\nimport { auth } from \"@clerk/nextjs/server\";\n\nexport async function GET() {\n  const { userId } = await auth();\n  if (!userId) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n  // userId is the Clerk user ID — use as foreign key in DB\n}\n```\n\nNote: `@clerk/nextjs` middleware auto-blocks API routes. Remove if routes don't need auth.\n","html":"<h2>Overview</h2>\n<p>Next.js 14/15 App Router API routes use the Route Handler pattern (<code>route.ts</code> files). Middleware runs before routes for auth, rate limiting, and redirects. Edge Runtime for latency-sensitive paths.</p>\n<h2>Route Handler Structure</h2>\n<pre><code class=\"language-typescript\">// src/app/api/projects/route.ts\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { z } from \"zod\";\n\nconst CreateProjectSchema = z.object({\n  name: z.string().min(1).max(100),\n  description: z.string().optional(),\n});\n\nexport async function GET(request: NextRequest) {\n  const { searchParams } = new URL(request.url);\n  const status = searchParams.get(\"status\") ?? \"active\";\n\n  try {\n    const projects = await db.from(\"projects\").select().eq(\"status\", status);\n    return NextResponse.json({ projects });\n  } catch (error) {\n    console.error(\"[GET /api/projects]\", error);\n    return NextResponse.json(\n      { error: \"Failed to fetch projects\" },\n      { status: 500 }\n    );\n  }\n}\n\nexport async function POST(request: NextRequest) {\n  const body = await request.json().catch(() => null);\n  if (!body) {\n    return NextResponse.json({ error: \"Invalid JSON\" }, { status: 400 });\n  }\n\n  const parsed = CreateProjectSchema.safeParse(body);\n  if (!parsed.success) {\n    return NextResponse.json(\n      { error: \"Validation failed\", details: parsed.error.flatten() },\n      { status: 422 }\n    );\n  }\n\n  const project = await db.from(\"projects\").insert(parsed.data).select().single();\n  return NextResponse.json({ project }, { status: 201 });\n}\n</code></pre>\n<h2>Dynamic Route Segments</h2>\n<pre><code class=\"language-typescript\">// src/app/api/projects/[id]/route.ts\nexport async function GET(\n  request: NextRequest,\n  { params }: { params: Promise&#x3C;{ id: string }> }\n) {\n  const { id } = await params;\n  const project = await db.from(\"projects\").select().eq(\"id\", id).single();\n\n  if (!project) {\n    return NextResponse.json({ error: \"Not found\" }, { status: 404 });\n  }\n\n  return NextResponse.json({ project });\n}\n\nexport async function PATCH(\n  request: NextRequest,\n  { params }: { params: Promise&#x3C;{ id: string }> }\n) {\n  const { id } = await params;\n  const body = await request.json();\n  // ... update logic\n}\n\nexport async function DELETE(\n  request: NextRequest,\n  { params }: { params: Promise&#x3C;{ id: string }> }\n) {\n  const { id } = await params;\n  await db.from(\"projects\").delete().eq(\"id\", id);\n  return new NextResponse(null, { status: 204 });\n}\n</code></pre>\n<h2>Middleware</h2>\n<pre><code class=\"language-typescript\">// middleware.ts (project root)\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport const config = {\n  matcher: [\"/api/:path*\", \"/dashboard/:path*\"],\n};\n\nexport function middleware(request: NextRequest) {\n  const { pathname } = request.nextUrl;\n\n  // Auth check\n  const token = request.cookies.get(\"session-token\")?.value;\n  if (pathname.startsWith(\"/dashboard\") &#x26;&#x26; !token) {\n    return NextResponse.redirect(new URL(\"/login\", request.url));\n  }\n\n  // CORS for API routes\n  if (pathname.startsWith(\"/api/\")) {\n    const response = NextResponse.next();\n    const origin = request.headers.get(\"origin\") ?? \"\";\n    const allowed = [\"https://yourdomain.com\", \"http://localhost:3000\"];\n\n    if (allowed.includes(origin)) {\n      response.headers.set(\"Access-Control-Allow-Origin\", origin);\n      response.headers.set(\"Access-Control-Allow-Methods\", \"GET, POST, PUT, DELETE, OPTIONS\");\n      response.headers.set(\"Access-Control-Allow-Headers\", \"Content-Type, Authorization\");\n    }\n\n    // Handle preflight\n    if (request.method === \"OPTIONS\") {\n      return new NextResponse(null, { status: 204, headers: response.headers });\n    }\n\n    return response;\n  }\n\n  return NextResponse.next();\n}\n</code></pre>\n<h2>Rate Limiting (Edge-Compatible)</h2>\n<pre><code class=\"language-typescript\">// lib/rate-limit.ts\nimport { NextRequest, NextResponse } from \"next/server\";\n\nconst requestCounts = new Map&#x3C;string, { count: number; reset: number }>();\n\nexport function rateLimit(\n  request: NextRequest,\n  limit = 100,\n  windowMs = 60_000\n): NextResponse | null {\n  const ip = request.ip ?? request.headers.get(\"x-forwarded-for\") ?? \"unknown\";\n  const now = Date.now();\n  const entry = requestCounts.get(ip);\n\n  if (!entry || now > entry.reset) {\n    requestCounts.set(ip, { count: 1, reset: now + windowMs });\n    return null;\n  }\n\n  if (entry.count >= limit) {\n    return NextResponse.json(\n      { error: \"Rate limit exceeded\" },\n      {\n        status: 429,\n        headers: {\n          \"Retry-After\": String(Math.ceil((entry.reset - now) / 1000)),\n          \"X-RateLimit-Limit\": String(limit),\n          \"X-RateLimit-Remaining\": \"0\",\n        },\n      }\n    );\n  }\n\n  entry.count++;\n  return null;\n}\n</code></pre>\n<h2>Edge Runtime</h2>\n<pre><code class=\"language-typescript\">// src/app/api/geo/route.ts\nexport const runtime = \"edge\";  // runs in V8 isolates globally\n\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport async function GET(request: NextRequest) {\n  const country = request.geo?.country ?? \"US\";\n  const city = request.geo?.city ?? \"Unknown\";\n\n  return NextResponse.json({ country, city });\n}\n</code></pre>\n<p>Edge restrictions: no Node.js APIs (no <code>fs</code>, <code>crypto</code>, <code>path</code>). Use Web APIs only.</p>\n<h2>Streaming Response</h2>\n<pre><code class=\"language-typescript\">export const runtime = \"edge\";\n\nexport async function POST(request: NextRequest) {\n  const { prompt } = await request.json();\n\n  const stream = new TransformStream();\n  const writer = stream.writable.getWriter();\n  const encoder = new TextEncoder();\n\n  // Start async work\n  (async () => {\n    const response = await fetch(\"https://api.anthropic.com/v1/messages\", {\n      method: \"POST\",\n      headers: {\n        \"x-api-key\": process.env.ANTHROPIC_API_KEY!,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({ model: \"claude-opus-4-6\", max_tokens: 2048, stream: true }),\n    });\n\n    const reader = response.body!.getReader();\n    while (true) {\n      const { done, value } = await reader.read();\n      if (done) break;\n      await writer.write(value);\n    }\n\n    await writer.close();\n  })();\n\n  return new Response(stream.readable, {\n    headers: { \"Content-Type\": \"text/event-stream\" },\n  });\n}\n</code></pre>\n<h2>Auth Helper Pattern</h2>\n<pre><code class=\"language-typescript\">// lib/auth.ts\nimport { cookies } from \"next/headers\";\n\nexport async function getAuthenticatedUser() {\n  const cookieStore = await cookies();\n  const token = cookieStore.get(\"session-token\")?.value;\n  if (!token) return null;\n\n  // verify token...\n  return user;\n}\n\n// In route handler\nexport async function GET() {\n  const user = await getAuthenticatedUser();\n  if (!user) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n  // ...\n}\n</code></pre>\n<h2>Error Response Conventions</h2>\n<pre><code class=\"language-typescript\">// Consistent error shape across all routes\nfunction errorResponse(message: string, status: number, details?: unknown) {\n  return NextResponse.json(\n    { error: message, ...(details ? { details } : {}) },\n    { status }\n  );\n}\n\n// Use in routes\nreturn errorResponse(\"Not found\", 404);\nreturn errorResponse(\"Validation failed\", 422, parsed.error.flatten());\nreturn errorResponse(\"Internal server error\", 500);\n</code></pre>\n<h2>Clerk Auth Integration</h2>\n<pre><code class=\"language-typescript\">import { auth } from \"@clerk/nextjs/server\";\n\nexport async function GET() {\n  const { userId } = await auth();\n  if (!userId) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n  // userId is the Clerk user ID — use as foreign key in DB\n}\n</code></pre>\n<p>Note: <code>@clerk/nextjs</code> middleware auto-blocks API routes. Remove if routes don't need auth.</p>\n"}