Module 8: Route Handlers & Third-Party API Integration in Next.js 16

Module 8: Route Handlers & Third-Party API Integration in Next.js 16

AI Reading

Quick summary of this article

Build REST endpoints with Next.js 16 Route Handlers: GET, POST, PUT, DELETE, dynamic segments, async cookies and headers, consistent error shapes, webhooks and server-side API calls.

  • A route.ts file exports HTTP methods (GET, POST, …) and replaces the Pages Router's API routes.
  • cookies() and headers() are async in Next.js 16 and must be awaited.
  • Those APIs live in next/headers — a Route Handler can read them, but a cached use cache function cannot.
  • Wrap outbound API calls in one module with timeouts and typed responses, and never expose API keys through NEXT_PUBLIC_ variables.

Complete Next.js 16 Series — Module 8 of 12

Server Actions cover form mutations. Route Handlers cover everything else: mobile clients, webhooks, third-party callbacks, streaming endpoints and public APIs.

Anatomy of a Route Handler

// src/app/api/health/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  return NextResponse.json({ status: "ok", time: new Date().toISOString() });
}

src/app/api/health/route.ts serves GET /api/health. You cannot have both route.ts and page.tsx in the same folder — that is a build error.

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

A Full CRUD Resource

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

const createSchema = z.object({
  name: z.string().min(2).max(120),
  price: z.number().positive(),
});

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const take = Math.min(Number(searchParams.get("take") ?? 20), 100);
  const cursor = searchParams.get("cursor") ?? undefined;

  const products = await db.product.findMany({ take, cursor: cursor ? { id: cursor } : undefined });

  return NextResponse.json({ data: products });
}

export async function POST(request: NextRequest) {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
  }

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

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

And the dynamic segment version:

// src/app/api/products/[id]/route.ts
export async function GET(
  _request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params; // async in Next.js 16
  const product = await db.product.findUnique({ where: { id } });

  if (!product) {
    return NextResponse.json({ error: "Product not found" }, { status: 404 });
  }
  return NextResponse.json({ data: product });
}

export async function DELETE(
  _request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  await db.product.delete({ where: { id } });
  return new NextResponse(null, { status: 204 });
}

Reading Cookies and Headers

Both are async in Next.js 16:

import { cookies, headers } from "next/headers";

export async function GET() {
  const cookieStore = await cookies();
  const session = cookieStore.get("session")?.value;

  const headerStore = await headers();
  const userAgent = headerStore.get("user-agent") ?? "unknown";

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  return NextResponse.json({ session, userAgent });
}

Setting a cookie in a Route Handler or Server Action:

const store = await cookies();
store.set("theme", "dark", {
  httpOnly: true,
  sameSite: "lax",
  secure: process.env.NODE_ENV === "production",
  path: "/",
  maxAge: 60 * 60 * 24 * 365,
});

Calling cookies() opts the route into dynamic rendering. Inside a function marked use cache it throws — cached functions may not read per-request data.

Consistent Error Responses

Pick one envelope and use it everywhere. Clients — and your own future self — will thank you.

// src/lib/api.ts
export type ApiError = {
  error: string;
  code?: string;
  details?: unknown;
};

export function fail(message: string, status = 400, code?: string, details?: unknown) {
  return Response.json({ error: message, code, details } satisfies ApiError, { status });
}

export function ok<T>(data: T, status = 200) {
  return Response.json({ data }, { status });
}

Then a handler reads cleanly:

if (!session) return fail("Unauthorized", 401, "AUTH_REQUIRED");
if (!product) return fail("Not found", 404, "NOT_FOUND");
return ok(product);

Calling a Third-Party API (Server Side)

Never call a key-bearing API from a Client Component. Wrap it in a module that lives on the server:

// src/lib/weather.ts
import "server-only";

const KEY = process.env.WEATHER_API_KEY;
if (!KEY) throw new Error("WEATHER_API_KEY is not set");

export type Weather = { tempC: number; condition: string };

export async function getWeather(city: string): Promise<Weather | null> {
  const res = await fetch(
    `https://api.example.com/weather?city=${encodeURIComponent(city)}&key=${KEY}`,
    {
      next: { revalidate: 600, tags: [`weather-${city}`] },
      signal: AbortSignal.timeout(8000),
    }
  );

  if (!res.ok) {
    if (res.status === 404) return null;
    throw new Error(`Weather API failed: ${res.status}`);
  }

  const json = (await res.json()) as { temp_c: number; condition: string };
  return { tempC: json.temp_c, condition: json.condition };
}

import "server-only" makes the build fail loudly if this file is ever imported into a Client Component. That is a cheap guard with enormous value.

Proxying a Client Request Through a Route Handler

Sometimes the browser must call your server, and your server calls the API — to hide a key, add CORS headers, or normalise the response:

// src/app/api/weather/[city]/route.ts
import { getWeather } from "@/lib/weather";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ city: string }> }
) {
  const { city } = await params;
  const weather = await getWeather(city);

  if (!weather) return Response.json({ error: "City not found" }, { status: 404 });

  return Response.json(weather, {
    headers: { "Cache-Control": "public, s-maxage=600, stale-while-revalidate=60" },
  });
}

Webhooks

Webhook endpoints must be dynamic, must verify signatures, and must return quickly:

// src/app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import crypto from "node:crypto";

export const dynamic = "force-dynamic";

export async function POST(request: Request) {
  const signature = (await headers()).get("x-signature");
  const raw = await request.text();

  const expected = crypto
    .createHmac("sha256", process.env.WEBHOOK_SECRET!)
    .update(raw)
    .digest("hex");

  const valid =
    signature &&
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!valid) return new Response("Invalid signature", { status: 401 });

  const event = JSON.parse(raw) as { type: string; data: unknown };

  switch (event.type) {
    case "order.paid":
      await handleOrderPaid(event.data);
      break;
    default:
      break;
  }

  return new Response(null, { status: 200 });
}

Two non-negotiables: read the raw body (not request.json()) so the signature matches, and use timingSafeEqual rather than === to prevent timing attacks.

Streaming a Response

Route Handlers can stream, which suits AI endpoints and large exports:

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

  const stream = new ReadableStream({
    async start(controller) {
      for (const chunk of ["Hello", " from", " Next.js", " 16"]) {
        controller.enqueue(encoder.encode(chunk));
        await new Promise((r) => setTimeout(r, 300));
      }
      controller.close();
    },
  });

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

Caching a Route Handler

Route Handlers are dynamic when they read the request; otherwise they can be cached:

export const revalidate = 60;            // ISR-style caching
export const dynamic = "force-static";   // never dynamic

For a JSON API consumed by your own app, revalidate plus Cache-Control headers is usually enough.

Module 8 Recap

  • route.ts exports HTTP method functions and replaces Pages Router API routes.
  • params, cookies() and headers() are async — always await.
  • Standardise on one response envelope with a typed fail() / ok() helper.
  • Keep API keys server-side with import "server-only" and non-NEXT_PUBLIC_ env vars.
  • Verify webhook signatures over the raw body using timingSafeEqual.
  • Return 204 for deletes and correct status codes everywhere.

Next up — Module 9: a real database layer with Prisma 7 and PostgreSQL.

Leave a Reply

Your email address will not be published. Required fields are marked *