Route Handlers en Next.js 15: Guía Práctica para Construir APIs

Aprende a construir APIs con Route Handlers en Next.js 15. Guía práctica con ejemplos de CRUD, CORS, streaming, webhooks, subida de archivos y patrones BFF. Actualizada para Next.js 15 y 16.

Introducción: ¿Por qué necesitas dominar los Route Handlers?

Si ya has trabajado con React Server Components, Server Actions y Middleware en Next.js — los tres pilares que cubrimos en artículos anteriores —, hay una pieza que todavía nos falta: los Route Handlers. Básicamente, son el mecanismo que te permite construir APIs completas dentro de tu proyecto Next.js, sin montar un backend aparte.

Piensa en ellos como la evolución de las antiguas API Routes del Pages Router. Mientras que las Server Actions se ocupan de las mutaciones internas (formularios, actualizaciones desde componentes React), los Route Handlers cubren todo lo demás: endpoints para clientes externos, webhooks, streaming, CORS, subida de archivos y patrones Backend-for-Frontend. Son herramientas complementarias, y la verdad es que saber cuándo usar cada una marca la diferencia entre una arquitectura limpia y un lío de responsabilidades mezcladas.

En esta guía vamos a ir construyendo APIs reales paso a paso — desde un GET básico hasta cosas más interesantes como streaming con ReadableStream, verificación de webhooks y rate limiting. Todo actualizado para Next.js 15, con notas sobre lo que cambia en Next.js 16.

De API Routes a Route Handlers: qué cambió y por qué importa

El modelo antiguo con Pages Router

Si vienes del Pages Router, seguramente estás acostumbrado a crear archivos en pages/api/ que exportan un handler con los objetos req y res al estilo Express:

// pages/api/usuarios.ts (Pages Router — modelo antiguo)
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'GET') {
    res.status(200).json({ usuarios: [] })
  } else if (req.method === 'POST') {
    const datos = req.body
    res.status(201).json({ mensaje: 'Usuario creado' })
  } else {
    res.setHeader('Allow', ['GET', 'POST'])
    res.status(405).end(`Método ${req.method} no permitido`)
  }
}

Funcionaba, sí. Pero tenía limitaciones claras: sin soporte nativo de caché, sin tipado fuerte para los métodos HTTP, y una API basada en Node.js que se alejaba bastante de los estándares web.

El modelo moderno con App Router

Los Route Handlers del App Router cambian el paradigma por completo. Usan las APIs web estándar de Request y Response, cada método HTTP se exporta como función separada, y se integran con el sistema de caché y el Edge Runtime de Next.js.

// app/api/usuarios/route.ts (App Router — modelo moderno)
import { NextRequest, NextResponse } from 'next/server'

export async function GET() {
  const usuarios = await obtenerUsuarios()
  return NextResponse.json({ usuarios })
}

export async function POST(request: NextRequest) {
  const datos = await request.json()
  const nuevoUsuario = await crearUsuario(datos)
  return NextResponse.json(nuevoUsuario, { status: 201 })
}

La diferencia salta a la vista: código más limpio, separación clara de métodos, y nada de condicionales if/else para distinguir entre GET y POST. Next.js enruta automáticamente cada petición al método exportado. Y si alguien envía un método que no has definido, devuelve un 405 Method Not Allowed sin que tengas que hacer nada.

Estructura básica y métodos HTTP soportados

Los Route Handlers se definen creando un archivo route.ts (o route.js) dentro del directorio app/. Los métodos soportados son: GET, POST, PUT, PATCH, DELETE, HEAD y OPTIONS.

Ojo con esto: no puedes tener un route.ts y un page.tsx en el mismo segmento de ruta. Si necesitas una página y un endpoint en la misma URL, mueve el Route Handler a un subdirectorio como app/api/.

// Estructura de archivos típica
app/
├── api/
│   ├── usuarios/
│   │   ├── route.ts          → GET /api/usuarios, POST /api/usuarios
│   │   └── [id]/
│   │       └── route.ts      → GET /api/usuarios/:id, PUT, DELETE
│   ├── productos/
│   │   └── route.ts          → GET /api/productos
│   └── webhook/
│       └── stripe/
│           └── route.ts      → POST /api/webhook/stripe
└── page.tsx

