Guia completo de API Routes no Next.js: de Route Handlers às melhores práticas de tratamento de erros

Na tarde da última sexta-feira, o gerente de produto apareceu e perguntou: “Dá para adicionar um endpoint de cadastro de usuário?”. Abri a pasta pages/api do projeto, pronto para repetir o padrão de sempre, e descobri que ela estava vazia. Só então me lembrei: aquele era um projeto novo com App Router, e a forma de escrever APIs tinha mudado completamente.
Abri a documentação do Next.js e encontrei o termo “Route Handlers”. Bateu aquela apreensão: mais um conceito novo. Passei a tarde lendo a documentação e analisando exemplos até entender o que era um route.ts e por que os conhecidos req e res já não eram usados.
Se você também está confuso sobre como escrever endpoints de backend no Next.js, este artigo vai organizar as ideias. Primeiro, vou comparar as APIs do Pages Router e do App Router. Depois, exemplos práticos mostram como processar requisições, projetar respostas e tratar erros de forma elegante. Ao terminar, você terá segurança para criar APIs de backend com Next.js.
Fundamentos de API Routes: a diferença essencial entre as duas abordagens
Como era na época do Pages Router
Antes do Next.js 13, os endpoints eram escritos na pasta pages/api. A abordagem lembrava bastante o Express e usava os objetos req e res do Node.js:
// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
res.status(200).json({ message: 'Hello from Pages Router!' })
}
A vantagem era a curva de aprendizado curta. Quem já tinha experiência com Node.js ou Express praticamente começava sem estudar nada. Mas havia uma desvantagem: o código dependia de APIs específicas do Node.js e podia apresentar problemas ao ser implantado em ambientes de borda, ou Edge Runtime.
Route Handlers na era do App Router
Com a chegada do App Router no Next.js 13, a forma de escrever APIs mudou por completo. Agora, criamos um arquivo route.ts dentro do diretório app e usamos as APIs Web padrão Request e Response:
// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: 'Hello from Route Handlers!' })
}
Na primeira vez que vi esse formato, também estranhei. Por que não dava mais para usar req e res? Depois entendi que a mudança tinha motivos claros:
- Adoção dos padrões Web: as APIs nativas
RequesteResponsetornam o código mais portátil e alinhado ao desenvolvimento Web moderno. - Mais segurança de tipos: o suporte do TypeScript é mais completo, sem precisar instalar definições de tipos adicionais.
- Suporte ao Edge Runtime: a aplicação pode ser implantada em ambientes de borda, como Vercel Edge e Cloudflare Workers, com respostas mais rápidas.
Comparação das principais diferenças
| Recurso | Pages Router | App Router |
|---|---|---|
| Local do arquivo | pages/api/* | app/*/route.ts |
| Design da API | req/res do Node.js | Request/Response do padrão Web |
| Métodos HTTP | Uma exportação padrão e verificação manual de req.method | Uma exportação independente para cada método, como GET e POST |
| Comportamento de cache | Sem cache | Requisições GET armazenadas em cache por padrão |
Sinceramente, no começo eu também não entendia a necessidade da mudança. Depois de algum tempo de uso, percebi que o novo formato é realmente mais claro, sobretudo ao lidar com diferentes métodos HTTP: não é mais necessário escrever uma sequência de if (req.method === 'GET').
Route Handlers na prática: criando e processando diferentes requisições HTTP
Métodos HTTP compatíveis
Route Handlers aceitam sete métodos HTTP: GET, POST, PUT, PATCH, DELETE, HEAD e OPTIONS. Cada método corresponde a uma função exportada pelo nome. Gosto desse design porque basta olhar para a estrutura do código para saber quais operações o endpoint aceita.
Veja um exemplo completo de endpoint para gerenciar usuários:
// app/api/users/route.ts
// Obtém a lista de usuários
export async function GET(request: Request) {
// Obtém os parâmetros de consulta da URL
const { searchParams } = new URL(request.url)
const page = searchParams.get('page') || '1'
return Response.json({
users: [
{ id: 1, name: 'Zhang San' },
{ id: 2, name: 'Li Si' }
],
page: parseInt(page)
})
}
// Cria um novo usuário
export async function POST(request: Request) {
// Interpreta o corpo JSON da requisição
const body = await request.json()
return Response.json({
id: 3,
name: body.name
}, { status: 201 })
}
Processamento dos dados da requisição
Os Route Handlers do Next.js oferecem várias formas de obter os dados de uma requisição. No começo, eu também confundia algumas delas, então organizei assim:
- Parâmetros da URL: use
request.urljunto com o objetoURL.
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q')
- Corpo da requisição (JSON): use
await request.json().
const body = await request.json()
console.log(body.email) // Obtém o campo de e-mail
- Corpo da requisição (FormData): use
await request.formData().
const formData = await request.formData()
const file = formData.get('avatar')
- Cabeçalhos e cookies: importe-os de
next/headers.
import { headers, cookies } from 'next/headers'
export async function GET() {
const headersList = headers()
const cookieStore = cookies()
const token = headersList.get('authorization')
const userId = cookieStore.get('user_id')
// ...
}
- Parâmetros de rota dinâmica: obtenha-os pelo segundo argumento da função.
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const userId = params.id
return Response.json({ userId })
}
Aqui existe uma armadilha que já encontrei: no Next.js 15+, params é assíncrono e é preciso usar await params.id. Na maioria das situações, porém, o uso direto funciona, e o TypeScript avisa quando há algo a ajustar.
Construção da resposta
Retornar JSON é o caso mais comum. Basta usar Response.json():
export async function GET() {
return Response.json({
success: true,
data: { message: 'Operação concluída com sucesso' }
})
}
Também é simples definir um código de status e cabeçalhos personalizados:
export async function POST(request: Request) {
const body = await request.json()
// A validação dos parâmetros falhou: retorna 400
if (!body.email) {
return Response.json(
{ error: 'O e-mail é obrigatório' },
{ status: 400 }
)
}
// Criação concluída: retorna 201 e define cabeçalhos
return Response.json(
{ id: 123, email: body.email },
{
status: 201,
headers: {
'X-Request-Id': 'abc-123',
'Cache-Control': 'no-cache'
}
}
)
}
Cenário real: endpoint de cadastro de usuário
Agora vamos reunir os conceitos anteriores em um endpoint completo de cadastro:
// app/api/auth/register/route.ts
import { headers } from 'next/headers'
export async function POST(request: Request) {
// Obtém os cabeçalhos da requisição
const headersList = headers()
const contentType = headersList.get('content-type')
// Verifica o Content-Type
if (!contentType?.includes('application/json')) {
return Response.json(
{ error: 'Envie os dados no formato JSON' },
{ status: 400 }
)
}
// Interpreta o corpo da requisição
const body = await request.json()
const { username, email, password } = body
// Validação básica
if (!username || !email || !password) {
return Response.json(
{ error: 'Nome de usuário, e-mail e senha são obrigatórios' },
{ status: 400 }
)
}
// Aqui, o usuário deveria ser salvo no banco de dados
// const user = await db.user.create({ username, email, password })
// Retorna uma resposta de sucesso
return Response.json({
success: true,
data: {
id: 1,
username,
email
}
}, { status: 201 })
}
Esse exemplo inclui verificação de cabeçalhos, interpretação de JSON, validação de parâmetros, tratamento de erros e resposta de sucesso. É o padrão básico da maioria dos endpoints.
Melhores práticas de tratamento de erros: APIs mais estáveis e confiáveis
A forma correta de usar try-catch
Quando comecei a escrever APIs, costumava envolver tudo em um grande bloco try-catch:
// ❌ Não recomendado: um único try-catch para toda a lógica
export async function POST(request: Request) {
try {
const body = await request.json()
// Várias regras de negócio...
return Response.json({ success: true })
} catch (error) {
return Response.json({ error: 'Falha na operação' }, { status: 500 })
}
}
O problema é que todos os erros acabam retornando 500. O frontend não recebe detalhes úteis, e a depuração fica dolorosa. Depois, passei a tratar operações diferentes separadamente:
// ✅ Recomendado: diferencia tipos de erro
export async function POST(request: Request) {
let body
// Trata separadamente erros de interpretação do JSON
try {
body = await request.json()
} catch (error) {
return Response.json(
{ error: 'Formato de requisição inválido; verifique o JSON' },
{ status: 400 }
)
}
// Erros de validação podem ser retornados diretamente
if (!body.email || !body.password) {
return Response.json(
{ error: 'E-mail e senha são obrigatórios' },
{ status: 400 }
)
}
// Trata separadamente erros de banco de dados
try {
const user = await db.user.create(body)
return Response.json({ success: true, data: user })
} catch (error) {
// Verifica se o e-mail já existe
if (error.code === 'P2002') {
return Response.json(
{ error: 'Este e-mail já está cadastrado' },
{ status: 409 }
)
}
// Outros erros de banco de dados
console.error('Database error:', error)
return Response.json(
{ error: 'Erro no servidor; tente novamente mais tarde' },
{ status: 500 }
)
}
}
Com essa mudança, as mensagens ficam muito mais claras e o frontend pode reagir de forma diferente a cada código de status.
Respostas de erro estruturadas
Já trabalhei em muitos projetos e encontrei formatos de erro de todo tipo: às vezes { error: '...' }, às vezes { message: '...' }, às vezes { msg: '...' }. Para quem integra o frontend, isso é um sofrimento.
Com o tempo, cheguei a este formato padrão:
// Formato unificado de resposta de erro
interface ErrorResponse {
success: false
error: string // Mensagem amigável para o usuário
code?: string // Código de erro para internacionalização no frontend
details?: any // Detalhes do erro, usados no ambiente de desenvolvimento
requestId?: string // ID de rastreamento da requisição
}
// Formato unificado de resposta de sucesso
interface SuccessResponse<T> {
success: true
data: T
requestId?: string
}
Na prática, podemos criar funções auxiliares:
// lib/api-response.ts
import { nanoid } from 'nanoid'
export function successResponse<T>(data: T, status: number = 200) {
return Response.json({
success: true,
data,
requestId: nanoid()
}, { status })
}
export function errorResponse(
error: string,
status: number = 500,
code?: string,
details?: any
) {
const isDev = process.env.NODE_ENV === 'development'
return Response.json({
success: false,
error,
code,
details: isDev ? details : undefined, // Não retorna detalhes em produção
requestId: nanoid()
}, { status })
}
Assim, os endpoints ficam bem mais concisos:
// app/api/users/[id]/route.ts
import { successResponse, errorResponse } from '@/lib/api-response'
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const userId = params.id
try {
const user = await db.user.findUnique({ where: { id: userId } })
if (!user) {
return errorResponse('Usuário não encontrado', 404, 'USER_NOT_FOUND')
}
return successResponse(user)
} catch (error) {
return errorResponse(
'Falha ao obter os dados do usuário',
500,
'INTERNAL_ERROR',
error
)
}
}
Erros esperados versus erros inesperados
Depois de trabalhar algum tempo com APIs, percebi que os erros se dividem em dois grupos:
- Erros esperados: parâmetros inválidos, recurso inexistente ou falta de permissão. Fazem parte da lógica normal de negócio e devem retornar códigos 4xx.
- Erros inesperados: falha de conexão com o banco de dados, indisponibilidade de serviço externo ou bug no código. São erros do sistema e devem retornar códigos 5xx.
O tratamento também deve ser diferente:
export async function POST(request: Request) {
const body = await request.json()
// Erro esperado: retorna diretamente, sem precisar registrar log
if (!body.email?.includes('@')) {
return errorResponse('Formato de e-mail inválido', 400, 'INVALID_EMAIL')
}
try {
// Chama uma API externa
const response = await fetch('https://api.example.com/verify', {
method: 'POST',
body: JSON.stringify({ email: body.email })
})
if (!response.ok) {
// Um erro devolvido pela API de terceiros é um erro esperado
return errorResponse('Falha na verificação do e-mail', 400, 'VERIFICATION_FAILED')
}
return successResponse({ verified: true })
} catch (error) {
// Erros de rede e timeouts são inesperados
console.error('Unexpected error:', error) // Registra o erro
// É possível integrar um serviço de monitoramento, como o Sentry
// Sentry.captureException(error)
return errorResponse(
'Serviço temporariamente indisponível; tente novamente mais tarde',
503,
'SERVICE_UNAVAILABLE'
)
}
}
Como evitar o vazamento de informações sensíveis
Já caí nessa armadilha em projetos antigos: devolvi diretamente ao frontend a mensagem de erro do banco de dados e acabei expondo sua estrutura. A forma correta é esta:
try {
const user = await db.user.create(body)
return successResponse(user)
} catch (error) {
// ❌ Perigoso: retorna o erro original diretamente
// return errorResponse(error.message, 500)
// ✅ Seguro: retorna uma mensagem genérica e deixa os detalhes apenas no log
console.error('Database error:', {
error,
userId: request.headers.get('user-id'),
timestamp: new Date().toISOString()
})
return errorResponse(
'Falha ao criar o usuário; tente novamente mais tarde',
500,
'CREATE_USER_FAILED'
)
}
Também é importante diferenciar os ambientes de produção e desenvolvimento:
const isDev = process.env.NODE_ENV === 'development'
return Response.json({
success: false,
error: 'Falha na operação',
// Retorna detalhes do erro apenas no ambiente de desenvolvimento
stack: isDev ? error.stack : undefined,
details: isDev ? error : undefined
}, { status: 500 })
Design do formato de resposta: a chave para a colaboração entre frontend e backend
Princípios de design de uma API RESTful
Ao projetar uma API, costumo seguir os princípios RESTful. Não é obrigatório aplicá-los com rigidez, mas essas regras deixam a API mais fácil de entender.
Os pontos centrais são:
-
Use métodos HTTP para expressar operações:
- GET: obter um recurso
- POST: criar um recurso
- PUT/PATCH: atualizar um recurso
- DELETE: excluir um recurso
-
Use a URL para expressar recursos:
/api/users— lista de usuários/api/users/123— usuário de ID 123/api/users/123/posts— posts desse usuário
-
Use códigos de status para expressar o resultado:
- 200: sucesso
- 201: criação concluída
- 400: parâmetros inválidos enviados pelo cliente
- 401: usuário não autenticado
- 403: usuário sem permissão
- 404: recurso não encontrado
- 500: erro no servidor
Por exemplo, uma API de gerenciamento de usuários pode ser organizada assim:
// app/api/users/route.ts
export async function GET(request: Request) {
// GET /api/users?page=1&limit=20
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const users = await db.user.findMany({
skip: (page - 1) * limit,
take: limit
})
return Response.json({ success: true, data: users })
}
export async function POST(request: Request) {
// POST /api/users
const body = await request.json()
const user = await db.user.create({ data: body })
return Response.json(
{ success: true, data: user },
{ status: 201 } // Observe o uso do código 201
)
}
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
// GET /api/users/123
const user = await db.user.findUnique({ where: { id: params.id } })
if (!user) {
return Response.json(
{ success: false, error: 'Usuário não encontrado' },
{ status: 404 }
)
}
return Response.json({ success: true, data: user })
}
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
// PATCH /api/users/123
const body = await request.json()
const user = await db.user.update({
where: { id: params.id },
data: body
})
return Response.json({ success: true, data: user })
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
// DELETE /api/users/123
await db.user.delete({ where: { id: params.id } })
return Response.json({ success: true, data: null })
}
Formato unificado de resposta
Já mencionei o formato unificado ao falar de tratamento de erros; agora vamos completá-lo. Atualmente, uso esta estrutura:
// Tipo básico de resposta
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string; code?: string }
// Resposta paginada
interface PaginatedResponse<T> {
success: true
data: T[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
// Exemplo de resposta de lista
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const [users, total] = await Promise.all([
db.user.findMany({ skip: (page - 1) * limit, take: limit }),
db.user.count()
])
return Response.json({
success: true,
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
})
}
Definições de tipos no TypeScript
Se frontend e backend usam TypeScript, vale a pena compartilhar também as definições de tipos. Costumo criar uma pasta types no projeto:
// types/api.ts
export interface User {
id: string
username: string
email: string
createdAt: string
}
export interface CreateUserRequest {
username: string
email: string
password: string
}
export interface CreateUserResponse {
success: true
data: User
}
// types/api-client.ts
import type { CreateUserRequest, CreateUserResponse } from './api'
export async function createUser(data: CreateUserRequest) {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
const result: CreateUserResponse = await response.json()
if (!result.success) {
throw new Error(result.error)
}
return result.data
}
Assim, o frontend recebe sugestões completas de tipos ao chamar a API, o que torna o desenvolvimento bem mais agradável.
Problemas comuns e suas soluções
Cache padrão em requisições GET
Essa foi a armadilha que mais me prejudicou. Certa vez, criei um endpoint para consultar dados de usuário. Tudo funcionava localmente, mas, depois da implantação, o frontend continuava recebendo dados antigos após uma atualização. Passei horas investigando até descobrir que as requisições GET do Next.js eram armazenadas em cache por padrão.
Por que isso acontece? A ideia da equipe do Next.js é que muitas requisições GET retornam dados estáticos, e o cache melhora o desempenho. Na prática, porém, grande parte das requisições GET precisa de dados dinâmicos e atualizados.
A solução é simples: adicione uma configuração ao arquivo route.ts:
// app/api/users/route.ts
export const dynamic = 'force-dynamic' // Desativa o cache
export async function GET() {
const users = await db.user.findMany()
return Response.json({ success: true, data: users })
}
Há outras formas de fazer isso:
// Método 2: define revalidate como 0
export const revalidate = 0
// Método 3: define Cache-Control no cabeçalho da resposta
export async function GET() {
const users = await db.user.findMany()
return Response.json(
{ success: true, data: users },
{
headers: {
'Cache-Control': 'no-store, max-age=0'
}
}
)
}
Quando vale a pena usar cache? Se o endpoint retorna dados que quase nunca mudam, como listas de países ou categorias, aproveite o recurso:
// app/api/countries/route.ts
export const revalidate = 3600 // Cache por 1 hora
export async function GET() {
const countries = await db.country.findMany()
return Response.json({ success: true, data: countries })
}
API retorna 404 depois da implantação
Tudo funciona no desenvolvimento local, mas, depois da implantação na Vercel ou Netlify, todos os endpoints retornam 404. Já encontrei esse problema várias vezes; normalmente, a causa é uma destas:
- Nome de arquivo incorreto: deve ser
route.tsouroute.js, nuncaapi.tsou outro nome. - Local incorreto:
route.tsnão pode ficar na mesma pasta quepage.tsx. - Arquivo não enviado ao git: confira o
.gitignoree garanta que o arquivo de rota foi versionado.
Checklist:
# ✅ Estrutura correta
app/
api/
users/
route.ts # Correto: GET /api/users
users/
[id]/
route.ts # Correto: GET /api/users/123
# ❌ Estrutura incorreta
app/
api/
users.ts # Incorreto: deveria ser route.ts
users/
page.tsx
route.ts # Incorreto: não pode ficar junto de page.tsx
Outro ponto fácil de esquecer é verificar se next.config.js não exclui o diretório app:
// next.config.js
module.exports = {
// Não inclua esta configuração, pois ela ignora o diretório app
// pageExtensions: ['page.tsx', 'page.ts'],
}
Problemas com redirect dentro de try-catch
A função redirect() do Next.js lança um erro especial para realizar o redirecionamento. Se você a colocar dentro de um try-catch, esse erro será capturado e o redirecionamento não acontecerá:
import { redirect } from 'next/navigation'
// ❌ Forma incorreta
export async function GET() {
try {
const isLoggedIn = await checkAuth()
if (!isLoggedIn) {
redirect('/login') // O redirecionamento será capturado pelo catch
}
return Response.json({ success: true })
} catch (error) {
return Response.json({ error: 'Falha na operação' }, { status: 500 })
}
}
// ✅ Forma correta
export async function GET() {
const isLoggedIn = await checkAuth()
if (!isLoggedIn) {
redirect('/login') // Chame fora do try-catch
}
try {
const data = await fetchData()
return Response.json({ success: true, data })
} catch (error) {
return Response.json({ error: 'Falha na operação' }, { status: 500 })
}
}
Na verdade, o uso de redirect() em Route Handlers é pouco frequente. Na maioria dos casos, basta retornar o status 401 e deixar o frontend cuidar do redirecionamento.
Quando Route Handlers não são necessários
Quando comecei a usar o App Router, achava que toda busca de dados precisava de um endpoint de API. Mais tarde, descobri que Server Components podem chamar a lógica de backend diretamente, sem dar a volta por uma requisição HTTP.
Veja este cenário:
// ❌ Não recomendado: cria uma API para depois chamá-la no componente
// app/api/posts/route.ts
export async function GET() {
const posts = await db.post.findMany()
return Response.json({ success: true, data: posts })
}
// app/blog/page.tsx
async function BlogPage() {
const res = await fetch('http://localhost:3000/api/posts')
const { data } = await res.json()
return <div>{/* Renderiza a lista de posts */}</div>
}
// ✅ Recomendado: o Server Component consulta diretamente
// app/blog/page.tsx
async function BlogPage() {
const posts = await db.post.findMany() // Consulta o banco de dados diretamente
return <div>{/* Renderiza a lista de posts */}</div>
}
Quando usar Route Handlers?
- Chamadas externas: para fornecer endpoints a aplicativos móveis ou serviços de terceiros.
- Webhooks: para receber callbacks de serviços externos.
- Alteração de dados em Client Components: envio de formulários, exclusões e outras operações.
- Lógica de negócio complexa: upload de arquivos ou chamadas para várias APIs externas.
Quando Route Handlers não são necessários?
- Busca de dados em Server Components: consultar o banco diretamente é mais rápido.
- Envio de formulários simples: Server Actions são mais simples.
- Navegação entre páginas internas: use o roteamento do Next.js.
Técnicas avançadas para criar APIs mais profissionais
Validação de entrada
Nos exemplos anteriores, validamos os parâmetros manualmente com if, o que dá bastante trabalho. Hoje, uso Zod para validação:
import { z } from 'zod'
// Define as regras de validação
const createUserSchema = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
password: z.string().min(8),
age: z.number().min(18).optional()
})
export async function POST(request: Request) {
const body = await request.json()
// Valida os dados
const result = createUserSchema.safeParse(body)
if (!result.success) {
return Response.json({
success: false,
error: 'Falha na validação dos dados',
details: result.error.errors // Retorna detalhes dos erros de validação
}, { status: 400 })
}
// result.data contém dados validados e com tipos seguros
const user = await db.user.create({ data: result.data })
return Response.json({ success: true, data: user }, { status: 201 })
}
A vantagem do Zod é reunir validação e definição de tipos:
// Deriva um tipo TypeScript a partir do schema
type CreateUserInput = z.infer<typeof createUserSchema>
// Equivale a:
// type CreateUserInput = {
// username: string
// email: string
// password: string
// age?: number
// }
Padrão de middleware
Depois de escrever alguns endpoints, você percebe bastante código repetido: autenticação, logs e tratamento de erros. É nesse momento que vale abstrair tudo em middlewares:
// lib/middleware.ts
type RouteHandler = (request: Request, context: any) => Promise<Response>
// Middleware de autenticação
export function withAuth(handler: RouteHandler): RouteHandler {
return async (request, context) => {
const token = request.headers.get('authorization')
if (!token) {
return Response.json(
{ success: false, error: 'Usuário não autenticado' },
{ status: 401 }
)
}
// Valida o token
const user = await verifyToken(token)
if (!user) {
return Response.json(
{ success: false, error: 'Token inválido' },
{ status: 401 }
)
}
// Passa os dados do usuário ao handler
context.user = user
return handler(request, context)
}
}
// Middleware de log
export function withLogging(handler: RouteHandler): RouteHandler {
return async (request, context) => {
const start = Date.now()
const { method, url } = request
console.log(`[${method}] ${url} - Início do processamento`)
const response = await handler(request, context)
const duration = Date.now() - start
console.log(`[${method}] ${url} - Concluído (${duration}ms)`)
return response
}
}
// Uso combinado
// app/api/profile/route.ts
import { withAuth, withLogging } from '@/lib/middleware'
async function getProfile(request: Request, context: any) {
const user = context.user // Obtém o usuário do middleware
return Response.json({ success: true, data: user })
}
export const GET = withLogging(withAuth(getProfile))
Esse padrão funciona muito bem. Em projetos reais, costumo combiná-lo com a validação do Zod:
// lib/middleware.ts
export function withValidation<T>(
schema: z.Schema<T>,
handler: (request: Request, data: T, context: any) => Promise<Response>
): RouteHandler {
return async (request, context) => {
const body = await request.json()
const result = schema.safeParse(body)
if (!result.success) {
return Response.json({
success: false,
error: 'Falha na validação dos dados',
details: result.error.errors
}, { status: 400 })
}
return handler(request, result.data, context)
}
}
// Uso
export const POST = withAuth(
withValidation(createUserSchema, async (request, data, context) => {
// data já foi validado e tem tipos seguros
const user = await db.user.create({ data })
return Response.json({ success: true, data: user }, { status: 201 })
})
)
Escolha do Edge Runtime
O Next.js oferece dois runtimes: Node.js Runtime e Edge Runtime. Na maioria dos casos, o Node.js Runtime padrão é suficiente. Se a API precisa responder rapidamente no mundo todo, porém, vale considerar o Edge Runtime:
// app/api/hello/route.ts
export const runtime = 'edge' // Define o Edge Runtime
export async function GET() {
return Response.json({ message: 'Hello from Edge!' })
}
A vantagem do Edge Runtime é a baixa latência, pois o código é implantado no nó de borda mais próximo do usuário. Ele também tem limitações:
- Não permite APIs do Node.js: módulos como
fsepathnão estão disponíveis. - Não se conecta a bancos tradicionais: é preciso usar bancos com conexão HTTP, como Prisma Data Proxy ou PlanetScale.
- Limite no tamanho do pacote: o código não pode ser grande demais, ou a implantação falhará.
Quando usar Edge Runtime?
- Em APIs simples, sem dependências complexas.
- Em cenários com muito mais leitura do que escrita.
- Quando a baixa latência global é necessária.
Quando usar Node.js Runtime?
- Quando é preciso conectar um banco de dados tradicional.
- Quando são necessárias bibliotecas do ecossistema Node.js.
- Para lógica de negócio complexa.
Sinceramente, uso o Node.js Runtime padrão na maioria dos projetos. Hoje, o Edge Runtime ainda faz mais sentido em cenários específicos.
Conclusão
Chegamos ao fim dos principais conceitos de API Routes no Next.js. Vamos recapitular:
- Mudança na abordagem: saímos de
req/resdo Pages Router paraRequest/Responsedo App Router, adotando os padrões Web. - Route Handlers: cada método HTTP é exportado separadamente, com uma estrutura clara e suporte a GET, POST, PUT, PATCH, DELETE e outros.
- Processamento de requisições: parâmetros de URL, corpo JSON, FormData, cabeçalhos, cookies e parâmetros de rota dinâmica cobrem as diferentes formas de obter dados.
- Tratamento de erros: diferencie erros esperados de inesperados, unifique o formato de resposta e evite o vazamento de informações sensíveis.
- Design de respostas: siga os princípios RESTful, use códigos de status com significado e compartilhe tipos TypeScript.
- Armadilhas frequentes: cache de GET, erro 404 após a implantação,
redirectcapturado pelo try-catch e uso excessivo de Route Handlers.
É verdade que a forma de escrever APIs mudou bastante do Pages Router para o App Router, e o começo pode ser desconfortável. Depois de algum tempo, porém, o novo formato mostra vantagens claras: tipos mais seguros, código mais legível e implantação mais flexível.
Seus próximos passos podem ser:
- Começar agora: reescreva um endpoint do seu projeto atual com Route Handlers para sentir a diferença.
- Criar um modelo: use os exemplos de tratamento de erros e formato de resposta deste artigo para montar um template de API reutilizável.
- Continuar aprendendo: o Next.js evolui rapidamente; acompanhe a documentação oficial e conheça recursos como Server Actions e Middleware.
Lembre-se: não existe solução mágica para escrever APIs, e as propostas deste artigo não são as únicas corretas. Adapte-as à realidade do projeto e encontre a abordagem que funciona melhor para sua equipe.
Se este artigo foi útil, salve-o para consultar quando surgir uma dúvida. Bom desenvolvimento com Next.js!
FAQ
Quais são as principais diferenças entre Route Handlers e API Routes do Pages Router?
Por que minha requisição GET não retorna os dados mais recentes?
Quando devo usar Route Handlers e quando devo usar Server Components?
Como tratar erros de API de forma elegante?
Depois da implantação, todas as APIs retornam 404, mas funcionam localmente. O que fazer?
21 min de leitura · Publicado em: 5 jan 2026 · Atualizado em: 4 set 2026
Guia completo Next.js
Se você chegou pela busca, o caminho mais rápido é ir para o post anterior ou próximo desta série.
Anterior
Guia completo de SEO multilíngue no Next.js: faça os buscadores indexarem cada idioma corretamente
Mais de 60% dos sites multilíngues têm erros de configuração de SEO. Este guia explica hreflang, sitemaps multilíngues e estratégias de URL para evitar armadilhas e melhorar o posicionamento de cada versão.
Parte 10 de 26
Próximo
Segurança e autenticação de APIs no Next.js: guia prático completo de JWT a rate limiting
Guia prático completo de segurança para APIs no Next.js: aprenda a criar aplicações confiáveis e prontas para produção com autenticação JWT, configuração de CORS, rate limiting, validação de entrada e prevenção contra vulnerabilidades recentes.
Parte 12 de 26



Comentários
Entre com GitHub para comentar