D
Dev SOPKnowledge Base
Search
← All topics

Next.js API Routes, Middleware, and Edge Runtime Patterns

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.

nextjsapi-routesmiddlewareedge-runtimetypescriptzod
Agent 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

Overview

Next.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.

Route Handler Structure

// src/app/api/projects/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

const CreateProjectSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().optional(),
});

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const status = searchParams.get("status") ?? "active";

  try {
    const projects = await db.from("projects").select().eq("status", status);
    return NextResponse.json({ projects });
  } catch (error) {
    console.error("[GET /api/projects]", error);
    return NextResponse.json(
      { error: "Failed to fetch projects" },
      { status: 500 }
    );
  }
}

export async function POST(request: NextRequest) {
  const body = await request.json().catch(() => null);
  if (!body) {
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }

  const parsed = CreateProjectSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: "Validation failed", details: parsed.error.flatten() },
      { status: 422 }
    );
  }

  const project = await db.from("projects").insert(parsed.data).select().single();
  return NextResponse.json({ project }, { status: 201 });
}

Dynamic Route Segments

// src/app/api/projects/[id]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const project = await db.from("projects").select().eq("id", id).single();

  if (!project) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }

  return NextResponse.json({ project });
}

export async function PATCH(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();
  // ... update logic
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  await db.from("projects").delete().eq("id", id);
  return new NextResponse(null, { status: 204 });
}

Middleware

// middleware.ts (project root)
import { NextRequest, NextResponse } from "next/server";

export const config = {
  matcher: ["/api/:path*", "/dashboard/:path*"],
};

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Auth check
  const token = request.cookies.get("session-token")?.value;
  if (pathname.startsWith("/dashboard") && !token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // CORS for API routes
  if (pathname.startsWith("/api/")) {
    const response = NextResponse.next();
    const origin = request.headers.get("origin") ?? "";
    const allowed = ["https://yourdomain.com", "http://localhost:3000"];

    if (allowed.includes(origin)) {
      response.headers.set("Access-Control-Allow-Origin", origin);
      response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
      response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
    }

    // Handle preflight
    if (request.method === "OPTIONS") {
      return new NextResponse(null, { status: 204, headers: response.headers });
    }

    return response;
  }

  return NextResponse.next();
}

Rate Limiting (Edge-Compatible)

// lib/rate-limit.ts
import { NextRequest, NextResponse } from "next/server";

const requestCounts = new Map<string, { count: number; reset: number }>();

export function rateLimit(
  request: NextRequest,
  limit = 100,
  windowMs = 60_000
): NextResponse | null {
  const ip = request.ip ?? request.headers.get("x-forwarded-for") ?? "unknown";
  const now = Date.now();
  const entry = requestCounts.get(ip);

  if (!entry || now > entry.reset) {
    requestCounts.set(ip, { count: 1, reset: now + windowMs });
    return null;
  }

  if (entry.count >= limit) {
    return NextResponse.json(
      { error: "Rate limit exceeded" },
      {
        status: 429,
        headers: {
          "Retry-After": String(Math.ceil((entry.reset - now) / 1000)),
          "X-RateLimit-Limit": String(limit),
          "X-RateLimit-Remaining": "0",
        },
      }
    );
  }

  entry.count++;
  return null;
}

Edge Runtime

// src/app/api/geo/route.ts
export const runtime = "edge";  // runs in V8 isolates globally

import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const country = request.geo?.country ?? "US";
  const city = request.geo?.city ?? "Unknown";

  return NextResponse.json({ country, city });
}

Edge restrictions: no Node.js APIs (no fs, crypto, path). Use Web APIs only.

Streaming Response

export const runtime = "edge";

export async function POST(request: NextRequest) {
  const { prompt } = await request.json();

  const stream = new TransformStream();
  const writer = stream.writable.getWriter();
  const encoder = new TextEncoder();

  // Start async work
  (async () => {
    const response = await fetch("https://api.anthropic.com/v1/messages", {
      method: "POST",
      headers: {
        "x-api-key": process.env.ANTHROPIC_API_KEY!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ model: "claude-opus-4-6", max_tokens: 2048, stream: true }),
    });

    const reader = response.body!.getReader();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      await writer.write(value);
    }

    await writer.close();
  })();

  return new Response(stream.readable, {
    headers: { "Content-Type": "text/event-stream" },
  });
}

Auth Helper Pattern

// lib/auth.ts
import { cookies } from "next/headers";

export async function getAuthenticatedUser() {
  const cookieStore = await cookies();
  const token = cookieStore.get("session-token")?.value;
  if (!token) return null;

  // verify token...
  return user;
}

// In route handler
export async function GET() {
  const user = await getAuthenticatedUser();
  if (!user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
  // ...
}

Error Response Conventions

// Consistent error shape across all routes
function errorResponse(message: string, status: number, details?: unknown) {
  return NextResponse.json(
    { error: message, ...(details ? { details } : {}) },
    { status }
  );
}

// Use in routes
return errorResponse("Not found", 404);
return errorResponse("Validation failed", 422, parsed.error.flatten());
return errorResponse("Internal server error", 500);

Clerk Auth Integration

import { auth } from "@clerk/nextjs/server";

export async function GET() {
  const { userId } = await auth();
  if (!userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
  // userId is the Clerk user ID — use as foreign key in DB
}

Note: @clerk/nextjs middleware auto-blocks API routes. Remove if routes don't need auth.