Ejemplo completo: CRUD de recursos

Vamos al grano con un ejemplo práctico. Este handler maneja la lista y creación de productos, con paginación incluida:

// app/api/productos/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/database'

// GET /api/productos — Listar todos los productos
export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl
  const categoria = searchParams.get('categoria')
  const pagina = parseInt(searchParams.get('pagina') || '1')
  const limite = parseInt(searchParams.get('limite') || '10')

  const productos = await db.producto.findMany({
    where: categoria ? { categoria } : undefined,
    skip: (pagina - 1) * limite,
    take: limite,
    orderBy: { creadoEn: 'desc' },
  })

  const total = await db.producto.count({
    where: categoria ? { categoria } : undefined,
  })

  return NextResponse.json({
    datos: productos,
    paginacion: {
      pagina,
      limite,
      total,
      totalPaginas: Math.ceil(total / limite),
    },
  })
}

// POST /api/productos — Crear un producto
export async function POST(request: NextRequest) {
  try {
    const cuerpo = await request.json()

    if (!cuerpo.nombre || !cuerpo.precio) {
      return NextResponse.json(
        { error: 'Nombre y precio son obligatorios' },
        { status: 400 }
      )
    }

    const producto = await db.producto.create({ data: cuerpo })
    return NextResponse.json(producto, { status: 201 })
  } catch (error) {
    return NextResponse.json(
      { error: 'Error al crear el producto' },
      { status: 500 }
    )
  }
}

Rutas dinámicas con parámetros asíncronos

Aquí viene algo que personalmente me costó un par de horas la primera vez. A partir de Next.js 15, los parámetros de ruta son asíncronos. Tienes que usar await para acceder a params. Si te olvidas del await, obtienes undefined silenciosamente — un bug sutil que puede volverte loco tratando de depurar.

// app/api/productos/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/database'

// GET /api/productos/:id
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params  // ← ¡Importante! await es obligatorio en Next.js 15+

  const producto = await db.producto.findUnique({
    where: { id },
  })

  if (!producto) {
    return NextResponse.json(
      { error: 'Producto no encontrado' },
      { status: 404 }
    )
  }

  return NextResponse.json(producto)
}

// PUT /api/productos/:id
export async function PUT(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const datos = await request.json()

  const producto = await db.producto.update({
    where: { id },
    data: datos,
  })

  return NextResponse.json(producto)
}

// DELETE /api/productos/:id
export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  await db.producto.delete({ where: { id } })

  return new Response(null, { status: 204 })
}

Trabajando con headers, cookies y autenticación

Next.js te da helpers específicos a través de next/headers para leer y manipular headers y cookies. Honestamente, es mucho más cómodo que el enfoque del Pages Router.

// app/api/perfil/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cookies, headers } from 'next/headers'
import { verificarToken } from '@/lib/auth'

export async function GET(request: NextRequest) {
  // Leer headers
  const headersList = await headers()
  const autorizacion = headersList.get('Authorization')
  const userAgent = headersList.get('User-Agent')

  // Leer cookies
  const cookieStore = await cookies()
  const tokenSesion = cookieStore.get('session-token')

  // Verificar autenticación
  const token = autorizacion?.replace('Bearer ', '')
  if (!token && !tokenSesion) {
    return NextResponse.json(
      { error: 'No autorizado' },
      { status: 401 }
    )
  }

  const usuario = await verificarToken(token || tokenSesion?.value || '')
  if (!usuario) {
    return NextResponse.json(
      { error: 'Token inválido' },
      { status: 403 }
    )
  }

  return NextResponse.json({ usuario })
}

// Ejemplo: establecer cookies en la respuesta
export async function POST(request: NextRequest) {
  const { email, contrasena } = await request.json()
  const token = await autenticarUsuario(email, contrasena)

  const respuesta = NextResponse.json({ exito: true })
  respuesta.cookies.set('session-token', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 7 días
    path: '/',
  })

  return respuesta
}

