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 endpoint
  • page.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 en app/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 datos
  • POST: Crear datos
  • PUT/PATCH: Actualizar datos
  • DELETE: 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