Skip to content

3. Route Handlers & API Routes

Next.js can be your backend. In the App Router these are Route Handlers; in the Pages Router they were API Routes.


1. Route Handlers (App Router)

A route.ts file exports functions named after HTTP methods.

ts
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const users = await db.user.findMany();
  return NextResponse.json(users);
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const user = await db.user.create({ data: body });
  return NextResponse.json(user, { status: 201 });
}

Supported exports: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.

Rule

route.ts and page.tsx cannot live in the same folder. Both claim the same URL.


2. Dynamic Routes and Params

ts
// app/api/users/[id]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;    // async in Next 15+

  const user = await db.user.findUnique({ where: { id } });
  if (!user) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }

  return NextResponse.json(user);
}

3. Reading the Request

ts
export async function GET(request: NextRequest) {
  // Query string
  const { searchParams } = new URL(request.url);
  const page = Number(searchParams.get("page") ?? 1);

  // Headers
  const auth = request.headers.get("authorization");

  // Cookies
  const token = request.cookies.get("token")?.value;

  // JSON body (POST/PUT)
  // const body = await request.json();

  // Form body
  // const formData = await request.formData();
}

4. Responses

ts
// JSON
return NextResponse.json({ ok: true });

// Status code
return NextResponse.json({ error: "Forbidden" }, { status: 403 });

// Custom headers
return NextResponse.json(data, {
  headers: { "Cache-Control": "s-maxage=60, stale-while-revalidate=300" },
});

// Redirect
return NextResponse.redirect(new URL("/login", request.url));

// Set a cookie
const res = NextResponse.json({ ok: true });
res.cookies.set("token", jwt, {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax",
  maxAge: 60 * 60 * 24 * 7,
  path: "/",
});
return res;

// Plain text / stream
return new Response("Hello", { headers: { "Content-Type": "text/plain" } });

Security Note

Auth cookies must be httpOnly (JavaScript cannot read them — blocks XSS token theft), secure in production, and sameSite: "lax" or "strict" (blocks CSRF). Storing a JWT in localStorage is the classic interview trap answer — any XSS on your site steals it.


5. Validation

Never trust the request body.

ts
import { z } from "zod";

const createUser = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(50),
  age: z.number().int().positive().optional(),
});

export async function POST(request: NextRequest) {
  const body = await request.json();
  const parsed = createUser.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: "Validation failed", issues: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const user = await db.user.create({ data: parsed.data });
  return NextResponse.json(user, { status: 201 });
}

Zod also gives you the TypeScript type for free: type CreateUser = z.infer<typeof createUser>.


6. Error Handling Pattern

ts
export async function POST(request: NextRequest) {
  try {
    const session = await auth();
    if (!session) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const body = await request.json();
    const result = await createOrder(session.userId, body);

    return NextResponse.json(result, { status: 201 });
  } catch (error) {
    console.error("[POST /api/orders]", error);

    // Never leak internals to the client
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}

Log the real error server-side; return a generic message to the client. Stack traces in an API response are an information-disclosure bug.


7. Caching Behaviour

Next.js 15 and Earlier

GET handlers were cached by default. You opted out:

ts
export const dynamic = "force-dynamic";
export const revalidate = 60;

Next.js 16

Route Handlers follow the same opt-in model as pages. Nothing is cached unless you say so, and with Cache Components enabled, GET handlers follow the same prerendering model as pages.

ts
export async function GET() {
  "use cache";
  cacheLife("hours");
  const data = await getExpensiveData();
  return NextResponse.json(data);
}

Interview Point

If someone asks "why is my API returning stale data" on Next 14/15, the answer is almost always the default GET cache. On Next 16 that class of bug is gone by design.


8. Runtime Options

ts
export const runtime = "nodejs";  // default — full Node API, DB drivers
export const runtime = "edge";    // V8 isolate, faster cold start, limited APIs
Node.js runtimeEdge runtime
Cold startSlowerVery fast
Node APIs (fs, crypto)FullLimited Web APIs only
Database driversAllOnly HTTP-based (Neon, PlanetScale, Upstash)
LocationRegionDistributed globally
Best forReal business logic, ORM queriesAuth checks, geo, redirects, simple JSON

Note that proxy.ts (the Next 16 replacement for middleware) runs on the Node.js runtime.


9. CORS

ts
export async function GET() {
  return NextResponse.json(data, {
    headers: {
      "Access-Control-Allow-Origin": "https://app.example.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: {
      "Access-Control-Allow-Origin": "https://app.example.com",
      "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
    },
  });
}

Access-Control-Allow-Origin: * plus credentials is invalid and rejected by browsers. Whitelist explicit origins for anything authenticated.


10. Webhooks

Two things interviewers look for: raw body and signature verification.

ts
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";

export async function POST(request: NextRequest) {
  const body = await request.text();          // raw text, NOT .json()
  const signature = request.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed":
      await fulfilOrder(event.data.object);
      break;
  }

  return NextResponse.json({ received: true });
}

