Alternar tema

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

Easton editorial illustration: performance inspection lens

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:

  1. Adoção dos padrões Web: as APIs nativas Request e Response tornam o código mais portátil e alinhado ao desenvolvimento Web moderno.
  2. Mais segurança de tipos: o suporte do TypeScript é mais completo, sem precisar instalar definições de tipos adicionais.
  3. 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

RecursoPages RouterApp Router
Local do arquivopages/api/*app/*/route.ts
Design da APIreq/res do Node.jsRequest/Response do padrão Web
Métodos HTTPUma exportação padrão e verificação manual de req.methodUma exportação independente para cada método, como GET e POST
Comportamento de cacheSem cacheRequisiçõ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:

  1. Parâmetros da URL: use request.url junto com o objeto URL.
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q')
  1. 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
  1. Corpo da requisição (FormData): use await request.formData().
const formData = await request.formData()
const file = formData.get('avatar')
  1. 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')

  // ...
}
  1. 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:

  1. 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.
  2. 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:

  1. 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
  2. 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
  3. 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:

  1. Nome de arquivo incorreto: deve ser route.ts ou route.js, nunca api.ts ou outro nome.
  2. Local incorreto: route.ts não pode ficar na mesma pasta que page.tsx.
  3. Arquivo não enviado ao git: confira o .gitignore e 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?

  1. Chamadas externas: para fornecer endpoints a aplicativos móveis ou serviços de terceiros.
  2. Webhooks: para receber callbacks de serviços externos.
  3. Alteração de dados em Client Components: envio de formulários, exclusões e outras operações.
  4. 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?

  1. Busca de dados em Server Components: consultar o banco diretamente é mais rápido.
  2. Envio de formulários simples: Server Actions são mais simples.
  3. 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:

  1. Não permite APIs do Node.js: módulos como fs e path não estão disponíveis.
  2. Não se conecta a bancos tradicionais: é preciso usar bancos com conexão HTTP, como Prisma Data Proxy ou PlanetScale.
  3. 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/res do Pages Router para Request/Response do 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, redirect capturado 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:

  1. Começar agora: reescreva um endpoint do seu projeto atual com Route Handlers para sentir a diferença.
  2. Criar um modelo: use os exemplos de tratamento de erros e formato de resposta deste artigo para montar um template de API reutilizável.
  3. 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?
Há três diferenças principais: 1) Route Handlers usam as APIs Web padrão Request e Response, enquanto o Pages Router usa req e res do Node.js; 2) cada método HTTP é exportado separadamente, sem precisar verificar req.method manualmente; 3) requisições GET são armazenadas em cache por padrão, e é necessário configurar dynamic='force-dynamic' para desativá-lo.
Por que minha requisição GET não retorna os dados mais recentes?
As requisições GET do App Router do Next.js são armazenadas em cache por padrão. Para resolver, adicione export const dynamic = 'force-dynamic' ao arquivo route.ts ou defina Cache-Control: no-store no cabeçalho da resposta. O cache só é adequado para endpoints que retornam dados estáticos.
Quando devo usar Route Handlers e quando devo usar Server Components?
Use Route Handlers para chamadas de API externas, webhooks, alterações de dados em Client Components e upload de arquivos. Eles não são necessários quando um Server Component pode consultar o banco de dados diretamente ou quando um formulário simples pode ser enviado de modo mais conveniente com Server Actions.
Como tratar erros de API de forma elegante?
Separe erros esperados (4xx) de erros inesperados (5xx) e use um formato unificado, como { success, error, code, requestId }. Trate operações diferentes com blocos try-catch separados, em vez de envolver toda a lógica em um único bloco. Em produção, não devolva informações sensíveis; registre-as apenas nos logs.
Depois da implantação, todas as APIs retornam 404, mas funcionam localmente. O que fazer?
Confira três pontos: 1) o arquivo precisa se chamar route.ts ou route.js; 2) route.ts não pode ficar na mesma pasta que page.tsx; 3) confirme que o arquivo foi enviado ao git e que next.config.js não exclui o diretório app. Nomes ou locais incorretos são causas frequentes.

21 min de leitura · Publicado em: 5 jan 2026 · Atualizado em: 4 set 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog