Route Handlers: escrevendo API no Next.js

1. Visão geral

No App Router do Next.js, você não precisa montar um servidor Express separado para ter uma API. Route Handlers permitem escrever endpoints HTTP direto na pasta app, usando a Web API padrão Request e Response.

Este artigo constrói uma pequena API de gerenciamento de pedidos (Order). Você aprenderá como definir os métodos HTTP, ler query string e body, lidar com segmentos dinâmicos, trabalhar com headers/cookies, controlar cache e escrever testes com Vitest. Os exemplos usam Next.js 15+ com TypeScript.

2. Seu primeiro Route Handler

Um Route Handler é um arquivo chamado route.ts (ou route.js) dentro de app. O caminho da pasta determina a URL. Cada método HTTP é uma função exportada com nome em maiúsculas.

Primeiro, precisamos de um “armazenamento” simples de dados em memória:

// 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;
  },
};

Agora crie o endpoint GET /api/orders:

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

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

Explicação:

  • O arquivo está em app/api/orders/route.ts, então a URL é /api/orders. O prefixo api é apenas uma convenção. Next.js não o obriga.
  • export async function GET() lida com requisições GET. Next.js retorna automaticamente 405 Method Not Allowed para métodos que você não exporta.
  • Response.json(...) é a Web API padrão. Ela serializa os dados e define o header Content-Type: application/json.

Execute npm run dev e tente fazer uma chamada:

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

Nota: Você não pode ter tanto route.ts quanto page.tsx no mesmo diretório. Por exemplo, app/orders/page.tsx e app/orders/route.ts entrarão em conflito. É por isso que as pessoas costumam organizar as APIs em app/api.

3. Lendo query string e body

3.1. Query string

O parâmetro do handler é uma Request padrão. Para ler a query string, você cria uma URL a partir de 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);
}

Chamar /api/orders?minTotal=500000 retornará apenas o pedido de Bình:

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

Se preferir, você pode declarar o parâmetro como NextRequest (de next/server) e usar request.nextUrl.searchParams. Aqui usamos Request padrão porque é mais fácil criar em testes.

3.2. Body JSON e validação

Não confie nos dados que o cliente envia. Usamos zod para validar o body. Instale a biblioteca:

npm install zod

Adicione um handler POST ao mesmo arquivo:

// app/api/orders/route.ts (complemento)
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: "O body não é um JSON válido" }, { status: 400 });
  }

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

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

Explicação:

  • request.json() vai lançar uma exceção se o body estiver vazio ou tiver sintaxe incorreta. Por isso o envolvemos em try/catch e retornamos 400.
  • safeParse não lança. Ele retorna { success, data } ou { success, error }. Dados com tipo incorreto receberão 422.
  • O segundo parâmetro de Response.json permite definir status e headers. Aqui retornamos 201 Created com o header Location.
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}

Com um formulário HTML, você usa await request.formData(). Com dados brutos, você usa await request.text(). Tenha em mente que o body só pode ser lido uma vez.

4. Segmento dinâmico

Para ter GET /api/orders/:id, você cria uma pasta com um nome entre colchetes:

// 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: `Pedido não encontrado ${id}` }, { 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 });
}

Explicação:

  • O segundo parâmetro contém params. A partir do Next.js 15, params é uma Promise, então você deve fazer await nela.
  • DELETE retorna 204 No Content com body null. Você não deve usar Response.json neste caso, pois 204 não pode ter um body.
  • Os valores em params são sempre string. Se o ID é um número, você precisa convertê-lo e validá-lo.

Você também pode usar catch-all [...slug] (recebe string[]) ou optional catch-all [[...slug]].

Nota: Código antigo que escrevia params.id sem await receberá um aviso ou erro no Next.js 15+. Ao fazer upgrade do Next.js 14, este é o lugar mais comum onde as coisas quebram.

5. Headers, cookies e redirecionamento

Você pode ler headers diretamente de request.headers. Além disso, Next.js fornece funções cookies() e headers() de next/headers. Ambas são funções 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: "Não autenticado" }, { 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(...) é usado para ler cookies. set e delete funcionam apenas em Route Handlers ou Server Actions, não em Server Components.
  • Usar cookies() ou headers() torna a rota dinâmica, ou seja, é renderizada a cada requisição.

Se precisar redirecionar, use Response.redirect(new URL("/login", request.url), 307) ou a função redirect() de next/navigation.

Para CORS, você define o header e exporta OPTIONS para 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. Cache

Esta é a parte que confunde muitas pessoas. A partir do Next.js 15, os handlers GET não são mais cacheados por padrão. Cada requisição executa a função novamente. Outros métodos como POST, PUT, DELETE nunca são cacheados.

Se os dados mudam com pouca frequência e você quer fazer build como estático, faça opt-in com a configuração de segmento de rota:

// app/api/categories/route.ts
export const dynamic = "force-static";
export const revalidate = 3600; // revalidar no máximo a cada hora

export async function GET() {
  const categories = ["Eletrônicos", "Livros", "Moda"];
  return Response.json({ categories, generatedAt: new Date().toISOString() });
}
  • dynamic = "force-static" força a rota a ser renderizada no build. O resultado é então servido como arquivo estático.
  • revalidate = 3600 permite que Next.js recrie a resposta no máximo a cada hora (ISR).
  • Enquanto executa next dev, a rota ainda é executada a cada requisição. Para ver generatedAt ficar parado, você precisa fazer next build && next start.

Nota: Se uma rota force-static lê cookies(), headers() ou query string, esses valores estarão vazios no momento do build. Não use static para endpoints que dependem do usuário.

7. Testando Route Handlers com Vitest

Um Route Handler é apenas uma função async que recebe Request e retorna Response, então você pode chamá-lo diretamente sem iniciar um servidor. Instale 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" },
});

O plugin vite-tsconfig-paths faz o alias @/lib/... funcionar nos testes. Agora escreva os testes:

// 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("filtra pedidos por 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("cria um pedido válido e retorna 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("rejeita total negativo com 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)

Se o handler usar cookies() ou headers() de next/headers, chamá-lo diretamente lançará uma exceção porque falta o contexto de requisição do Next.js. Neste caso, você tem duas opções: fazer mock do módulo com vi.mock("next/headers", ...) ou ler valores de request.headers para tornar o código mais fácil de testar.

8. Erros comuns e quando não usar

Alguns erros comuns:

  • Fetching Route Handler de um Server Component. Chamar fetch("/api/orders") em um Server Component cria uma volta HTTP desnecessária. No build, essa chamada falha porque o servidor ainda não está rodando. Em vez disso, chame diretamente orderStore.findAll() ou a camada de consulta de dados.
  • Usar Route Handlers para mutações de formulários internos. Se apenas a UI do seu app chama, Server Actions são mais concisas. Elas têm type-safety end-to-end e integração com revalidatePath.
  • Armazenar estado em variáveis de módulo, como orderStore acima, ao fazer deploy serverless. Cada instância tem sua própria memória e pode ser reiniciada a qualquer momento. O exemplo no artigo é apenas para fins ilustrativos. Na prática, você precisa usar um banco de dados.
  • Esquecer que o body só pode ser lido uma vez. Se precisar ler duas vezes, use request.clone().
  • Retornar erros sem estrutura. Uma exceção não capturada se torna um 500 com uma página de erro HTML. Sempre retorne JSON com um status claro.

Route Handlers são apropriados quando você precisa:

  • De um endpoint para clientes externos, como um app mobile ou parceiro.
  • De um webhook que receba dados, por exemplo de Stripe ou GitHub.
  • De um endpoint que retorne arquivos, RSS ou dados em streaming.
  • De um endpoint que o código client-side chama com fetch.

9. Conclusão

Route Handlers transformam cada arquivo route.ts em um endpoint HTTP, usando Request/Response padrão da Web. Neste artigo, você aprendeu:

  • Como definir os métodos GET, POST, DELETE.
  • Como ler query string e body, validar body com zod.
  • Como lidar com segmentos dinâmicos com params como Promise.
  • Como trabalhar com headers, cookies e CORS.
  • Como habilitar cache estático quando necessário.
  • Como testar handlers diretamente com Vitest sem precisar de um servidor.

O princípio principal a lembrar: use Route Handlers para APIs públicas ou webhooks. Para dados internos, consulte diretamente em Server Components. Para mutações de formulários do seu próprio app, use Server Actions.

nextjsroute-handlersapitypescriptapp-router