Desarrollo Backend y de APIs
Servidor vs. Cliente: El modelo mental
Cuando abres un sitio web, hay dos computadoras involucradas: tu navegador (el cliente) y una máquina remota (el servidor). Entender qué se ejecuta dónde es fundamental para construir apps web, incluso cuando la IA escribe la mayoría de tu código.
El cliente (navegador) maneja lo que los usuarios ven e interactúan: renderizar HTML, responder a clics, reproducir animaciones. El JavaScript que se ejecuta en el navegador tiene acceso al DOM, localStorage y la pantalla del usuario — pero no puede acceder a bases de datos, sistemas de archivos ni API keys secretas.
El servidor maneja lo que necesita ser seguro o computacionalmente pesado: leer de bases de datos, procesar pagos, enviar emails, validar datos. El código del servidor tiene acceso a variables de entorno, bases de datos y claves secretas — pero no puede manipular directamente la pantalla del usuario.
Server Components vs. Client Components en Next.js
Next.js hace explícita la división servidor-cliente. Por defecto, los componentes en el App Router son Server Components — se ejecutan en el servidor, pueden acceder a bases de datos directamente, y nunca envían su código al navegador.
Cuando un componente necesita interactividad (clics, estado, efectos), agregas la directiva "use client" al inicio del archivo:
"use client"
import { useState } from "react"
export function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>Cuenta: {count}</button>
}
La regla general: Mantén los componentes en el servidor a menos que necesiten APIs del navegador (useState, useEffect, handlers de onClick). Los Server Components son más rápidos, más seguros y reducen el JavaScript enviado al navegador.
Al darle prompts a la IA, sé explícito: "Este componente necesita ser un Client Component porque usa useState" o "Mantelo como Server Component — solo muestra datos de la base de datos."
Rutas de API en Next.js
Las rutas de API te permiten construir endpoints del servidor dentro de tu app de Next.js. Viven en el directorio app/api/ y manejan peticiones HTTP.
Creando un endpoint
// app/api/tasks/route.ts
import { NextResponse } from "next/server"
export async function GET() {
const tasks = await db.task.findMany()
return NextResponse.json(tasks)
}
export async function POST(request: Request) {
const body = await request.json()
const task = await db.task.create({ data: body })
return NextResponse.json(task, { status: 201 })
}
Cada método HTTP (GET, POST, PUT, DELETE) es un export con nombre. Next.js enruta la petición a la función correspondiente.
Los métodos HTTP se mapean a acciones
| Método | Propósito | Ejemplo | |--------|-----------|---------| | GET | Leer datos | Obtener todas las tareas | | POST | Crear datos | Crear una nueva tarea | | PUT | Actualizar datos | Actualizar una tarea existente | | DELETE | Eliminar datos | Eliminar una tarea |
Códigos de estado que debes conocer
- 200 — OK (GET o PUT exitoso)
- 201 — Created (POST exitoso)
- 400 — Bad Request (entrada inválida del cliente)
- 401 — Unauthorized (no ha iniciado sesión)
- 403 — Forbidden (ha iniciado sesión pero con permisos insuficientes)
- 404 — Not Found (el recurso no existe)
- 500 — Internal Server Error (algo se rompió en el servidor)
Patrón de prompt: "Crea una API de tareas con endpoints GET (listar todas), POST (crear), PUT (actualizar por ID) y DELETE (eliminar por ID). Retorna códigos de estado apropiados para cada uno."
Segmentos de ruta dinámicos
Para endpoints que operan sobre un recurso específico, usa segmentos dinámicos:
// app/api/tasks/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const task = await db.task.findUnique({ where: { id: params.id } })
if (!task) {
return NextResponse.json({ error: "Tarea no encontrada" }, { status: 404 })
}
return NextResponse.json(task)
}
Server Actions
Los Server Actions son la forma moderna de manejar mutaciones en Next.js. En lugar de construir endpoints de API y llamarlos desde el cliente, escribes funciones de servidor que el cliente puede llamar directamente.
Lo básico
// app/actions/tasks.ts
"use server"
import { revalidatePath } from "next/cache"
export async function createTask(formData: FormData) {
const title = formData.get("title") as string
const description = formData.get("description") as string
await db.task.create({
data: { title, description },
})
revalidatePath("/tasks")
}
La directiva "use server" marca estas funciones como exclusivas del servidor. Puedes llamarlas desde Client Components o usarlas directamente en acciones de formulario:
<form action={createTask}>
<input name="title" placeholder="Titulo de la tarea" />
<textarea name="description" placeholder="Descripcion" />
<button type="submit">Crear Tarea</button>
</form>
Este formulario funciona incluso sin JavaScript habilitado en el navegador — eso es progressive enhancement.
Cuándo usar Server Actions vs. API Routes
Server Actions cuando: la acción es disparada por tu propia app (envíos de formulario, clics de botones), quieres progressive enhancement, o quieres código más simple.
API Routes cuando: necesitas un endpoint para consumidores externos (apps móviles, integraciones de terceros), necesitas webhooks, o necesitas control granular sobre los detalles HTTP.
Prompt: "Usa Server Actions para los formularios de creación y actualización de tareas. Usa API routes solo para la API pública que consumirá la app móvil."
Principios de diseño de API REST
REST es un conjunto de convenciones para estructurar endpoints de API. Seguirlas hace que tu API sea predecible y fácil de consumir.
URLs basadas en recursos
Las URLs deben representar recursos (sustantivos), no acciones (verbos):
Bien:
GET /api/tasks → listar tareas
POST /api/tasks → crear una tarea
GET /api/tasks/123 → obtener tarea 123
PUT /api/tasks/123 → actualizar tarea 123
DELETE /api/tasks/123 → eliminar tarea 123
Mal:
GET /api/getTasks
POST /api/createTask
POST /api/deleteTask/123
Formato de respuesta consistente
Elige un formato de respuesta y mantenlo:
{
"data": { "id": "123", "title": "Comprar viveres", "completed": false },
"error": null
}
Para errores:
{
"data": null,
"error": { "code": "VALIDATION_ERROR", "message": "El titulo es requerido" }
}
La consistencia importa porque tu código frontend puede tener una sola función para manejar todas las respuestas de la API.
Validación de peticiones con Zod
Nunca confíes en los datos del cliente. Incluso si tu frontend valida las entradas, alguien podría enviar una petición directamente a tu API con datos basura. La validación del lado del servidor no es negociable.
Por qué Zod
Zod es una librería de validación TypeScript-first que funciona de maravilla con IA. Sus schemas son declarativos y legibles:
import { z } from "zod"
const CreateTaskSchema = z.object({
title: z.string().min(1, "El titulo es requerido").max(200),
description: z.string().optional(),
priority: z.enum(["low", "medium", "high"]).default("medium"),
dueDate: z.coerce.date().optional(),
})
type CreateTaskInput = z.infer<typeof CreateTaskSchema>
Usando Zod en rutas de API
export async function POST(request: Request) {
const body = await request.json()
const result = CreateTaskSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: result.error.flatten() },
{ status: 400 }
)
}
const task = await db.task.create({ data: result.data })
return NextResponse.json({ data: task }, { status: 201 })
}
El método safeParse retorna { success: true, data } o { success: false, error } — nunca lanza excepciones. Esto hace que el manejo de errores sea limpio y predecible.
Patrones comunes de schemas
// Validacion de email
const email = z.string().email("Direccion de email invalida")
// Contrasena con requisitos
const password = z.string()
.min(8, "La contrasena debe tener al menos 8 caracteres")
.regex(/[A-Z]/, "Debe contener una letra mayuscula")
.regex(/[0-9]/, "Debe contener un numero")
// Parametros de paginacion
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
Prompt: "Agrega validación con Zod a todas las rutas de API. Valida los cuerpos de petición para POST/PUT y los parámetros de query para endpoints GET. Retorna errores de validación estructurados."
Patrones de Middleware
El middleware se ejecuta antes de tus handlers de ruta, permitiéndote agregar preocupaciones transversales como autenticación, logging y rate limiting.
Middleware de Next.js
// middleware.ts (en la raiz de tu proyecto)
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export function middleware(request: NextRequest) {
// Verificar autenticacion para rutas protegidas
const token = request.cookies.get("session-token")
if (request.nextUrl.pathname.startsWith("/dashboard") && !token) {
return NextResponse.redirect(new URL("/login", request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ["/dashboard/:path*", "/api/protected/:path*"],
}
La configuración matcher le dice a Next.js a qué rutas aplica este middleware. Esto es más eficiente que ejecutar middleware en cada petición.
Prompt: "Agrega middleware que redirija a usuarios no autenticados a /login para todas las rutas /dashboard. También agrega un middleware de API que verifique un token de sesión válido en todas las rutas /api/protected/."
Manejo de errores bien hecho
Un pobre manejo de errores es el problema más común en el código generado por IA. La IA tiende a usar patrones optimistas que ignoran los casos de fallo. Necesitas ser explícito sobre el manejo de errores en tus prompts.
Try/Catch en rutas de API
export async function POST(request: Request) {
try {
const body = await request.json()
const validated = CreateTaskSchema.parse(body)
const task = await db.task.create({ data: validated })
return NextResponse.json({ data: task }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: { code: "VALIDATION_ERROR", details: error.flatten() } },
{ status: 400 }
)
}
console.error("Error al crear tarea:", error)
return NextResponse.json(
{ error: { code: "INTERNAL_ERROR", message: "Algo salio mal" } },
{ status: 500 }
)
}
}
La regla de oro: Nunca expongas errores internos
El bloque catch registra el error real para depuración pero retorna un mensaje genérico al cliente. Nunca envíes stack traces, errores de base de datos ni detalles internos a los usuarios — son riesgos de seguridad.
Clases de error personalizadas
Para apps más grandes, las clases de error personalizadas hacen el manejo más limpio:
class AppError extends Error {
constructor(
public code: string,
public statusCode: number,
message: string
) {
super(message)
}
}
class NotFoundError extends AppError {
constructor(resource: string) {
super("NOT_FOUND", 404, `${resource} no encontrado`)
}
}
class ValidationError extends AppError {
constructor(message: string) {
super("VALIDATION_ERROR", 400, message)
}
}
Error Boundaries en React
Los error boundaries capturan errores de JavaScript en el árbol de componentes y muestran una UI de respaldo en lugar de que toda la página se rompa:
// app/error.tsx
"use client"
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px]">
<h2 className="text-xl font-semibold mb-4">Algo salio mal</h2>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
Intentar de Nuevo
</button>
</div>
)
}
Prompt: "Agrega manejo de errores completo a todas las rutas de API. Usa try/catch, retorna respuestas de error consistentes, nunca expongas errores internos, y agrega un error.tsx boundary para la app."
Rate Limiting
Sin rate limiting, alguien puede inundar tu API con peticiones, degradando el rendimiento para todos o aumentando tus costos de servidor.
Rate limiter simple en memoria
const rateLimit = new Map<string, { count: number; resetTime: number }>()
function checkRateLimit(ip: string, limit = 100, windowMs = 60000): boolean {
const now = Date.now()
const record = rateLimit.get(ip)
if (!record || now > record.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + windowMs })
return true
}
if (record.count >= limit) return false
record.count++
return true
}
Para producción, usa una librería como rate-limiter-flexible con Redis como almacén. El enfoque en memoria funciona para desarrollo pero no escala entre múltiples instancias del servidor.
Configuración de CORS
CORS (Cross-Origin Resource Sharing) controla qué dominios pueden llamar a tu API. Si tu frontend está en app.example.com y tu API en api.example.com, necesitas CORS.
En Next.js, configuras los headers de CORS en tus handlers de ruta o middleware:
const corsHeaders = {
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
export async function OPTIONS() {
return NextResponse.json({}, { headers: corsHeaders })
}
Si tu frontend y API están en el mismo dominio (que es el caso en la mayoría de apps Next.js), no necesitas preocuparte por CORS en absoluto.
Variables de entorno y secretos
Las variables de entorno almacenan configuración que cambia entre entornos (desarrollo, staging, producción) y secretos que nunca deben estar en tu código.
El archivo .env.local
# .env.local (NUNCA hagas commit de este archivo)
DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"
AUTH_SECRET="valor-super-secreto-cambiar-en-produccion"
STRIPE_SECRET_KEY="sk_test_..."
# Variables del lado del cliente (visibles en el navegador)
NEXT_PUBLIC_APP_URL="http://localhost:3000"
Reglas críticas:
- Agrega
.env.locala.gitignore(Next.js hace esto por defecto) - Solo las variables con prefijo
NEXT_PUBLIC_se exponen al navegador - Nunca pongas claves secretas en variables
NEXT_PUBLIC_ - Usa valores diferentes en desarrollo vs. producción
Accede a ellas en tu código:
// Solo del lado del servidor
const dbUrl = process.env.DATABASE_URL
// Disponible en cliente y servidor
const appUrl = process.env.NEXT_PUBLIC_APP_URL
Prompt: "Configura variables de entorno para la URL de la base de datos, el secreto de auth y las claves de Stripe. Asegúrate de que solo la URL de la app se exponga al cliente."
Juntando todo
Veamos cómo todas estas piezas encajan en un ejemplo real — una API completa de notas:
// app/api/notes/route.ts
import { NextResponse } from "next/server"
import { z } from "zod"
import { db } from "@/lib/db"
import { getSession } from "@/lib/auth"
const CreateNoteSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
isPublic: z.boolean().default(false),
})
export async function GET(request: Request) {
try {
const session = await getSession()
if (!session) {
return NextResponse.json(
{ error: { code: "UNAUTHORIZED", message: "Inicio de sesion requerido" } },
{ status: 401 }
)
}
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get("page") ?? "1")
const limit = parseInt(searchParams.get("limit") ?? "20")
const notes = await db.note.findMany({
where: { userId: session.user.id },
orderBy: { updatedAt: "desc" },
skip: (page - 1) * limit,
take: limit,
})
const total = await db.note.count({ where: { userId: session.user.id } })
return NextResponse.json({
data: notes,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
})
} catch (error) {
console.error("Error al obtener notas:", error)
return NextResponse.json(
{ error: { code: "INTERNAL_ERROR", message: "Algo salio mal" } },
{ status: 500 }
)
}
}
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session) {
return NextResponse.json(
{ error: { code: "UNAUTHORIZED", message: "Inicio de sesion requerido" } },
{ status: 401 }
)
}
const body = await request.json()
const result = CreateNoteSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: { code: "VALIDATION_ERROR", details: result.error.flatten() } },
{ status: 400 }
)
}
const note = await db.note.create({
data: { ...result.data, userId: session.user.id },
})
return NextResponse.json({ data: note }, { status: 201 })
} catch (error) {
console.error("Error al crear nota:", error)
return NextResponse.json(
{ error: { code: "INTERNAL_ERROR", message: "Algo salio mal" } },
{ status: 500 }
)
}
}
Este único archivo demuestra: verificaciones de autenticación, validación con Zod, consultas de Prisma, paginación, manejo de errores consistente y códigos de estado apropiados. Cada patrón que cubrimos en esta lección, trabajando juntos.
Qué sigue
Ahora puedes construir rutas de API, validar datos, manejar errores con gracia y mantener los secretos seguros. Tus apps tienen una base sólida del lado del servidor.
Pero, ¿dónde viven realmente los datos? En la siguiente lección, abordaremos las bases de datos — desde elegir entre SQLite y PostgreSQL, hasta diseñar schemas con Prisma, hasta escribir las consultas que alimentan tu API. Aprenderás cómo describir tu modelo de datos a la IA y obtener una capa de base de datos completa y lista para producción.