Saltar al contenido
Lección 11 de 22

Desarrollo Backend y de APIs

13 min read

Servidor vs. Cliente: El modelo mental

Cuando abres un sitio web, hay dos computadoras involucradas: tu navegador (el cliente) y una maquina remota (el servidor). Entender que se ejecuta donde es fundamental para construir apps web, incluso cuando la IA escribe la mayoria de tu codigo.

El cliente (navegador) maneja lo que los usuarios ven e interactuan: 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 codigo 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 explicita la division 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 envian su codigo 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: Manten los componentes en el servidor a menos que necesiten APIs del navegador (useState, useEffect, handlers de onClick). Los Server Components son mas rapidos, mas seguros y reducen el JavaScript enviado al navegador.

Al darle prompts a la IA, se explicito: "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 metodo HTTP (GET, POST, PUT, DELETE) es un export con nombre. Next.js enruta la peticion a la funcion correspondiente.

Los metodos HTTP se mapean a acciones

| Metodo | Proposito | 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 |

Codigos de estado que debes conocer

  • 200 — OK (GET o PUT exitoso)
  • 201 — Created (POST exitoso)
  • 400 — Bad Request (entrada invalida del cliente)
  • 401 — Unauthorized (no ha iniciado sesion)
  • 403 — Forbidden (ha iniciado sesion pero con permisos insuficientes)
  • 404 — Not Found (el recurso no existe)
  • 500 — Internal Server Error (algo se rompio en el servidor)

Patron de prompt: "Crea una API de tareas con endpoints GET (listar todas), POST (crear), PUT (actualizar por ID) y DELETE (eliminar por ID). Retorna codigos de estado apropiados para cada uno."

Segmentos de ruta dinamicos

Para endpoints que operan sobre un recurso especifico, usa segmentos dinamicos:

// 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 basico

// 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.

Cuando usar Server Actions vs. API Routes

Server Actions cuando: la accion es disparada por tu propia app (envios de formulario, clics de botones), quieres progressive enhancement, o quieres codigo mas simple.

API Routes cuando: necesitas un endpoint para consumidores externos (apps moviles, integraciones de terceros), necesitas webhooks, o necesitas control granular sobre los detalles HTTP.

Prompt: "Usa Server Actions para los formularios de creacion y actualizacion de tareas. Usa API routes solo para la API publica que consumira la app movil."

Principios de diseno de API REST

REST es un conjunto de convenciones para estructurar endpoints de API. Seguirlas hace que tu API sea predecible y facil 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 codigo frontend puede tener una sola funcion para manejar todas las respuestas de la API.

Validacion de peticiones con Zod

Nunca confies en los datos del cliente. Incluso si tu frontend valida las entradas, alguien podria enviar una peticion directamente a tu API con datos basura. La validacion del lado del servidor no es negociable.

Por que Zod

Zod es una libreria de validacion 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 metodo 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 validacion con Zod a todas las rutas de API. Valida los cuerpos de peticion para POST/PUT y los parametros de query para endpoints GET. Retorna errores de validacion estructurados."

Patrones de Middleware

El middleware se ejecuta antes de tus handlers de ruta, permitiendote agregar preocupaciones transversales como autenticacion, 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 configuracion matcher le dice a Next.js a que rutas aplica este middleware. Esto es mas eficiente que ejecutar middleware en cada peticion.

Prompt: "Agrega middleware que redirija a usuarios no autenticados a /login para todas las rutas /dashboard. Tambien agrega un middleware de API que verifique un token de sesion valido en todas las rutas /api/protected/."

Manejo de errores bien hecho

Un pobre manejo de errores es el problema mas comun en el codigo generado por IA. La IA tiende a usar patrones optimistas que ignoran los casos de fallo. Necesitas ser explicito 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 depuracion pero retorna un mensaje generico al cliente. Nunca envies stack traces, errores de base de datos ni detalles internos a los usuarios — son riesgos de seguridad.

Clases de error personalizadas

Para apps mas grandes, las clases de error personalizadas hacen el manejo mas 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 arbol de componentes y muestran una UI de respaldo en lugar de que toda la pagina 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 produccion, usa una libreria como rate-limiter-flexible con Redis como almacen. El enfoque en memoria funciona para desarrollo pero no escala entre multiples instancias del servidor.

Configuracion de CORS

CORS (Cross-Origin Resource Sharing) controla que dominios pueden llamar a tu API. Si tu frontend esta 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 estan en el mismo dominio (que es el caso en la mayoria de apps Next.js), no necesitas preocuparte por CORS en absoluto.

Variables de entorno y secretos

Las variables de entorno almacenan configuracion que cambia entre entornos (desarrollo, staging, produccion) y secretos que nunca deben estar en tu codigo.

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 criticas:

  1. Agrega .env.local a .gitignore (Next.js hace esto por defecto)
  2. Solo las variables con prefijo NEXT_PUBLIC_ se exponen al navegador
  3. Nunca pongas claves secretas en variables NEXT_PUBLIC_
  4. Usa valores diferentes en desarrollo vs. produccion

Accede a ellas en tu codigo:

// 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. Asegurate de que solo la URL de la app se exponga al cliente."

Juntando todo

Veamos como 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 unico archivo demuestra: verificaciones de autenticacion, validacion con Zod, consultas de Prisma, paginacion, manejo de errores consistente y codigos de estado apropiados. Cada patron que cubrimos en esta leccion, trabajando juntos.

Que sigue

Ahora puedes construir rutas de API, validar datos, manejar errores con gracia y mantener los secretos seguros. Tus apps tienen una base solida del lado del servidor.

Pero, donde viven realmente los datos? En la siguiente leccion, abordaremos las bases de datos — desde elegir entre SQLite y PostgreSQL, hasta disenar schemas con Prisma, hasta escribir las consultas que alimentan tu API. Aprenderas como describir tu modelo de datos a la IA y obtener una capa de base de datos completa y lista para produccion.