Parsing to JSON first breaks signature verification, because the signature is computed over the exact raw bytes.

Also make the handler idempotent — providers retry, so the same event can arrive twice.


11. Streaming a Response

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

  const stream = new ReadableStream({
    async start(controller) {
      for await (const chunk of generateTokens()) {
        controller.enqueue(encoder.encode(`data: ${chunk}\n\n`));
      }
      controller.close();
    },
  });

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

The standard pattern for LLM token streaming and live progress updates.


12. Route Handlers vs Server Actions

Both let you run server code. Interviewers ask when to use which.

Use a Server ActionUse a Route Handler
Form submission from your own appA public REST API
Mutation tied to a UI componentA mobile app or third-party consumer
You want progressive enhancementWebhooks
You want revalidatePath right thereYou need custom headers, status codes, streaming
Internal to this Next.js appAnything called from outside

One-Line Answer

"Server Actions for mutations inside my own app; Route Handlers when something outside the app needs to call it."


13. Pages Router API Routes (Legacy)

Still worth recognising, since plenty of production code runs on it.

ts
// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { id } = req.query;

  switch (req.method) {
    case "GET": {
      const user = await db.user.findUnique({ where: { id: String(id) } });
      return res.status(200).json(user);
    }
    case "DELETE":
      await db.user.delete({ where: { id: String(id) } });
      return res.status(204).end();
    default:
      res.setHeader("Allow", ["GET", "DELETE"]);
      return res.status(405).end(`Method ${req.method} not allowed`);
  }
}

Key Differences

Pages API RoutesApp Route Handlers
Filepages/api/*.tsapp/**/route.ts
SignatureOne handler(req, res)One export per HTTP method
ObjectsNode req / resWeb Request / Response
Method routingManual switchAutomatic
Body parsingAutomaticManual await request.json()

14. Environment Variables

bash
# .env.local
DATABASE_URL="postgres://…"        # server only
STRIPE_SECRET_KEY="sk_live_…"      # server only
NEXT_PUBLIC_API_URL="https://api.example.com"   # exposed to the browser

The Rule

Only variables prefixed NEXT_PUBLIC_ are sent to the browser. They are inlined into the JavaScript bundle at build time and are permanently public.

ts
// Server Component / Route Handler — safe
const key = process.env.STRIPE_SECRET_KEY;

// Client Component — undefined without the prefix, and NEVER put a secret here
const url = process.env.NEXT_PUBLIC_API_URL;

Security Note

Putting an API key behind NEXT_PUBLIC_ does not protect it — it is in the shipped bundle, readable by anyone with DevTools. This is one of the most common real-world Next.js security mistakes, and a favourite interview question.

Note also that serverRuntimeConfig and publicRuntimeConfig were removed in Next.js 16 — use .env files.

© 2025 DDocs · Dipak's Documentation Guide