Cambiar tema

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

Easton editorial illustration: performance inspection lens

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:

  1. Estándares Web: Request y Response nativos del navegador; código más portable y alineado con el desarrollo web moderno
  2. Mejor tipado: TypeScript encaja mejor, sin tipos extra
  3. Edge Runtime: despliegue en Vercel Edge, Cloudflare Workers, etc., con respuestas más rápidas

Comparación rápida

CaracterísticaPages RouterApp Router
Ubicaciónpages/api/*app/*/route.ts
Diseño de APINode.js req/resWeb estándar Request/Response
Métodos HTTPUn export por defecto; req.method a manoUn export por método (GET, POST, etc.)
CachéSin caché por defectoGET 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:

  1. Parámetros URL: request.url con el objeto URL
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q')
  1. Cuerpo JSON: await request.json()
const body = await request.json()
console.log(body.email)
  1. Cuerpo FormData: await request.formData()
const formData = await request.formData()
const file = formData.get('avatar')
  1. 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')

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

  1. Esperados: formato inválido, recurso inexistente, sin permiso → 4xx
  2. 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:

  1. Métodos HTTP:

    • GET: leer
    • POST: crear
    • PUT/PATCH: actualizar
    • DELETE: borrar
  2. URLs como recursos:

    • /api/users — lista
    • /api/users/123 — usuario 123
    • /api/users/123/posts — posts de ese usuario
  3. 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:

  1. Nombre: debe ser route.ts o route.js, no api.ts
  2. Ubicación: route.ts no junto a page.tsx
  3. 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:

  1. Apps móviles o terceros
  2. Webhooks
  3. Mutaciones desde componentes cliente
  4. Subida de archivos o varias APIs externas

No hace falta:

  1. Datos en Server Components
  2. Formularios simples → Server Actions
  3. 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:

  1. Sin APIs de Node.js (fs, path, etc.)
  2. BD vía HTTP (Prisma Data Proxy, PlanetScale)
  3. 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/res a Request/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, redirect en try-catch, abusar de Route Handlers

El salto Pages → App Router cuesta al principio; luego ganas tipos, claridad y despliegue flexible.

Siguientes pasos:

  1. Prueba ya: reescribe un endpoint con Route Handlers
  2. Plantilla: guarda helpers de error y respuesta para reutilizar
  3. 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?
Hay tres diferencias clave: 1) los Route Handlers usan las APIs Web estándar Request/Response, mientras que Pages Router usa req/res de Node.js; 2) cada método HTTP se exporta por separado, sin comprobar req.method a mano; 3) las peticiones GET se cachean por defecto y hay que configurar dynamic='force-dynamic' para desactivarlo.
¿Por qué mi GET no devuelve datos actualizados?
En App Router, las peticiones GET se cachean por defecto. Solución: añade export const dynamic = 'force-dynamic' en route.ts, o pon Cache-Control: no-store en los headers. Solo conviene cachear endpoints que devuelven datos estáticos.
¿Cuándo usar Route Handlers y cuándo Server Components?
Usa Route Handlers para: llamadas a APIs externas, webhooks, mutaciones desde componentes cliente y subida de archivos. No los necesitas cuando un Server Component puede consultar la base de datos directamente, o cuando un formulario simple basta con Server Actions.
¿Cómo manejar errores de API con elegancia?
Separa errores esperados (4xx) de inesperados (5xx) y usa un formato unificado { success, error, code, requestId }. Haz try-catch por operación, no uno gigante. En producción no devuelvas detalles sensibles; regístralos en logs.
Tras desplegar, todas las APIs dan 404 pero en local van bien. ¿Qué hago?
Revisa tres cosas: 1) el archivo debe llamarse route.ts o route.js; 2) route.ts no puede estar en la misma carpeta que page.tsx; 3) confirma que el archivo está en git y que next.config.js no excluye app. Lo habitual es un nombre o ubicación incorrectos.

14 min de lectura · Publicado el: 5 ene 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog