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 prefixoapié apenas uma convenção. Next.js não o obriga. export async function GET()lida com requisiçõesGET. Next.js retorna automaticamente405 Method Not Allowedpara métodos que você não exporta.Response.json(...)é a Web API padrão. Ela serializa os dados e define o headerContent-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.tsquantopage.tsxno mesmo diretório. Por exemplo,app/orders/page.tsxeapp/orders/route.tsentrarão em conflito. É por isso que as pessoas costumam organizar as APIs emapp/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 emtry/catche retornamos400.safeParsenão lança. Ele retorna{ success, data }ou{ success, error }. Dados com tipo incorreto receberão422.- O segundo parâmetro de
Response.jsonpermite definirstatuseheaders. Aqui retornamos201 Createdcom o headerLocation.
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 fazerawaitnela. DELETEretorna204 No Contentcom bodynull. Você não deve usarResponse.jsonneste caso, pois204não pode ter um body.- Os valores em
paramssão semprestring. 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.idsemawaitreceberá 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.setedeletefuncionam apenas em Route Handlers ou Server Actions, não em Server Components.- Usar
cookies()ouheaders()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 = 3600permite 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 vergeneratedAtficar parado, você precisa fazernext build && next start.
Nota: Se uma rota
force-staticlê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 diretamenteorderStore.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
orderStoreacima, 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
500com uma página de erro HTML. Sempre retorne JSON com umstatusclaro.
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
paramscomo 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.