Route Handlers
Los Route Handlers son archivos route.ts
que exportan funciones para manejar métodos HTTP. Con ellos creas APIs REST completas en NextJS.
Métodos HTTP
GET - Obtener datos
// app/api/productos/route.ts
export async function GET() {
const productos = await db.producto.findMany()
return Response.json(productos)
}
POST - Crear datos
// app/api/productos/route.ts
export async function POST(request: Request) {
const body = await request.json()
const producto = await db.producto.create({
data: body
})
return Response.json(producto, { status: 201 })
}
PUT - Actualizar datos
// app/api/productos/[id]/route.ts
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json()
const producto = await db.producto.update({
where: { id: params.id },
data: body
})
return Response.json(producto)
}
DELETE - Eliminar datos
// app/api/productos/[id]/route.ts
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await db.producto.delete({
where: { id: params.id }
})
return Response.json({ message: 'Producto eliminado' })
}
PATCH - Actualización parcial
// app/api/productos/[id]/route.ts
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json()
const producto = await db.producto.update({
where: { id: params.id },
data: body // Solo actualiza los campos enviados
})
return Response.json(producto)
}
Ubicación de archivos
app/
├── api/
│ ├── productos/
│ │ ├── route.ts # /api/productos
│ │ └── [id]/
│ │ └── route.ts # /api/productos/[id]
│ ├── categorias/
│ │ └── route.ts # /api/categorias
│ └── auth/
│ ├── login/
│ │ └── route.ts # /api/auth/login
│ └── register/
│ └── route.ts # /api/auth/register
⚠️
Solo route.ts, no page.tsx
En carpetas de API solo puede haber route.ts
, no page.tsx
. Son mutuamente exclusivos.
route.ts
= API endpointpage.tsx
= Página visible
No puedes tener ambos en la misma ruta.
API completa de productos
// app/api/productos/route.ts
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'
// GET /api/productos - Lista todos
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const categoria = searchParams.get('categoria')
const productos = await db.producto.findMany({
where: categoria ? { categoriaId: categoria } : {},
orderBy: { createdAt: 'desc' }
})
return Response.json(productos)
}
// POST /api/productos - Crea uno nuevo
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Validar datos
if (!body.nombre || !body.precio) {
return Response.json(
{ error: 'Nombre y precio son requeridos' },
{ status: 400 }
)
}
const producto = await db.producto.create({
data: {
nombre: body.nombre,
precio: body.precio,
descripcion: body.descripcion,
categoriaId: body.categoriaId,
}
})
return Response.json(producto, { status: 201 })
} catch (error) {
return Response.json(
{ error: 'Error al crear producto' },
{ status: 500 }
)
}
}
// app/api/productos/[id]/route.ts
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'
type Params = {
params: { id: string }
}
// GET /api/productos/[id] - Obtiene uno
export async function GET(request: NextRequest, { params }: Params) {
const producto = await db.producto.findUnique({
where: { id: params.id }
})
if (!producto) {
return Response.json(
{ error: 'Producto no encontrado' },
{ status: 404 }
)
}
return Response.json(producto)
}
// PUT /api/productos/[id] - Actualiza
export async function PUT(request: NextRequest, { params }: Params) {
try {
const body = await request.json()
const producto = await db.producto.update({
where: { id: params.id },
data: body
})
return Response.json(producto)
} catch (error) {
return Response.json(
{ error: 'Error al actualizar producto' },
{ status: 500 }
)
}
}
// DELETE /api/productos/[id] - Elimina
export async function DELETE(request: NextRequest, { params }: Params) {
try {
await db.producto.delete({
where: { id: params.id }
})
return Response.json({ message: 'Producto eliminado' })
} catch (error) {
return Response.json(
{ error: 'Error al eliminar producto' },
{ status: 500 }
)
}
}
Consumir desde el cliente
'use client'
import { useState, useEffect } from 'react'
export default function ProductosPage() {
const [productos, setProductos] = useState([])
useEffect(() => {
fetch('/api/productos')
.then(res => res.json())
.then(data => setProductos(data))
}, [])
async function agregarProducto(datos) {
const res = await fetch('/api/productos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos)
})
const nuevoProducto = await res.json()
setProductos([...productos, nuevoProducto])
}
return (
<div>
{productos.map(p => (
<div key={p.id}>{p.nombre}</div>
))}
</div>
)
}
Headers y CORS
Headers de respuesta
export async function GET() {
return Response.json(
{ message: 'Hola' },
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0',
},
}
)
}
CORS (Cross-Origin)
export async function GET() {
return Response.json(
{ message: 'Hola' },
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
}
)
}
// Manejar OPTIONS (preflight)
export async function OPTIONS() {
return new Response(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
})
}
Autenticación
Con headers
export async function GET(request: Request) {
const authorization = request.headers.get('Authorization')
if (!authorization) {
return Response.json(
{ error: 'No autorizado' },
{ status: 401 }
)
}
const token = authorization.replace('Bearer ', '')
try {
const user = await verifyToken(token)
// Usuario autenticado, continuar
const productos = await db.producto.findMany()
return Response.json(productos)
} catch (error) {
return Response.json(
{ error: 'Token inválido' },
{ status: 401 }
)
}
}
Con cookies
import { cookies } from 'next/headers'
export async function GET() {
const cookieStore = await cookies()
const token = cookieStore.get('auth-token')
if (!token) {
return Response.json(
{ error: 'No autenticado' },
{ status: 401 }
)
}
// Verificar token...
const productos = await db.producto.findMany()
return Response.json(productos)
}
Subida de archivos
// app/api/upload/route.ts
import { NextRequest } from 'next/server'
import { writeFile } from 'fs/promises'
import path from 'path'
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return Response.json(
{ error: 'No se envió archivo' },
{ status: 400 }
)
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Guardar archivo
const filename = `${Date.now()}-${file.name}`
const filepath = path.join(process.cwd(), 'public/uploads', filename)
await writeFile(filepath, buffer)
return Response.json({
message: 'Archivo subido',
url: `/uploads/${filename}`
})
} catch (error) {
return Response.json(
{ error: 'Error al subir archivo' },
{ status: 500 }
)
}
}
Consumir:
'use client'
export default function UploadForm() {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
const data = await res.json()
console.log(data.url)
}
return (
<form onSubmit={handleSubmit}>
<input type="file" name="file" />
<button type="submit">Subir</button>
</form>
)
}
Streaming responses
Para datos grandes o tiempo real:
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
// Enviar datos progresivamente
controller.enqueue(encoder.encode('data: Mensaje 1\n\n'))
await new Promise(resolve => setTimeout(resolve, 1000))
controller.enqueue(encoder.encode('data: Mensaje 2\n\n'))
await new Promise(resolve => setTimeout(resolve, 1000))
controller.enqueue(encoder.encode('data: Mensaje 3\n\n'))
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}
Webhooks
Para recibir eventos de servicios externos:
// app/api/webhooks/stripe/route.ts
import { NextRequest } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: NextRequest) {
const body = await request.text()
const signature = request.headers.get('stripe-signature')!
try {
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
// Manejar evento
switch (event.type) {
case 'payment_intent.succeeded':
const payment = event.data.object
console.log('Pago exitoso:', payment.id)
// Actualizar base de datos
break
case 'payment_intent.failed':
console.log('Pago fallido')
break
}
return Response.json({ received: true })
} catch (error) {
return Response.json(
{ error: 'Webhook error' },
{ status: 400 }
)
}
}
Caching
Cachear respuestas
export async function GET() {
const productos = await db.producto.findMany()
return Response.json(productos, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
}
})
}
Revalidación
// app/api/productos/route.ts
export const revalidate = 60 // Revalidar cada 60 segundos
export async function GET() {
const productos = await db.producto.findMany()
return Response.json(productos)
}
Forzar dynamic
export const dynamic = 'force-dynamic' // No cachear
export async function GET() {
const productos = await db.producto.findMany()
return Response.json(productos)
}
Middleware para Route Handlers
Crea funciones reutilizables:
// lib/api-middleware.ts
import { NextRequest } from 'next/server'
export function withAuth(handler: Function) {
return async (request: NextRequest, context: any) => {
const authorization = request.headers.get('Authorization')
if (!authorization) {
return Response.json(
{ error: 'No autorizado' },
{ status: 401 }
)
}
// Verificar token
try {
const user = await verifyToken(authorization)
// Agregar user al contexto
context.user = user
return handler(request, context)
} catch (error) {
return Response.json(
{ error: 'Token inválido' },
{ status: 401 }
)
}
}
}
Usar:
// app/api/productos/route.ts
import { withAuth } from '@/lib/api-middleware'
async function handler(request: NextRequest, context: any) {
// context.user está disponible
const productos = await db.producto.findMany({
where: { userId: context.user.id }
})
return Response.json(productos)
}
export const GET = withAuth(handler)
Rate limiting
// lib/rate-limit.ts
const rateLimit = new Map()
export function checkRateLimit(ip: string): boolean {
const now = Date.now()
const windowMs = 60 * 1000 // 1 minuto
const maxRequests = 10
const requests = rateLimit.get(ip) || []
const recentRequests = requests.filter((time: number) => now - time < windowMs)
if (recentRequests.length >= maxRequests) {
return false // Límite excedido
}
recentRequests.push(now)
rateLimit.set(ip, recentRequests)
return true
}
// app/api/productos/route.ts
import { NextRequest } from 'next/server'
import { checkRateLimit } from '@/lib/rate-limit'
export async function GET(request: NextRequest) {
const ip = request.ip || 'unknown'
if (!checkRateLimit(ip)) {
return Response.json(
{ error: 'Demasiadas peticiones' },
{ status: 429 }
)
}
const productos = await db.producto.findMany()
return Response.json(productos)
}
Validación con Zod
import { NextRequest } from 'next/server'
import { z } from 'zod'
const ProductoSchema = z.object({
nombre: z.string().min(3).max(100),
precio: z.number().positive(),
descripcion: z.string().optional(),
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Validar
const validData = ProductoSchema.parse(body)
// Crear producto
const producto = await db.producto.create({
data: validData
})
return Response.json(producto, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json(
{ error: 'Datos inválidos', details: error.errors },
{ status: 400 }
)
}
return Response.json(
{ error: 'Error del servidor' },
{ status: 500 }
)
}
}
Aprende más sobre Zod: Lee nuestra guía completa de Zod para dominar la validación de datos.
Mejores prácticas
1. Siempre valida los datos
// ❌ Mal: Sin validación
export async function POST(request: Request) {
const body = await request.json()
await db.producto.create({ data: body })
}
// ✅ Bien: Con validación
export async function POST(request: Request) {
const body = await request.json()
const validData = ProductoSchema.parse(body)
await db.producto.create({ data: validData })
}
2. Maneja errores apropiadamente
export async function GET() {
try {
const productos = await db.producto.findMany()
return Response.json(productos)
} catch (error) {
console.error('Error:', error)
return Response.json(
{ error: 'Error al obtener productos' },
{ status: 500 }
)
}
}
3. Usa códigos de estado HTTP correctos
// 200: OK
return Response.json(data, { status: 200 })
// 201: Created
return Response.json(data, { status: 201 })
// 400: Bad Request
return Response.json({ error: 'Datos inválidos' }, { status: 400 })
// 401: Unauthorized
return Response.json({ error: 'No autorizado' }, { status: 401 })
// 404: Not Found
return Response.json({ error: 'No encontrado' }, { status: 404 })
// 500: Internal Server Error
return Response.json({ error: 'Error del servidor' }, { status: 500 })
4. Documenta tu API
/**
* GET /api/productos
*
* Obtiene lista de productos
*
* Query params:
* - categoria: string (opcional)
* - limit: number (opcional, default: 10)
*
* Respuesta: Array de productos
*/
export async function GET(request: NextRequest) {
// ...
}
5. Considera Server Actions en vez de APIs
// ❌ Innecesario: API solo para tu app
// app/api/productos/route.ts
export async function POST(request: Request) {
const body = await request.json()
return Response.json(await createProducto(body))
}
// ✅ Mejor: Server Action
// app/actions.ts
'use server'
export async function createProducto(data: any) {
return await db.producto.create({ data })
}
Solo usa Route Handlers cuando:
- API pública (otros servicios consumen)
- Webhooks
- Necesitas control total del HTTP response
Cuándo usar Route Handlers
✅ Usa Route Handlers para:
- APIs públicas que otros servicios consumen
- Webhooks (Stripe, GitHub, etc)
- Proxy a APIs externas
- Subida de archivos
- Streaming de datos
- OAuth callbacks
❌ NO uses Route Handlers para:
- Mutaciones solo para tu app (usa Server Actions)
- Formularios simples (usa Server Actions)
- Datos solo para tu frontend (usa Server Components)
💡
Server Actions vs Route Handlers
Server Actions son más simples para la mayoría de casos:
- Integración directa con formularios
- Validación con Zod más fácil
- Tipos de TypeScript automáticos
- Revalidación de cache integrada
Route Handlers son mejores cuando necesitas:
- API REST completa (GET, POST, PUT, DELETE)
- Control total del response (headers, status)
- APIs públicas o webhooks
Resumen
Route Handlers:
- Archivos
route.ts
enapp/api/
- Soportan GET, POST, PUT, DELETE, PATCH
Response.json()
para respuestas JSON- Acceso a headers, cookies, search params
- Validación con Zod recomendada
Métodos principales:
GET
: Obtener datosPOST
: Crear datosPUT/PATCH
: Actualizar datosDELETE
: Eliminar datos
Mejores prácticas:
- Valida siempre los datos de entrada
- Usa códigos HTTP correctos
- Maneja errores apropiadamente
- Considera Server Actions para casos simples