Configurar CORS correctamente: tres enfoques

Si tu API va a ser consumida desde otro dominio — una app móvil, un frontend separado, cualquier cliente externo — necesitas CORS. Y seamos sinceros, este es uno de los temas que más dolores de cabeza genera en Next.js. Así que vamos a verlo con calma.

Enfoque 1: CORS por ruta individual

La opción más directa. Exportas un handler OPTIONS para el preflight e incluyes los headers CORS en cada respuesta:

// app/api/datos-publicos/route.ts
const corsHeaders = {
  'Access-Control-Allow-Origin': 'https://mi-frontend.com',
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}

export async function OPTIONS() {
  return new Response(null, {
    status: 204,
    headers: corsHeaders,
  })
}

export async function GET() {
  const datos = await obtenerDatosPublicos()
  return NextResponse.json(datos, { headers: corsHeaders })
}

export async function POST(request: NextRequest) {
  const cuerpo = await request.json()
  const resultado = await procesarDatos(cuerpo)
  return NextResponse.json(resultado, {
    status: 201,
    headers: corsHeaders,
  })
}

Enfoque 2: CORS global con next.config.js

Si todos tus endpoints necesitan los mismos headers CORS, mejor defínelos una sola vez en la configuración global. Menos repetición, menos posibilidad de olvidarte uno:

// next.config.ts
const nextConfig = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Origin', value: 'https://mi-frontend.com' },
          { key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' },
          { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
        ],
      },
    ]
  },
}

export default nextConfig

Enfoque 3: CORS en Middleware (Proxy en Next.js 16)

Para aplicar CORS de forma centralizada antes de que la petición llegue a cualquier Route Handler. Este es mi enfoque favorito cuando tengo más de tres o cuatro rutas API:

// middleware.ts (o proxy.ts en Next.js 16)
import { NextRequest, NextResponse } from 'next/server'

const origenesPermitidos = ['https://mi-frontend.com', 'https://app.midominio.com']

export function middleware(request: NextRequest) {
  const origen = request.headers.get('Origin') || ''
  const esCors = origenesPermitidos.includes(origen)

  // Manejar preflight
  if (request.method === 'OPTIONS') {
    return new NextResponse(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': esCors ? origen : '',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Max-Age': '86400',
      },
    })
  }

  const respuesta = NextResponse.next()
  if (esCors) {
    respuesta.headers.set('Access-Control-Allow-Origin', origen)
  }
  return respuesta
}

export const config = {
  matcher: '/api/:path*',
}

Streaming con ReadableStream: respuestas en tiempo real

Una de las capacidades más interesantes de los Route Handlers (y que pocas guías cubren bien) es el soporte nativo para streaming. Esto es especialmente útil para respuestas de IA generativa, procesamiento de datos grandes o Server-Sent Events.

// app/api/stream/route.ts
export async function GET() {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      const eventos = [
        { tipo: 'inicio', mensaje: 'Procesamiento iniciado' },
        { tipo: 'progreso', porcentaje: 25 },
        { tipo: 'progreso', porcentaje: 50 },
        { tipo: 'progreso', porcentaje: 75 },
        { tipo: 'completado', mensaje: 'Procesamiento finalizado' },
      ]

      for (const evento of eventos) {
        const datos = `data: ${JSON.stringify(evento)}\n\n`
        controller.enqueue(encoder.encode(datos))
        // Simular trabajo asíncrono
        await new Promise((resolve) => setTimeout(resolve, 1000))
      }

      controller.close()
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  })
}

Consumir el stream desde el cliente

Del lado del cliente, consumir el stream es bastante directo con la API ReadableStream:

'use client'

import { useState } from 'react'

export function VisorProgreso() {
  const [eventos, setEventos] = useState<string[]>([])

  async function iniciarProceso() {
    const respuesta = await fetch('/api/stream')
    const lector = respuesta.body?.getReader()
    const decodificador = new TextDecoder()

    if (!lector) return

    while (true) {
      const { done, value } = await lector.read()
      if (done) break

      const texto = decodificador.decode(value)
      const lineas = texto.split('\n').filter((l) => l.startsWith('data: '))

      for (const linea of lineas) {
        const datos = JSON.parse(linea.replace('data: ', ''))
        setEventos((prev) => [...prev, JSON.stringify(datos)])
      }
    }
  }

  return (
    <div>
      <button onClick={iniciarProceso}>Iniciar</button>
      {eventos.map((e, i) => (
        <p key={i}>{e}</p>
      ))}
    </div>
  )
}

Subida de archivos con formData()

Algo que me sorprendió gratamente del App Router: la subida de archivos se simplifica un montón gracias al método formData(). No necesitas Multer ni desactivar el body parser — nada de eso.

// app/api/subir/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'

export async function POST(request: NextRequest) {
  const formData = await request.formData()
  const archivo = formData.get('archivo') as File | null

  if (!archivo) {
    return NextResponse.json(
      { error: 'No se proporcionó ningún archivo' },
      { status: 400 }
    )
  }

  // Validar tipo y tamaño
  const tiposPermitidos = ['image/jpeg', 'image/png', 'image/webp']
  if (!tiposPermitidos.includes(archivo.type)) {
    return NextResponse.json(
      { error: 'Tipo de archivo no permitido' },
      { status: 400 }
    )
  }

  const TAMANO_MAXIMO = 5 * 1024 * 1024 // 5 MB
  if (archivo.size > TAMANO_MAXIMO) {
    return NextResponse.json(
      { error: 'El archivo excede el tamaño máximo de 5 MB' },
      { status: 400 }
    )
  }

  // Guardar el archivo
  const bytes = await archivo.arrayBuffer()
  const buffer = Buffer.from(bytes)

  const directorio = path.join(process.cwd(), 'public', 'uploads')
  await mkdir(directorio, { recursive: true })

  const nombreArchivo = `${Date.now()}-${archivo.name}`
  const rutaArchivo = path.join(directorio, nombreArchivo)
  await writeFile(rutaArchivo, buffer)

  return NextResponse.json({
    url: `/uploads/${nombreArchivo}`,
    nombre: archivo.name,
    tamano: archivo.size,
  })
}

Recibir webhooks de forma segura

Los Route Handlers encajan perfecto para recibir webhooks de servicios como Stripe, GitHub o Resend. Pero hay algo que no puedes saltarte: siempre verifica la firma del webhook. Sin esto, cualquier atacante podría enviarte eventos falsos.

// app/api/webhook/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!

export async function POST(request: NextRequest) {
  const cuerpo = await request.text() // ← Usar text(), no json()
  const firma = request.headers.get('stripe-signature')

  if (!firma) {
    return NextResponse.json(
      { error: 'Falta la firma del webhook' },
      { status: 400 }
    )
  }

  let evento: Stripe.Event

  try {
    evento = stripe.webhooks.constructEvent(cuerpo, firma, webhookSecret)
  } catch (err) {
    console.error('Error verificando webhook de Stripe:', err)
    return NextResponse.json(
      { error: 'Firma inválida' },
      { status: 400 }
    )
  }

  // Procesar el evento según su tipo
  switch (evento.type) {
    case 'checkout.session.completed':
      const sesion = evento.data.object as Stripe.Checkout.Session
      await procesarPagoExitoso(sesion)
      break
    case 'invoice.payment_failed':
      const factura = evento.data.object as Stripe.Invoice
      await manejarPagoFallido(factura)
      break
    default:
      console.log(`Evento no manejado: ${evento.type}`)
  }

  return NextResponse.json({ recibido: true })
}

Un detalle que te puede ahorrar horas de frustración: usa request.text() en vez de request.json() para leer el cuerpo. La verificación de firma necesita el cuerpo crudo — si lo parseas a JSON primero, la firma no va a coincidir nunca.

Rate limiting: protege tus endpoints

Si tu API es pública (o incluso si no lo es del todo), necesitas limitar cuántas peticiones puede hacer un cliente en un periodo de tiempo. Es una de esas cosas que parece opcional hasta que alguien abusa de tu endpoint y te sube la factura del hosting.

Aquí va un enfoque práctico con almacén en memoria para desarrollo:

// lib/rate-limit.ts
const solicitudes = new Map<string, { contador: number; reinicio: number }>()

export function verificarLimite(
  ip: string,
  limite: number = 60,
  ventanaMs: number = 60_000
): { permitido: boolean; restante: number; reinicioEn: number } {
  const ahora = Date.now()
  const registro = solicitudes.get(ip)

  if (!registro || ahora > registro.reinicio) {
    solicitudes.set(ip, { contador: 1, reinicio: ahora + ventanaMs })
    return { permitido: true, restante: limite - 1, reinicioEn: ahora + ventanaMs }
  }

  if (registro.contador >= limite) {
    return { permitido: false, restante: 0, reinicioEn: registro.reinicio }
  }

  registro.contador++
  return { permitido: true, restante: limite - registro.contador, reinicioEn: registro.reinicio }
}

// Uso en un Route Handler
// app/api/datos/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { verificarLimite } from '@/lib/rate-limit'

export async function GET(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') || '127.0.0.1'
  const { permitido, restante, reinicioEn } = verificarLimite(ip)

  if (!permitido) {
    return NextResponse.json(
      { error: 'Demasiadas solicitudes. Intenta de nuevo más tarde.' },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': '60',
          'X-RateLimit-Remaining': '0',
          'Retry-After': String(Math.ceil((reinicioEn - Date.now()) / 1000)),
        },
      }
    )
  }

  const datos = await obtenerDatos()
  return NextResponse.json(datos, {
    headers: {
      'X-RateLimit-Limit': '60',
      'X-RateLimit-Remaining': String(restante),
    },
  })
}

Para producción con múltiples instancias o serverless, cambia el Map en memoria por @upstash/ratelimit con Redis. Funciona en el Edge Runtime y escala sin problemas.

Route Handlers vs Server Actions: ¿cuándo usar cada uno?

Esta es probablemente la pregunta que más veo en foros y discusiones. Ambos ejecutan código en el servidor, pero están pensados para cosas distintas:

  • Server Actions — Para mutaciones internas llamadas desde componentes React. Formularios, actualizaciones optimistas, revalidación de datos. Se invocan directamente desde el cliente como funciones, con tipado de extremo a extremo.
  • Route Handlers — Para APIs públicas que consumen clientes externos, webhooks de terceros, streaming, subida de archivos, endpoints que necesitan control explícito sobre status codes, headers y CORS.

La regla que yo sigo: si el consumidor es un componente React dentro de tu propia app, empieza con Server Actions. Si necesitas HTTP explícito (status codes personalizados, streaming, CORS) o el consumidor es externo, ve con Route Handlers. Así de simple.

Caché y comportamiento dinámico en Next.js 15+

Un cambio importante que te conviene conocer: a partir de Next.js 15, los handlers GET son dinámicos (sin caché) por defecto. Esto es diferente al comportamiento anterior donde los GET se cacheaban automáticamente. Si quieres que un endpoint sea estático, ahora tienes que declararlo de forma explícita:

// Route Handler estático — se cachea como una página estática
export const dynamic = 'force-static'

export async function GET() {
  const datos = await obtenerDatosEstables()
  return NextResponse.json(datos)
}

// Route Handler dinámico (por defecto en Next.js 15+)
// No necesitas hacer nada especial
export async function GET(request: NextRequest) {
  const datosActuales = await obtenerDatosEnTiempoReal()
  return NextResponse.json(datosActuales)
}

El patrón BFF (Backend-for-Frontend)

Los Route Handlers son ideales para implementar el patrón BFF, donde tu app Next.js actúa como capa intermedia entre el frontend y tus microservicios. Puedes consolidar varias llamadas a APIs, aplicar autenticación, transformar datos y cachear respuestas, todo en un solo endpoint.

En la práctica, esto se traduce en menos llamadas desde el cliente y respuestas más rápidas:

// app/api/dashboard/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { verificarSesion } from '@/lib/auth'

export async function GET(request: NextRequest) {
  const sesion = await verificarSesion(request)
  if (!sesion) {
    return NextResponse.json({ error: 'No autorizado' }, { status: 401 })
  }

  // Consolidar múltiples microservicios en una sola respuesta
  const [pedidos, inventario, metricas] = await Promise.all([
    fetch(`${process.env.SERVICIO_PEDIDOS}/api/pedidos?usuario=${sesion.id}`).then(r => r.json()),
    fetch(`${process.env.SERVICIO_INVENTARIO}/api/resumen`).then(r => r.json()),
    fetch(`${process.env.SERVICIO_ANALYTICS}/api/metricas?periodo=30d`).then(r => r.json()),
  ])

  // Transformar y devolver solo lo que el frontend necesita
  return NextResponse.json({
    resumenPedidos: {
      total: pedidos.length,
      pendientes: pedidos.filter((p: any) => p.estado === 'pendiente').length,
    },
    alertasInventario: inventario.productosConBajoStock,
    metricas: {
      ingresos: metricas.ingresosTotales,
      tasaConversion: metricas.tasaConversion,
    },
  })
}

Notas sobre Next.js 16: de Middleware a Proxy

Si estás pendiente de la evolución del framework, Next.js 16 trae un cambio que vale la pena tener en el radar: Middleware pasa a llamarse Proxy. El archivo middleware.ts se renombra a proxy.ts, y aunque la funcionalidad es esencialmente la misma, hay una restricción nueva: los Proxy ya no pueden devolver cuerpos de respuesta. Si antes usabas Middleware para devolver JSON directamente, esa lógica ahora pertenece a los Route Handlers.

proxy.ts se enfoca en interceptar peticiones para redirecciones, rewrites y manipulación de headers. Los Route Handlers absorben toda la responsabilidad de generar respuestas HTTP. Puede parecer un cambio menor, pero en la práctica hace que la arquitectura sea más predecible y más fácil de depurar.

Preguntas frecuentes

¿Puedo tener un archivo route.ts y page.tsx en el mismo directorio?

No. Next.js no permite un route.ts y un page.tsx en el mismo segmento de ruta. Si necesitas ambos en la misma URL, organiza tus endpoints bajo app/api/ o mueve la página a otra ruta.

¿Los Route Handlers funcionan en el Edge Runtime?

Sí. Solo tienes que exportar export const runtime = 'edge' en tu archivo de ruta. El Edge Runtime ejecuta tu código más cerca del usuario, con menor latencia. Eso sí, ten en cuenta que no soporta todas las APIs de Node.js — fs y ciertas dependencias nativas no van a estar disponibles.

¿Cómo migro de API Routes del Pages Router a Route Handlers?

La migración se puede hacer de forma incremental. Crea el route.ts equivalente en app/, reemplaza el handler único por funciones nombradas (GET, POST, etc.), cambia req.query por request.nextUrl.searchParams, y usa request.json() en lugar de req.body. Puedes mantener ambos routers funcionando al mismo tiempo durante la migración, así que no hay prisa por migrar todo de golpe.

¿Por qué mi handler GET devuelve datos desactualizados?

En Next.js 15, los GET son dinámicos por defecto, así que en teoría no debería pasar. Si estás en una versión anterior, es posible que tu handler se esté cacheando automáticamente. Comprueba si usas cookies(), headers(), o alguna señal dinámica — Next.js desactiva el caché cuando detecta estas funciones. Si el problema sigue, añade export const dynamic = 'force-dynamic'.

¿Cuál es la diferencia entre NextResponse.json() y Response.json()?

Response.json() es la API web estándar y funciona perfectamente bien. NextResponse.json() la extiende con helpers de Next.js, como métodos para manipular cookies y hacer redirecciones. Si no necesitas esos extras, Response.json() es totalmente válido.

Sobre el Autor Editorial Team

Our team of expert writers and editors.