Route Handlers: Writing APIs in Next.js

1. Overview

In Next.js App Router, you don’t need to build a separate Express server to have an API. Route Handlers let you write HTTP endpoints directly in the app folder, using the standard Web API Request and Response.

This article builds a small order management API (Order). You’ll learn how to define HTTP methods, read query strings and body, handle dynamic segments, work with headers/cookies, control caching, and write tests with Vitest. The examples use Next.js 15+ with TypeScript.

2. Your first Route Handler

A Route Handler is a file named route.ts (or route.js) in the app folder. The folder path determines the URL. Each HTTP method is a function exported with uppercase name.

First, we need a simple in-memory data “store”:

// lib/orders.ts
export type Order = {
  id: string;
  customer: string;
  total: number;
};

const orders: Order[] = [
  { id: "1", customer: "An", total: 250_000 },
  { id: "2", customer: "Bình", total: 1_200_000 },
];

export const orderStore = {
  findAll: () => [...orders],
  findById: (id: string) => orders.find((o) => o.id === id),
  create: (data: Omit<Order, "id">): Order => {
    const order = { id: String(orders.length + 1), ...data };
    orders.push(order);
    return order;
  },
  remove: (id: string) => {
    const index = orders.findIndex((o) => o.id === id);
    if (index === -1) return false;
    orders.splice(index, 1);
    return true;
  },
};

Now create a GET /api/orders endpoint:

// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";

export async function GET() {
  return Response.json(orderStore.findAll());
}

Explanation:

  • The file is at app/api/orders/route.ts, so the URL is /api/orders. The api prefix is just a convention. Next.js doesn’t enforce it.
  • export async function GET() handles GET requests. Next.js automatically returns 405 Method Not Allowed for methods you don’t export.
  • Response.json(...) is standard Web API. It serializes data and sets the Content-Type: application/json header.

Run npm run dev and try it:

curl http://localhost:3000/api/orders
[{"id":"1","customer":"An","total":250000},{"id":"2","customer":"Bình","total":1200000}]

Note: In the same directory, you can’t have both route.ts and page.tsx. For example, app/orders/page.tsx and app/orders/route.ts will conflict. That’s why API endpoints are typically grouped under app/api.

3. Reading query strings and body

3.1. Query string

The handler parameter is a standard Request. To read query strings, create a URL from request.url:

// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const minTotal = Number(searchParams.get("minTotal") ?? 0);

  const result = orderStore.findAll().filter((o) => o.total >= minTotal);
  return Response.json(result);
}

Calling /api/orders?minTotal=500000 will return only Bình’s order:

[{"id":"2","customer":"Bình","total":1200000}]

If you prefer, you can declare the parameter as NextRequest (from next/server) and use request.nextUrl.searchParams. Here I use standard Request because it’s easier to create in tests.

3.2. JSON body and validation

Never trust data from the client. We use zod to validate the body. Install it:

npm install zod

Add a POST handler to the same file:

// app/api/orders/route.ts (addition)
import { z } from "zod";

const CreateOrderSchema = z.object({
  customer: z.string().min(1),
  total: z.number().positive(),
});

export async function POST(request: Request) {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return Response.json({ error: "Body is not valid JSON" }, { status: 400 });
  }

  const parsed = CreateOrderSchema.safeParse(body);
  if (!parsed.success) {
    return Response.json(
      { error: "Invalid data", issues: parsed.error.issues },
      { status: 422 },
    );
  }

  const order = orderStore.create(parsed.data);
  return Response.json(order, {
    status: 201,
    headers: { Location: `/api/orders/${order.id}` },
  });
}

Explanation:

  • request.json() throws if the body is empty or invalid JSON. We wrap it in try/catch and return 400.
  • safeParse doesn’t throw. It returns { success, data } or { success, error }. Invalid data gets 422.
  • The second parameter to Response.json lets you set status and headers. Here we return 201 Created with a Location header.
curl -i -X POST http://localhost:3000/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customer":"Chi","total":99000}'
HTTP/1.1 201 Created
location: /api/orders/3
content-type: application/json

{"id":"3","customer":"Chi","total":99000}

With HTML forms, use await request.formData(). With raw data, use await request.text(). Note that the body can only be read once.

4. Dynamic segment

To have GET /api/orders/:id, create a folder with a name in square brackets:

// app/api/orders/[id]/route.ts
import { orderStore } from "@/lib/orders";

type Context = { params: Promise<{ id: string }> };

export async function GET(_request: Request, { params }: Context) {
  const { id } = await params;
  const order = orderStore.findById(id);

  if (!order) {
    return Response.json({ error: `Order ${id} not found` }, { status: 404 });
  }
  return Response.json(order);
}

export async function DELETE(_request: Request, { params }: Context) {
  const { id } = await params;
  const removed = orderStore.remove(id);
  return new Response(null, { status: removed ? 204 : 404 });
}

Explanation:

  • The second parameter contains params. In Next.js 15, params is a Promise, so you must await it.
  • DELETE returns 204 No Content with a null body. Don’t use Response.json for this case, as 204 must not have a body.
  • Values in params are always string. If the ID is a number, you must convert and validate it yourself.

You can also use catch-all [...slug] (receives string[]) or optional catch-all [[...slug]].

Note: Old code that wrote params.id without await will break or warn on Next.js 15+. When upgrading from Next.js 14, this is the most common breakage point.

5. Headers, cookies, and redirect

You can read headers directly from request.headers. Additionally, Next.js provides the cookies() and headers() functions from next/headers. Both are async.

// app/api/me/route.ts
import { cookies, headers } from "next/headers";

export async function GET() {
  const cookieStore = await cookies();
  const headerList = await headers();

  const session = cookieStore.get("session")?.value;
  if (!session) {
    return Response.json({ error: "Not logged in" }, { status: 401 });
  }

  return Response.json({
    session,
    userAgent: headerList.get("user-agent"),
  });
}

export async function DELETE() {
  const cookieStore = await cookies();
  cookieStore.delete("session");
  return new Response(null, { status: 204 });
}
  • cookieStore.get(...) reads a cookie. set and delete only work in Route Handlers or Server Actions, not Server Components.
  • Using cookies() or headers() makes the route dynamic, meaning it re-renders on each request.

To redirect, use Response.redirect(new URL("/login", request.url), 307) or the redirect() function from next/navigation.

For CORS, set headers yourself and export an OPTIONS handler for preflight:

// app/api/public/route.ts
const corsHeaders = {
  "Access-Control-Allow-Origin": "https://shop.example.com",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
};

export async function OPTIONS() {
  return new Response(null, { status: 204, headers: corsHeaders });
}

export async function GET() {
  return Response.json({ status: "ok" }, { headers: corsHeaders });
}

6. Caching

This is where many people get confused. Starting with Next.js 15, GET handlers are not cached by default. Each request re-runs the function. Other methods like POST, PUT, DELETE are never cached.

If data changes infrequently and you want to build it as static, opt-in with route segment config:

// app/api/categories/route.ts
export const dynamic = "force-static";
export const revalidate = 3600; // refresh at most every hour

export async function GET() {
  const categories = ["Electronics", "Books", "Fashion"];
  return Response.json({ categories, generatedAt: new Date().toISOString() });
}
  • dynamic = "force-static" forces the route to render at build time. The result is then served as a static file.
  • revalidate = 3600 allows Next.js to regenerate the response at most every hour (ISR).
  • While running next dev, the route still runs on each request. To see generatedAt stay the same, you need next build && next start.

Note: If a force-static route reads cookies(), headers(), or query string, those values will be empty at build time. Don’t use static for user-dependent endpoints.

7. Testing Route Handlers with Vitest

A Route Handler is just an async function taking Request and returning Response, so you can call it directly without starting a server. Install Vitest:

npm install -D vitest vite-tsconfig-paths
// vitest.config.ts
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [tsconfigPaths()],
  test: { environment: "node" },
});

The vite-tsconfig-paths plugin makes the @/lib/... alias work in tests. Now write tests:

// app/api/orders/route.test.ts
import { describe, it, expect } from "vitest";
import { GET, POST } from "./route";

function jsonRequest(body: unknown) {
  return new Request("http://localhost/api/orders", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
}

describe("/api/orders", () => {
  it("filters orders by minTotal", async () => {
    const res = await GET(new Request("http://localhost/api/orders?minTotal=500000"));
    const data = await res.json();

    expect(res.status).toBe(200);
    expect(data.every((o: { total: number }) => o.total >= 500_000)).toBe(true);
  });

  it("creates valid order and returns 201", async () => {
    const res = await POST(jsonRequest({ customer: "Dũng", total: 150_000 }));

    expect(res.status).toBe(201);
    expect(res.headers.get("Location")).toMatch(/^\/api\/orders\/\d+$/);
    expect(await res.json()).toMatchObject({ customer: "Dũng", total: 150_000 });
  });

  it("rejects negative total with 422", async () => {
    const res = await POST(jsonRequest({ customer: "Dũng", total: -1 }));
    expect(res.status).toBe(422);
  });
});
npx vitest run
 ✓ app/api/orders/route.test.ts (3 tests)
 Test Files  1 passed (1)
      Tests  3 passed (3)

If a handler uses cookies() or headers() from next/headers, calling it directly will throw due to missing Next.js request context. You have two options: mock the module with vi.mock("next/headers", ...), or read values from request.headers to make the code easier to test.

8. Common errors and when not to use Route Handlers

Some common errors:

  • Fetching a Route Handler from a Server Component. Calling fetch("/api/orders") in a Server Component creates an extra HTTP round-trip. At build time, this call fails because the server isn’t running. Instead, call orderStore.findAll() directly or a data access layer.
  • Using Route Handlers for form mutations within the app. If only your own UI calls it, Server Actions are leaner. They’re type-safe end-to-end and have built-in revalidatePath.
  • Storing state in module variables, like orderStore above, when deploying to serverless. Each instance has its own memory and can restart anytime. The example is just for illustration. In practice, use a database.
  • Forgetting the body can only be read once. If you need to read it twice, use request.clone().
  • Returning errors without structure. An uncaught throw becomes a 500 with an HTML error page. Always return JSON with a clear status.

Route Handlers are appropriate when you need:

  • An endpoint for an external client, like a mobile app or partner.
  • A webhook receiving data, for example from Stripe or GitHub.
  • An endpoint serving files, RSS, or streaming data.
  • An endpoint that client-side code calls with fetch.

9. Conclusion

Route Handlers turn each route.ts file into an HTTP endpoint, using standard Web Request/Response. In this article, you’ve:

  • Defined HTTP methods GET, POST, DELETE.
  • Read query strings and body, validated body with zod.
  • Handled dynamic segments with params as a Promise.
  • Worked with headers, cookies, and CORS.
  • Enabled static caching when needed.
  • Tested handlers directly with Vitest without a server.

The key principle to remember: use Route Handlers for public APIs or webhooks. For internal data, query directly in Server Components. For form mutations within your app, use Server Actions.

nextjsroute-handlersapitypescriptapp-router