Guía completa de API Routes en Next.js: de Route Handlers a las mejores prácticas de manejo de errores

El viernes por la tarde, el product manager se acercó y dijo: «¿Podemos añadir un endpoint de registro de usuarios?». Abrí la carpeta pages/api del proyecto, listo para repetir el mismo patrón de siempre, y me encontré con que estaba vacía. Entonces recordé: es un proyecto nuevo con App Router; la forma de escribir APIs cambió por completo.
Abrí la documentación de Next.js, vi «Route Handlers» y me dio un pequeño susto: otro concepto nuevo. Me pasé la tarde leyendo docs y ejemplos hasta entender qué es route.ts y por qué ya no puedo usar req y res como antes.
Si tú también te pierdes con el backend en Next.js, este artículo te ayuda a ordenar ideas. Compararé Pages Router y App Router para ver qué cambió, y con casos prácticos veremos peticiones, respuestas y manejo de errores. Al terminar, podrás escribir backends en Next.js con confianza.
Fundamentos de API Routes: la diferencia entre los dos enfoques
La era del Pages Router
Antes de Next.js 13, escribíamos APIs en pages/api. El estilo se parecía a Express, con los objetos req y res de 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!' })
}
La ventaja: arrancar rápido, sobre todo si vienes de Node.js o Express. La pega: depende de APIs de Node.js y en Edge Runtime puede dar problemas.
Route Handlers en la era del App Router
Con App Router (Next.js 13+), las APIs van en app/.../route.ts y usan las APIs Web estándar Request y Response:
// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: 'Hello from Route Handlers!' })
}
La primera vez también me costó. ¿Por qué ya no req y res? Por buenas razones:
- Estándares Web:
RequestyResponsenativos del navegador; código más portable y alineado con el desarrollo web moderno - Mejor tipado: TypeScript encaja mejor, sin tipos extra
- Edge Runtime: despliegue en Vercel Edge, Cloudflare Workers, etc., con respuestas más rápidas
Comparación rápida
| Característica | Pages Router | App Router |
|---|---|---|
| Ubicación | pages/api/* | app/*/route.ts |
| Diseño de API | Node.js req/res | Web estándar Request/Response |
| Métodos HTTP | Un export por defecto; req.method a mano | Un export por método (GET, POST, etc.) |
| Caché | Sin caché por defecto | GET cacheado por defecto |
Al principio tampoco lo entendía. Tras usarlo un tiempo, el código queda más claro, sobre todo con varios métodos HTTP: se acabó el if (req.method === 'GET') interminable.
Route Handlers en la práctica: crear y manejar distintas peticiones HTTP
Métodos HTTP soportados
Los Route Handlers admiten siete métodos: GET, POST, PUT, PATCH, DELETE, HEAD y OPTIONS. Cada uno es una función exportada con nombre; de un vistazo ves qué operaciones expone el endpoint.
Ejemplo completo de gestión de usuarios:
// app/api/users/route.ts
// Obtener lista de usuarios
export async function GET(request: Request) {
// Parámetros de consulta desde la URL
const { searchParams } = new URL(request.url)
const page = searchParams.get('page') || '1'
return Response.json({
users: [
{ id: 1, name: 'Ana' },
{ id: 2, name: 'Luis' }
],
page: parseInt(page)
})
}
// Crear usuario nuevo
export async function POST(request: Request) {
const body = await request.json()
return Response.json({
id: 3,
name: body.name
}, { status: 201 })
}
Obtener datos de la petición
Al principio también me lié. Resumen de formas:
- Parámetros URL:
request.urlcon el objetoURL
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q')
- Cuerpo JSON:
await request.json()
const body = await request.json()
console.log(body.email)
- Cuerpo FormData:
await request.formData()
const formData = await request.formData()
const file = formData.get('avatar')
- Headers y cookies: desde
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 ruta dinámica: segundo argumento de la función
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const userId = params.id
return Response.json({ userId })
}
Trampa que pisé: en Next.js 15+ params puede ser asíncrono (await params.id); en la práctica suele bastar y TypeScript te avisa.
Construir la respuesta
Lo más habitual es JSON con Response.json():
export async function GET() {
return Response.json({
success: true,
data: { message: 'Operación correcta' }
})
}
Código de estado y headers personalizados:
export async function POST(request: Request) {
const body = await request.json()
if (!body.email) {
return Response.json(
{ error: 'El email no puede estar vacío' },
{ status: 400 }
)
}
return Response.json(
{ id: 123, email: body.email },
{
status: 201,
headers: {
'X-Request-Id': 'abc-123',
'Cache-Control': 'no-cache'
}
}
)
}
Caso real: registro de usuario
Juntando lo anterior:
// app/api/auth/register/route.ts
import { headers } from 'next/headers'
export async function POST(request: Request) {
const headersList = headers()
const contentType = headersList.get('content-type')
if (!contentType?.includes('application/json')) {
return Response.json(
{ error: 'Envía los datos en formato JSON' },
{ status: 400 }
)
}
const body = await request.json()
const { username, email, password } = body
if (!username || !email || !password) {
return Response.json(
{ error: 'Usuario, email y contraseña son obligatorios' },
{ status: 400 }
)
}
// Aquí iría guardar en base de datos
// const user = await db.user.create({ username, email, password })
return Response.json({
success: true,
data: {
id: 1,
username,
email
}
}, { status: 201 })
}
Cubre headers, JSON, validación, errores y éxito: el patrón de la mayoría de endpoints.
Mejores prácticas de manejo de errores: APIs más estables
Try-catch bien usado
Al principio envolvía todo en un try-catch enorme:
// ❌ No recomendado: un try-catch para todo
export async function POST(request: Request) {
try {
const body = await request.json()
// lógica de negocio...
return Response.json({ success: true })
} catch (error) {
return Response.json({ error: 'Operación fallida' }, { status: 500 })
}
}
Problema: todo devuelve 500 y el frontend no distingue. Mejor separar por operación:
// ✅ Recomendado: errores por tipo
export async function POST(request: Request) {
let body
try {
body = await request.json()
} catch (error) {
return Response.json(
{ error: 'Formato de petición incorrecto; revisa el JSON' },
{ status: 400 }
)
}
if (!body.email || !body.password) {
return Response.json(
{ error: 'Email y contraseña son obligatorios' },
{ status: 400 }
)
}
try {
const user = await db.user.create(body)
return Response.json({ success: true, data: user })
} catch (error) {
if (error.code === 'P2002') {
return Response.json(
{ error: 'Este email ya está registrado' },
{ status: 409 }
)
}
console.error('Database error:', error)
return Response.json(
{ error: 'Error del servidor; inténtalo más tarde' },
{ status: 500 }
)
}
}
Los mensajes y códigos quedan claros para el cliente.
Respuestas de error estructuradas
En muchos proyectes el formato variaba: { error }, { message }, { msg }… Un estándar ayuda:
interface ErrorResponse {
success: false
error: string
code?: string
details?: any
requestId?: string
}
interface SuccessResponse<T> {
success: true
data: T
requestId?: string
}
Helpers reutilizables:
// 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,
requestId: nanoid()
}, { status })
}
Uso en un handler:
// 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('Usuario no encontrado', 404, 'USER_NOT_FOUND')
}
return successResponse(user)
} catch (error) {
return errorResponse(
'No se pudo obtener el usuario',
500,
'INTERNAL_ERROR',
error
)
}
}
Errores esperados vs inesperados
Dos familias:
- Esperados: formato inválido, recurso inexistente, sin permiso → 4xx
- Inesperados: BD caída, terceros fuera, bugs → 5xx
export async function POST(request: Request) {
const body = await request.json()
if (!body.email?.includes('@')) {
return errorResponse('Formato de email incorrecto', 400, 'INVALID_EMAIL')
}
try {
const response = await fetch('https://api.example.com/verify', {
method: 'POST',
body: JSON.stringify({ email: body.email })
})
if (!response.ok) {
return errorResponse('Verificación de email fallida', 400, 'VERIFICATION_FAILED')
}
return successResponse({ verified: true })
} catch (error) {
console.error('Unexpected error:', error)
return errorResponse(
'Servicio no disponible temporalmente',
503,
'SERVICE_UNAVAILABLE'
)
}
}
Evitar filtrar información sensible
En un proyecto temprano devolví el error crudo de la BD y expuse el esquema. Mejor:
try {
const user = await db.user.create(body)
return successResponse(user)
} catch (error) {
console.error('Database error:', {
error,
userId: request.headers.get('user-id'),
timestamp: new Date().toISOString()
})
return errorResponse(
'No se pudo crear el usuario; inténtalo más tarde',
500,
'CREATE_USER_FAILED'
)
}
Desarrollo vs producción:
const isDev = process.env.NODE_ENV === 'development'
return Response.json({
success: false,
error: 'Operación fallida',
stack: isDev ? error.stack : undefined,
details: isDev ? error : undefined
}, { status: 500 })
Diseño de respuestas: clave para frontend y backend
Principios REST
No hace falta ser dogmático, pero REST ayuda a que la API se entienda:
-
Métodos HTTP:
- GET: leer
- POST: crear
- PUT/PATCH: actualizar
- DELETE: borrar
-
URLs como recursos:
/api/users— lista/api/users/123— usuario 123/api/users/123/posts— posts de ese usuario
-
Códigos de estado:
- 200: OK
- 201: creado
- 400: error del cliente
- 401: no autenticado
- 403: sin permiso
- 404: no encontrado
- 500: error del servidor
Ejemplo de API de usuarios:
// app/api/users/route.ts
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 = await db.user.findMany({
skip: (page - 1) * limit,
take: limit
})
return Response.json({ success: true, data: users })
}
export async function POST(request: Request) {
const body = await request.json()
const user = await db.user.create({ data: body })
return Response.json(
{ success: true, data: user },
{ status: 201 }
)
}
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({ where: { id: params.id } })
if (!user) {
return Response.json(
{ success: false, error: 'Usuario no encontrado' },
{ status: 404 }
)
}
return Response.json({ success: true, data: user })
}
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
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 } }
) {
await db.user.delete({ where: { id: params.id } })
return Response.json({ success: true, data: null })
}
Formato unificado
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string; code?: string }
interface PaginatedResponse<T> {
success: true
data: T[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
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)
}
})
}
Tipos TypeScript compartidos
Carpeta types en el proyecto:
// 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
}
El frontend gana autocompletado y seguridad de tipos.
Problemas frecuentes y soluciones
Caché por defecto en GET
Tras desplegar, los cambios de usuario no se veían: GET cacheado. Next.js asume datos estáticos; en la práctica muchos GET son dinámicos.
Solución en route.ts:
// app/api/users/route.ts
export const dynamic = 'force-dynamic'
export async function GET() {
const users = await db.user.findMany()
return Response.json({ success: true, data: users })
}
Otras opciones:
export const revalidate = 0
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'
}
}
)
}
¿Cuándo cachear? Datos casi fijos (países, categorías):
// app/api/countries/route.ts
export const revalidate = 3600
export async function GET() {
const countries = await db.country.findMany()
return Response.json({ success: true, data: countries })
}
404 tras desplegar
Causas habituales:
- Nombre: debe ser
route.tsoroute.js, noapi.ts - Ubicación:
route.tsno junto apage.tsx - Git: el archivo no ignorado en
.gitignore
# ✅ Estructura correcta
app/
api/
users/
route.ts
users/
[id]/
route.ts
# ❌ Incorrecta
app/
api/
users.ts
users/
page.tsx
route.ts
Revisa que next.config.js no excluya app:
// next.config.js
module.exports = {
// No uses esto o ignorará app
// pageExtensions: ['page.tsx', 'page.ts'],
}
redirect dentro de try-catch
redirect() de Next.js lanza un error especial; si lo capturas, no redirige:
import { redirect } from 'next/navigation'
// ❌ Mal
export async function GET() {
try {
const isLoggedIn = await checkAuth()
if (!isLoggedIn) {
redirect('/login')
}
return Response.json({ success: true })
} catch (error) {
return Response.json({ error: 'Operación fallida' }, { status: 500 })
}
}
// ✅ Bien
export async function GET() {
const isLoggedIn = await checkAuth()
if (!isLoggedIn) {
redirect('/login')
}
try {
const data = await fetchData()
return Response.json({ success: true, data })
} catch (error) {
return Response.json({ error: 'Operación fallida' }, { status: 500 })
}
}
En Route Handlers suele bastar 401 y que el frontend redirija.
Cuándo no hace falta Route Handlers
Al principio pensaba que todo pasaba por HTTP. Los Server Components pueden llamar al backend directamente:
// ❌ No recomendado: API + fetch interno
// 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>{/* lista */}</div>
}
// ✅ Mejor: consulta directa
// app/blog/page.tsx
async function BlogPage() {
const posts = await db.post.findMany()
return <div>{/* lista */}</div>
}
Sí Route Handlers:
- Apps móviles o terceros
- Webhooks
- Mutaciones desde componentes cliente
- Subida de archivos o varias APIs externas
No hace falta:
- Datos en Server Components
- Formularios simples → Server Actions
- Navegación interna → routing de Next.js
Técnicas avanzadas
Validación de entrada
En lugar de muchos if, uso Zod:
import { z } from 'zod'
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()
const result = createUserSchema.safeParse(body)
if (!result.success) {
return Response.json({
success: false,
error: 'Validación fallida',
details: result.error.errors
}, { status: 400 })
}
const user = await db.user.create({ data: result.data })
return Response.json({ success: true, data: user }, { status: 201 })
}
Tipos desde el schema:
type CreateUserInput = z.infer<typeof createUserSchema>
Patrón middleware
Auth, logs y errores repetidos → middleware:
// lib/middleware.ts
type RouteHandler = (request: Request, context: any) => Promise<Response>
export function withAuth(handler: RouteHandler): RouteHandler {
return async (request, context) => {
const token = request.headers.get('authorization')
if (!token) {
return Response.json(
{ success: false, error: 'No autenticado' },
{ status: 401 }
)
}
const user = await verifyToken(token)
if (!user) {
return Response.json(
{ success: false, error: 'Token inválido' },
{ status: 401 }
)
}
context.user = user
return handler(request, context)
}
}
export function withLogging(handler: RouteHandler): RouteHandler {
return async (request, context) => {
const start = Date.now()
const { method, url } = request
console.log(`[${method}] ${url} - inicio`)
const response = await handler(request, context)
const duration = Date.now() - start
console.log(`[${method}] ${url} - fin (${duration}ms)`)
return response
}
}
// app/api/profile/route.ts
import { withAuth, withLogging } from '@/lib/middleware'
async function getProfile(request: Request, context: any) {
const user = context.user
return Response.json({ success: true, data: user })
}
export const GET = withLogging(withAuth(getProfile))
Con validación Zod:
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: 'Validación fallida',
details: result.error.errors
}, { status: 400 })
}
return handler(request, result.data, context)
}
}
export const POST = withAuth(
withValidation(createUserSchema, async (request, data, context) => {
const user = await db.user.create({ data })
return Response.json({ success: true, data: user }, { status: 201 })
})
)
Elegir Edge Runtime
Node.js Runtime por defecto; Edge para respuesta global rápida:
// app/api/hello/route.ts
export const runtime = 'edge'
export async function GET() {
return Response.json({ message: 'Hello from Edge!' })
}
Ventajas: baja latencia en el edge. Límites:
- Sin APIs de Node.js (
fs,path, etc.) - BD vía HTTP (Prisma Data Proxy, PlanetScale)
- Límite de tamaño del bundle
Edge cuando: API simple, mucha lectura, baja latencia global.
Node.js cuando: BD tradicional, librerías de Node, lógica pesada.
En la mayoría de proyectos uso Node.js; Edge para casos concretos.
Conclusión
Resumen:
- Cambio de modelo: de
req/resaRequest/Response - Route Handlers: un export por método HTTP
- Peticiones: URL, JSON, FormData, headers, cookies, rutas dinámicas
- Errores: esperados vs inesperados, formato unificado, sin filtrar secretos
- Respuestas: REST, códigos claros, tipos compartidos
- Trampas: caché GET, 404 al desplegar,
redirecten try-catch, abusar de Route Handlers
El salto Pages → App Router cuesta al principio; luego ganas tipos, claridad y despliegue flexible.
Siguientes pasos:
- Prueba ya: reescribe un endpoint con Route Handlers
- Plantilla: guarda helpers de error y respuesta para reutilizar
- Sigue la doc: Server Actions, Middleware y novedades de Next.js
No hay una única receta; adapta al equipo y al proyecto.
Si te sirvió, guárdalo y consúltalo cuando haga falta. ¡Buen desarrollo con Next.js!
FAQ
¿Cuál es la principal diferencia entre Route Handlers y las API Routes del Pages Router?
¿Por qué mi GET no devuelve datos actualizados?
¿Cuándo usar Route Handlers y cuándo Server Components?
¿Cómo manejar errores de API con elegancia?
Tras desplegar, todas las APIs dan 404 pero en local van bien. ¿Qué hago?
14 min de lectura · Publicado el: 5 ene 2026 · Actualizado el: 21 ago 2026
Guía completa de Next.js
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
SEO multilingüe en Next.js: guía completa para que los buscadores indexen cada idioma correctamente
Más del 60 % de los sitios multilingües tienen errores de configuración SEO. Esta guía explica hreflang, sitemaps multilingües y la elección de estrategia de URL para evitar trampas comunes y lograr el posicionamiento correcto en cada idioma.
Parte 17 de 51
Siguiente
Guía completa de optimización del rendimiento de APIs en Next.js: caché, streaming y edge computing
¿Tu API de Next.js pasa de 3 segundos a 500 ms? Aprende a elegir estrategias de caché, implementar respuestas en streaming y usar Edge Functions, con ejemplos reales y datos de rendimiento.
Parte 19 de 51



Comentarios
Inicia sesión con GitHub para dejar un comentario