<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>IA al Código</title>
    <description>Artículos sobre IA, desarrollo web y herramientas para desarrolladores</description>
    <link>https://francobosg.netlify.app/blog/</link>
    <atom:link href="https://francobosg.netlify.app/blog/rss.xml" rel="self" type="application/rss+xml"/>
    <language>es</language>
    <lastBuildDate>Wed, 29 Apr 2026 00:00:00 GMT</lastBuildDate>
    <ttl>1440</ttl>
    <image>
      <url>https://francobosg.netlify.app/favicon/android-chrome-512x512.png</url>
      <title>IA al Código</title>
      <link>https://francobosg.netlify.app/blog/</link>
    </image>
    <managingEditor>contacto@francobosg.netlify.app (Fran Cobos)</managingEditor>
    <item>
      <title><![CDATA[Crear un Bot de Telegram con IA en Node.js (Tutorial Completo 2026)]]></title>
      <link>https://francobosg.netlify.app/blog/bot-telegram-ia-nodejs-2026/</link>
      <description><![CDATA[Crea un bot de Telegram con inteligencia artificial usando Node.js, la API de OpenAI o Claude y grammy. Respuestas automáticas, comandos, memoria de conversación y deploy gratuito.]]></description>
      <pubDate>Wed, 29 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/bot-telegram-ia-nodejs-2026/</guid>
      <category>Telegram</category>
      <category>Bot</category>
      <category>IA</category>
      <category>Node.js</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Los bots de Telegram son sorprendentemente útiles: asistente personal, bot de soporte para tu producto, automatizaciones... Y con IA integrada son mucho más potentes que los bots de comandos de antes.

En este tutorial construyes un bot completo desde cero en menos de una hora.

## Lo que vas a construir

- Bot de Telegram que responde a mensajes con IA (OpenAI o Claude)
- Memoria de conversación por usuario (recuerda el contexto)
- Comandos: `/start`, `/clear` (limpiar historial), `/help`
- Límite de mensajes para no arruinarte con la API
- Deploy gratuito en Railway

## Requisitos previos

- Node.js 18+ instalado
- Cuenta en Telegram
- API Key de OpenAI o Anthropic (o DeepSeek que es más barata)

---

## Paso 1: Crear el bot en Telegram

Abre Telegram y habla con **@BotFather**:

```
/newbot
```

BotFather te pedirá:
1. Un nombre para el bot (ej: "Mi Asistente IA")
2. Un username (debe terminar en `bot`, ej: `mi_asistente_ia_bot`)

Te dará un **token** como este: `7291834756:AAFxyz123...`

Guárdalo, es tu clave para controlar el bot.

---

## Paso 2: Configurar el proyecto

```bash
mkdir telegram-bot-ia
cd telegram-bot-ia
npm init -y
npm install grammy openai dotenv
```

**grammy** es la mejor librería para bots de Telegram en 2026 (más moderno que node-telegram-bot-api).

Crea la estructura:

```
telegram-bot-ia/
├── src/
│   ├── bot.js
│   ├── ai.js
│   └── memoria.js
├── .env
├── .env.example
└── package.json
```

Crea el `.env`:

```bash
# .env
TELEGRAM_TOKEN=7291834756:AAFxyz123...
OPENAI_API_KEY=sk-...

# Opcional: limitar uso
MAX_MENSAJES_POR_USUARIO=50
MAX_TOKENS_RESPUESTA=500
```

Crea el `.env.example` (para el repo, sin secrets):

```bash
TELEGRAM_TOKEN=tu_token_aqui
OPENAI_API_KEY=tu_api_key_aqui
```

Añade al `.gitignore`:

```
node_modules/
.env
```

---

## Paso 3: El módulo de IA

```javascript
// src/ai.js
import OpenAI from 'openai'

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY
})

const SISTEMA_PROMPT = `Eres un asistente útil y conciso. 
Respondes siempre en español.
Tus respuestas son directas y al punto.
Si no sabes algo, lo dices honestamente.`

/**
 * @param {Array<{role: string, content: string}>} historial
 * @returns {Promise<string>}
 */
export async function preguntarIA(historial) {
  const mensajes = [
    { role: 'system', content: SISTEMA_PROMPT },
    ...historial
  ]

  const respuesta = await openai.chat.completions.create({
    model: 'gpt-4o-mini',  // barato y rápido
    messages: mensajes,
    max_tokens: parseInt(process.env.MAX_TOKENS_RESPUESTA || '500'),
    temperature: 0.7,
  })

  return respuesta.choices[0].message.content
}
```

Si prefieres usar Claude (Anthropic):

```javascript
// src/ai.js (versión Claude)
import Anthropic from '@anthropic-ai/sdk'

const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY
})

export async function preguntarIA(historial) {
  const mensajes = historial.map(m => ({
    role: m.role === 'user' ? 'user' : 'assistant',
    content: m.content
  }))

  const respuesta = await client.messages.create({
    model: 'claude-haiku-4-5',  // el más barato de Claude
    max_tokens: parseInt(process.env.MAX_TOKENS_RESPUESTA || '500'),
    system: SISTEMA_PROMPT,
    messages: mensajes,
  })

  return respuesta.content[0].text
}
```

O DeepSeek (compatible con la API de OpenAI):

```javascript
// src/ai.js (versión DeepSeek — más barata)
import OpenAI from 'openai'

const openai = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY,
  baseURL: 'https://api.deepseek.com'
})

export async function preguntarIA(historial) {
  const mensajes = [
    { role: 'system', content: SISTEMA_PROMPT },
    ...historial
  ]

  const respuesta = await openai.chat.completions.create({
    model: 'deepseek-chat',
    messages: mensajes,
    max_tokens: 500,
  })

  return respuesta.choices[0].message.content
}
```

---

## Paso 4: La memoria de conversación

```javascript
// src/memoria.js

// Almacena el historial por usuario en memoria RAM
// Se pierde al reiniciar, pero es suficiente para empezar
const historiales = new Map()

const MAX_MENSAJES = 20 // Mantener solo los últimos 20 mensajes por usuario

/**
 * Obtiene el historial de un usuario
 * @param {number} userId
 * @returns {Array<{role: string, content: string}>}
 */
export function obtenerHistorial(userId) {
  if (!historiales.has(userId)) {
    historiales.set(userId, [])
  }
  return historiales.get(userId)
}

/**
 * Añade un mensaje al historial
 * @param {number} userId 
 * @param {'user' | 'assistant'} rol 
 * @param {string} contenido 
 */
export function añadirMensaje(userId, rol, contenido) {
  const historial = obtenerHistorial(userId)
  historial.push({ role: rol, content: contenido })
  
  // Mantener solo los últimos N mensajes (ventana deslizante)
  if (historial.length > MAX_MENSAJES) {
    historial.splice(0, historial.length - MAX_MENSAJES)
  }
}

/**
 * Limpia el historial de un usuario
 * @param {number} userId
 */
export function limpiarHistorial(userId) {
  historiales.set(userId, [])
}

/**
 * Cuenta los mensajes del usuario en las últimas 24h
 * Para limitar uso — versión simple en memoria
 */
const contadoresDiarios = new Map()

export function puedeEnviarMensaje(userId) {
  const max = parseInt(process.env.MAX_MENSAJES_POR_USUARIO || '50')
  const ahora = Date.now()
  const dia = 24 * 60 * 60 * 1000
  
  if (!contadoresDiarios.has(userId)) {
    contadoresDiarios.set(userId, { count: 0, resetAt: ahora + dia })
  }
  
  const contador = contadoresDiarios.get(userId)
  
  // Resetear si pasó un día
  if (ahora > contador.resetAt) {
    contador.count = 0
    contador.resetAt = ahora + dia
  }
  
  if (contador.count >= max) return false
  
  contador.count++
  return true
}
```

---

## Paso 5: El bot principal

```javascript
// src/bot.js
import 'dotenv/config'
import { Bot } from 'grammy'
import { preguntarIA } from './ai.js'
import { obtenerHistorial, añadirMensaje, limpiarHistorial, puedeEnviarMensaje } from './memoria.js'

const bot = new Bot(process.env.TELEGRAM_TOKEN)

// /start — Mensaje de bienvenida
bot.command('start', async (ctx) => {
  const nombre = ctx.from?.first_name || 'amigo'
  await ctx.reply(
    `¡Hola, ${nombre}! 👋\n\n` +
    `Soy un asistente con IA. Escríbeme cualquier cosa y te respondo.\n\n` +
    `Comandos disponibles:\n` +
    `/clear — Borrar historial de conversación\n` +
    `/help — Ver esta ayuda`
  )
})

// /help — Ayuda
bot.command('help', async (ctx) => {
  await ctx.reply(
    `*Cómo usarme:*\n\n` +
    `Escríbeme cualquier mensaje y te respondo con IA.\n` +
    `Recuerdo el contexto de nuestra conversación.\n\n` +
    `*Comandos:*\n` +
    `/clear — Borra nuestra conversación y empezamos de cero\n` +
    `/start — Mensaje de bienvenida`,
    { parse_mode: 'Markdown' }
  )
})

// /clear — Limpiar historial
bot.command('clear', async (ctx) => {
  limpiarHistorial(ctx.from.id)
  await ctx.reply('Historial borrado. ¡Empecemos de cero! 🧹')
})

// Mensajes de texto — la lógica principal
bot.on('message:text', async (ctx) => {
  const userId = ctx.from.id
  const textoUsuario = ctx.message.text

  // Ignorar comandos que no manejamos
  if (textoUsuario.startsWith('/')) return

  // Verificar límite de mensajes
  if (!puedeEnviarMensaje(userId)) {
    await ctx.reply(
      '⚠️ Has alcanzado el límite diario de mensajes. Vuelve mañana.'
    )
    return
  }

  // Mostrar "escribiendo..." mientras procesa
  await ctx.replyWithChatAction('typing')

  try {
    // Añadir mensaje del usuario al historial
    añadirMensaje(userId, 'user', textoUsuario)
    
    // Obtener historial completo y preguntar a la IA
    const historial = obtenerHistorial(userId)
    const respuesta = await preguntarIA(historial)
    
    // Guardar respuesta en historial
    añadirMensaje(userId, 'assistant', respuesta)
    
    // Enviar respuesta
    await ctx.reply(respuesta, { parse_mode: 'Markdown' })
    
  } catch (error) {
    console.error('Error al llamar a la IA:', error)
    
    // Quitar el último mensaje del historial si falló
    const historial = obtenerHistorial(userId)
    historial.pop()
    
    await ctx.reply(
      '❌ Hubo un error al procesar tu mensaje. Inténtalo de nuevo.'
    )
  }
})

// Manejar errores del bot
bot.catch((err) => {
  console.error('Error en el bot:', err)
})

// Arrancar el bot
bot.start()
console.log('🤖 Bot iniciado correctamente')
```

Añade el `type: module` al `package.json`:

```json
{
  "name": "telegram-bot-ia",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/bot.js",
    "dev": "node --watch src/bot.js"
  },
  "dependencies": {
    "dotenv": "^16.0.0",
    "grammy": "^1.30.0",
    "openai": "^4.0.0"
  }
}
```

---

## Paso 6: Probarlo localmente

```bash
node src/bot.js
# 🤖 Bot iniciado correctamente
```

Abre Telegram, busca tu bot por el username y escríbele. Debería responder con IA.

---

## Paso 7: Añadir funcionalidades extra

### Comandos personalizados

```javascript
// Añade esto en bot.js — ejemplo: /resume que resume texto
bot.command('resume', async (ctx) => {
  const texto = ctx.message.text.replace('/resume', '').trim()
  
  if (!texto) {
    await ctx.reply('Uso: /resume [texto a resumir]')
    return
  }
  
  await ctx.replyWithChatAction('typing')
  
  // Usar la IA con un prompt específico (sin historial)
  const respuesta = await preguntarIA([
    { role: 'user', content: `Resume esto en 3 puntos clave: ${texto}` }
  ])
  
  await ctx.reply(`📝 *Resumen:*\n\n${respuesta}`, { parse_mode: 'Markdown' })
})
```

### Responder a imágenes (Vision)

```javascript
bot.on('message:photo', async (ctx) => {
  await ctx.replyWithChatAction('typing')
  
  // Obtener la foto en mayor resolución
  const foto = ctx.message.photo.at(-1)
  const file = await ctx.api.getFile(foto.file_id)
  const fotoUrl = `https://api.telegram.org/file/bot${process.env.TELEGRAM_TOKEN}/${file.file_path}`
  
  const caption = ctx.message.caption || '¿Qué hay en esta imagen?'
  
  const respuesta = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [{
      role: 'user',
      content: [
        { type: 'text', text: caption },
        { type: 'image_url', image_url: { url: fotoUrl } }
      ]
    }],
    max_tokens: 500
  })
  
  await ctx.reply(respuesta.choices[0].message.content)
})
```

---

## Paso 8: Deploy gratuito en Railway

1. Sube el código a GitHub (sin el `.env`)

2. Ve a [railway.app](https://railway.app) y crea una cuenta

3. Crea un nuevo proyecto → "Deploy from GitHub repo"

4. Selecciona tu repositorio

5. En **Variables**, añade:
   - `TELEGRAM_TOKEN` = tu token
   - `OPENAI_API_KEY` = tu API key

6. Railway detecta automáticamente Node.js y ejecuta `npm start`

Tu bot estará online 24/7 de forma gratuita (Railway da $5/mes gratis, suficiente para un bot pequeño).

Alternativas gratuitas:
- **Render**: plan gratuito, se duerme tras 15min de inactividad (el bot no nota esto porque usa polling)
- **Fly.io**: 3 máquinas gratuitas para siempre
- **Hetzner VPS**: €4/mes, total control, no hay gratis pero es lo más barato de pago

---

## Resumen del código final

```
telegram-bot-ia/
├── src/
│   ├── bot.js        ← Lógica del bot y comandos
│   ├── ai.js         ← Llamadas a OpenAI/Claude/DeepSeek
│   └── memoria.js    ← Historial por usuario + límites
├── .env              ← Secrets (no subir a git)
├── .env.example      ← Template para el repo
├── .gitignore
└── package.json
```

Con menos de 200 líneas de código tienes un bot de Telegram con IA, memoria de conversación y límites de uso. A partir de aquí puedes añadir:

- Base de datos con SQLite para persistir el historial
- Pagos con Stripe para cobrar por el acceso
- Webhooks en vez de polling para mayor eficiencia
- Comandos adicionales según tu caso de uso

El código completo está pensado para ser el punto de partida, no el destino final.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Next.js 15: Todas las Novedades que Cambian tu Forma de Programar]]></title>
      <link>https://francobosg.netlify.app/blog/nextjs-15-novedades-guia-completa-2026/</link>
      <description><![CDATA[Guía completa de Next.js 15: React 19, caché por defecto desactivado, turbopack estable, after(), mejoras en Server Actions y todo lo que necesitas saber para migrar.]]></description>
      <pubDate>Wed, 29 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/nextjs-15-novedades-guia-completa-2026/</guid>
      <category>Next.js</category>
      <category>React</category>
      <category>Frontend</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Next.js 15 salió con cambios que rompen muchas apps y otros que hacen la vida mucho mejor. Voy a explicar cada uno con código real para que sepas exactamente qué te afecta.

## Los cambios más importantes de un vistazo

1. **Caché desactivado por defecto** — el cambio que más rompe cosas
2. **React 19 integrado** — `use()`, Server Actions mejorados, optimistic updates
3. **Turbopack estable en desarrollo** — 76% más rápido en dev
4. **`after()`** — ejecutar código después de enviar la respuesta
5. **`forbidden()` y `unauthorized()`** — manejo nativo de 401/403
6. **`instrumentation.js` estable** — observabilidad en el servidor
7. **Mejoras en Forms** — `<Form>` component nativo

---

## 1. El caché ya no está activo por defecto

Este es el cambio que más confunde a la gente que migra desde v14.

### ¿Cómo era en v14?

```typescript
// Next.js 14 — esto se CACHEABA por defecto (force-cache)
const res = await fetch('https://api.example.com/productos')
const data = await res.json()
// ⚠️ Solo hacía la petición UNA VEZ y devolvía el resultado cacheado el resto
```

### ¿Cómo es en v15?

```typescript
// Next.js 15 — NO se cachea por defecto (no-store)
const res = await fetch('https://api.example.com/productos')
const data = await res.json()
// ✅ Hace la petición CADA VEZ que se renderiza el componente
```

Ahora el comportamiento por defecto es el que esperas de cualquier `fetch` normal. **Si quieres caché, tienes que pedirla explícitamente:**

```typescript
// Caché estático: igual que v14 por defecto
const res = await fetch('https://api.example.com/productos', {
  cache: 'force-cache'
})

// Revalidar cada hora
const res = await fetch('https://api.example.com/productos', {
  next: { revalidate: 3600 }
})

// Sin caché (ahora el default)
const res = await fetch('https://api.example.com/productos', {
  cache: 'no-store'
})
```

### Route Handlers también cambian

```typescript
// v14 — GET Route Handler se CACHEABA
// v15 — GET Route Handler NO se cachea por defecto

// app/api/productos/route.ts
export async function GET() {
  const productos = await db.query('SELECT * FROM productos')
  return Response.json(productos)
  // En v15: siempre fresh. En v14: podía quedar stale.
}

// Para recuperar el comportamiento de v14 en v15:
export const dynamic = 'force-static'
export async function GET() { ... }
```

### Client Router Cache

```typescript
// v14 — los layouts se cacheaban 5 minutos en el cliente
// v15 — el Client Router Cache no cachea por defecto

// Recuperar comportamiento v14 en next.config.ts:
const config = {
  experimental: {
    staleTimes: {
      dynamic: 30,  // segundos
      static: 180,
    }
  }
}
```

---

## 2. React 19 — Las novedades que más usarás

### El hook `use()` — leer Promises y Context

```tsx
// Antes (React 18) — suspense con async component
async function Productos() {
  const productos = await fetchProductos() // Solo funciona en Server Components
  return <Lista items={productos} />
}

// React 19 — use() funciona en Client Components también
import { use } from 'react'

function Productos({ productosPromise }: { productosPromise: Promise<Producto[]> }) {
  const productos = use(productosPromise) // Suspende automáticamente
  return <Lista items={productos} />
}

// Uso:
const promesa = fetchProductos()
<Suspense fallback={<Skeleton />}>
  <Productos productosPromise={promesa} />
</Suspense>
```

También funciona con Context (reemplaza `useContext`):

```tsx
const tema = use(TemaContext) // equivalente a useContext(TemaContext) pero más flexible
```

### Server Actions mejorados con `useActionState`

```tsx
// React 19 + Next.js 15
import { useActionState } from 'react'

async function crearProducto(estado: Estado, formData: FormData) {
  'use server'
  
  const nombre = formData.get('nombre') as string
  
  try {
    await db.insert({ nombre })
    return { exito: true, mensaje: 'Producto creado' }
  } catch (e) {
    return { exito: false, error: 'Error al crear' }
  }
}

function FormularioProducto() {
  const [estado, accion, isPending] = useActionState(crearProducto, null)
  
  return (
    <form action={accion}>
      <input name="nombre" type="text" required />
      <button disabled={isPending}>
        {isPending ? 'Guardando...' : 'Crear'}
      </button>
      {estado?.error && <p className="text-red-500">{estado.error}</p>}
      {estado?.exito && <p className="text-green-500">{estado.mensaje}</p>}
    </form>
  )
}
```

### `useOptimistic` — updates optimistas limpios

```tsx
import { useOptimistic } from 'react'

function ListaTareas({ tareas }: { tareas: Tarea[] }) {
  const [tareasOptimistas, addTareaOptimista] = useOptimistic(
    tareas,
    (estado, nuevaTarea: Tarea) => [...estado, nuevaTarea]
  )

  async function añadirTarea(formData: FormData) {
    const texto = formData.get('texto') as string
    const tareaTemp = { id: Date.now(), texto, completada: false }
    
    addTareaOptimista(tareaTemp) // UI actualiza inmediatamente
    await crearTarea(texto)     // Luego hace la llamada real
  }

  return (
    <div>
      {tareasOptimistas.map(t => <TareaItem key={t.id} tarea={t} />)}
      <form action={añadirTarea}>
        <input name="texto" />
        <button>Añadir</button>
      </form>
    </div>
  )
}
```

---

## 3. Turbopack estable en desarrollo

```bash
# Next.js 15 — activar Turbopack en desarrollo
next dev --turbopack
```

O en `package.json`:

```json
{
  "scripts": {
    "dev": "next dev --turbopack"
  }
}
```

Los números que publica Vercel en proyectos grandes:
- **Arranque inicial del servidor**: 76% más rápido
- **Hot Module Replacement (HMR)**: hasta 96% más rápido
- **Compilación de rutas**: significativamente más rápido

Para proyectos medianos/grandes, el cambio en experiencia de desarrollo es muy notable. Para proyectos pequeños la diferencia es menos perceptible.

**Compatibilidad:** La mayoría de loaders de webpack tienen equivalente en Turbopack, pero algunos plugins personalizados aún no son compatibles. Revisa tu `next.config.ts` antes de activarlo.

---

## 4. `after()` — Código después de la respuesta

Una función muy útil que no existía antes de forma oficial:

```typescript
import { after } from 'next/server'

export async function GET() {
  const datos = await fetchDatos()
  
  // Envía la respuesta al usuario
  const respuesta = Response.json(datos)
  
  // Este código se ejecuta DESPUÉS de enviar la respuesta
  // El usuario ya tiene su respuesta, no espera esto
  after(async () => {
    await analytics.track('api_called')
    await cache.actualizar('datos')
    await logger.guardar({ timestamp: new Date(), ruta: '/api/datos' })
  })
  
  return respuesta
}
```

Perfecto para:
- Analytics y logging sin penalizar la latencia
- Invalidar cachés
- Enviar notificaciones
- Tareas de mantenimiento

Funciona en Route Handlers, Server Components y Server Actions.

---

## 5. `forbidden()` y `unauthorized()` — Errores 401/403 nativos

```typescript
import { forbidden, unauthorized } from 'next/navigation'

// En un Server Component o Route Handler
export default async function PaginaAdmin() {
  const sesion = await getSession()
  
  if (!sesion) {
    unauthorized() // Lanza un 401, renderiza unauthorized.tsx
  }
  
  if (sesion.rol !== 'admin') {
    forbidden() // Lanza un 403, renderiza forbidden.tsx
  }
  
  return <AdminPanel />
}
```

Crea los archivos de error en `app/`:

```
app/
  unauthorized.tsx   ← se renderiza con 401
  forbidden.tsx      ← se renderiza con 403
  not-found.tsx      ← 404 (existía antes)
```

```tsx
// app/unauthorized.tsx
export default function NoAutorizado() {
  return (
    <div>
      <h1>Inicia sesión para continuar</h1>
      <a href="/login">Iniciar sesión</a>
    </div>
  )
}
```

---

## 6. El componente `<Form>` de Next.js

Next.js 15 añade un componente `<Form>` que extiende el `<form>` de HTML:

```tsx
import Form from 'next/form'

function BuscadorProductos() {
  return (
    <Form action="/buscar">
      <input name="q" placeholder="Buscar..." />
      <button type="submit">Buscar</button>
    </Form>
  )
}
```

Lo que hace diferente:
- **Prefetch automático** de la página `/buscar` cuando el formulario es visible
- **Navegación del lado cliente** (sin recarga) usando el App Router
- **Loading UI** automático (usa `loading.tsx`)
- **URL actualizada** con los params del form para poder compartir el resultado

---

## Cómo migrar de Next.js 14 a 15

### Usa el codemod automático

```bash
npx @next/codemod@canary upgrade latest
```

Esto actualiza las dependencias y aplica los cambios de API automáticamente.

### Cambios manuales que el codemod no hace

**1. Revisar los fetch con caché implícita:**

```bash
# Busca en tu proyecto todos los fetch sin cache explícito
grep -rn "await fetch(" src/ --include="*.tsx" --include="*.ts"
```

Para cada fetch en un Server Component, decide si quieres caché o no y añádelo explícitamente.

**2. `cookies()`, `headers()`, `params` ahora son async:**

```typescript
// v14
import { cookies } from 'next/headers'
const cookieStore = cookies()
const token = cookieStore.get('token')

// v15
import { cookies } from 'next/headers'
const cookieStore = await cookies()  // ← await obligatorio
const token = cookieStore.get('token')
```

El codemod lo migra automáticamente, pero si tienes abstracciones propias encima, revísalas.

**3. `params` en Page components ahora es una Promise:**

```typescript
// v14
export default function Pagina({ params }: { params: { slug: string } }) {
  const { slug } = params
}

// v15
export default async function Pagina({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params  // ← await
}
```

---

## Resumen de lo que afecta a tu código del día a día

| Feature | Impacto en migración | Lo que tienes que hacer |
|---|---|---|
| Caché desactivado | **Alto** | Revisar todos los fetch y añadir `cache` explícito |
| `cookies()` async | **Alto** | El codemod lo migra, revisar abstracciones |
| `params` async | **Medio** | El codemod lo migra |
| React 19 | **Bajo** | Sin cambios breaking para la mayoría |
| Turbopack | **Bajo** | Opt-in, añadir `--turbopack` en dev |
| `after()` | **Ninguno** | Nueva API, úsala cuando quieras |
| `forbidden()`/`unauthorized()` | **Ninguno** | Reemplaza tu manejo manual de errores |

Para proyectos nuevos, Next.js 15 con React 19 es la combinación a usar en 2026. Para migrar proyectos existentes: ejecuta el codemod, revisa los fetch y reserva 2-4 horas para los casos edge.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Python para Desarrolladores JavaScript: Guía Definitiva 2026]]></title>
      <link>https://francobosg.netlify.app/blog/python-para-javascript-developers-2026/</link>
      <description><![CDATA[Aprende Python si ya sabes JavaScript. Comparativa directa de sintaxis, async, módulos, tipado y ecosistema. Con ejemplos paralelos JS vs Python para aprender 2x más rápido.]]></description>
      <pubDate>Wed, 29 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/python-para-javascript-developers-2026/</guid>
      <category>Python</category>
      <category>JavaScript</category>
      <category>Tutorial</category>
      <category>Backend</category>
      <content:encoded><![CDATA[Sabes JavaScript. Quieres aprender Python. Buenas noticias: **ya tienes el 60% aprendido**. La lógica de programación, async/await, los módulos, los arrays y objetos... todo eso lo vas a reutilizar. Solo cambia la sintaxis y el ecosistema.

Esta guía va directa al grano: JS a la izquierda, Python a la derecha. Aprende por comparación.

## Por qué aprender Python si ya sabes JavaScript

En 2026, Python es **el lenguaje de la IA**. Todos los modelos, librerías y tutoriales de machine learning usan Python por defecto. Si quieres:

- Llamar a APIs de IA de forma avanzada (fine-tuning, embeddings, RAG)
- Trabajar con datos (pandas, numpy)
- Automatizar tareas con scripts potentes
- Construir agentes de IA (LangChain, LlamaIndex)
- Acceder al mercado laboral data/ML

...Python es imprescindible. Y si ya sabes JS, aprenderlo es más fácil que para la mayoría.

## Sintaxis básica: comparativa directa

### Variables y tipos

```javascript
// JavaScript
const nombre = "Ana"
let edad = 28
const activo = true
const precio = 9.99

// Tipos con TypeScript
const nombre: string = "Ana"
const edad: number = 28
```

```python
# Python
nombre = "Ana"
edad = 28
activo = True
precio = 9.99

# Tipos con type hints (opcional pero recomendado)
nombre: str = "Ana"
edad: int = 28
```

Diferencias clave:
- Sin `const`, `let`, `var`. Solo asignación directa
- `True`/`False` con mayúscula (no `true`/`false`)
- Sin punto y coma al final
- La indentación **es obligatoria** y define los bloques (sin llaves `{}`)

### Strings

```javascript
// JavaScript
const saludo = `Hola, ${nombre}! Tienes ${edad} años`
const upper = nombre.toUpperCase()
const partes = "hola mundo".split(" ")
const sinEspacios = "  hola  ".trim()
```

```python
# Python
saludo = f"Hola, {nombre}! Tienes {edad} años"  # f-string
upper = nombre.upper()
partes = "hola mundo".split(" ")
sin_espacios = "  hola  ".strip()
```

Los f-strings de Python son exactamente como los template literals de JS. La sintaxis de métodos es casi idéntica.

### Condicionales

```javascript
// JavaScript
if (edad >= 18) {
  console.log("Mayor de edad")
} else if (edad >= 16) {
  console.log("Casi")
} else {
  console.log("Menor")
}

// Ternario
const estado = edad >= 18 ? "adulto" : "menor"
```

```python
# Python
if edad >= 18:
    print("Mayor de edad")
elif edad >= 16:
    print("Casi")
else:
    print("Menor")

# Ternario
estado = "adulto" if edad >= 18 else "menor"
```

- `else if` → `elif`
- Sin llaves, con `:` al final y sangría
- El ternario Python es al revés: `valor_si_true if condicion else valor_si_false`

### Bucles

```javascript
// JavaScript
for (let i = 0; i < 5; i++) {
  console.log(i)
}

// forEach
const nums = [1, 2, 3, 4, 5]
nums.forEach(n => console.log(n))

// for...of
for (const n of nums) {
  console.log(n)
}

// while
let i = 0
while (i < 5) {
  console.log(i)
  i++
}
```

```python
# Python
for i in range(5):
    print(i)

# Iterar lista (equivale a for...of)
nums = [1, 2, 3, 4, 5]
for n in nums:
    print(n)

# enumerate si necesitas el índice
for i, n in enumerate(nums):
    print(f"{i}: {n}")

# while
i = 0
while i < 5:
    print(i)
    i += 1
```

Python no tiene `for` de 3 partes. Usa `range(n)` para contar, `for x in lista` para iterar.

## Arrays → Listas. Objetos → Diccionarios

### Arrays / Listas

```javascript
// JavaScript
const frutas = ["manzana", "pera", "uva"]
frutas.push("naranja")
frutas.pop()
const primera = frutas[0]
const longitud = frutas.length
const sinPrimera = frutas.slice(1)

// map, filter, reduce
const mayus = frutas.map(f => f.toUpperCase())
const largas = frutas.filter(f => f.length > 4)
const total = [1, 2, 3].reduce((acc, n) => acc + n, 0)
```

```python
# Python
frutas = ["manzana", "pera", "uva"]
frutas.append("naranja")   # push → append
frutas.pop()               # igual
primera = frutas[0]
longitud = len(frutas)     # .length → len()
sin_primera = frutas[1:]   # slicing nativo

# List comprehensions (más idiomático que map/filter)
mayus = [f.upper() for f in frutas]
largas = [f for f in frutas if len(f) > 4]
total = sum([1, 2, 3])     # reduce para sumas → sum()

# map y filter existen pero se usan menos
mayus2 = list(map(lambda f: f.upper(), frutas))
largas2 = list(filter(lambda f: len(f) > 4, frutas))
```

Las **list comprehensions** son la forma más pythonica de hacer map/filter. Apréndetelas:

```python
# [expresion for variable in iterable if condicion]
cuadrados_pares = [x**2 for x in range(10) if x % 2 == 0]
# → [0, 4, 16, 36, 64]
```

### Objetos / Diccionarios

```javascript
// JavaScript
const usuario = {
  nombre: "Ana",
  edad: 28,
  activo: true
}

// Acceso
console.log(usuario.nombre)
console.log(usuario["nombre"])

// Destructuring
const { nombre, edad } = usuario

// Spread
const nuevo = { ...usuario, rol: "admin" }

// Verificar si existe una key
if ("nombre" in usuario) { ... }
if (usuario.hasOwnProperty("nombre")) { ... }
```

```python
# Python
usuario = {
    "nombre": "Ana",
    "edad": 28,
    "activo": True
}

# Acceso
print(usuario["nombre"])    # siempre con corchetes
print(usuario.get("nombre"))  # sin KeyError si no existe

# "Destructuring" — no existe igual, pero hay desempaquetado
nombre, edad = usuario["nombre"], usuario["edad"]

# Merge (Python 3.9+)
nuevo = {**usuario, "rol": "admin"}

# Verificar si existe una key
if "nombre" in usuario: ...
```

En Python las keys de los dicts van siempre con comillas. No hay notación de punto para acceder a valores (eso es para atributos de clases/objetos).

## Funciones

```javascript
// JavaScript
function sumar(a, b) {
  return a + b
}

// Arrow function
const multiplicar = (a, b) => a * b

// Default params
function saludar(nombre = "Mundo") {
  return `Hola, ${nombre}!`
}

// Rest params
function suma(...nums) {
  return nums.reduce((a, b) => a + b, 0)
}

// Objeto como parámetro (named params)
function crear({ nombre, edad = 25 }) {
  return { nombre, edad }
}
```

```python
# Python
def sumar(a, b):
    return a + b

# Lambda (como arrow function simple)
multiplicar = lambda a, b: a * b

# Default params
def saludar(nombre="Mundo"):
    return f"Hola, {nombre}!"

# Args variables (*args equivale a rest)
def suma(*nums):
    return sum(nums)

# Keyword arguments (named params)
def crear(nombre, edad=25):
    return {"nombre": nombre, "edad": edad}

crear(nombre="Ana", edad=30)  # llamada con kwargs
```

## Clases

```javascript
// JavaScript
class Persona {
  constructor(nombre, edad) {
    this.nombre = nombre
    this.edad = edad
  }

  saludar() {
    return `Hola, soy ${this.nombre}`
  }

  static crear(nombre, edad) {
    return new Persona(nombre, edad)
  }
}

class Empleado extends Persona {
  constructor(nombre, edad, empresa) {
    super(nombre, edad)
    this.empresa = empresa
  }
}
```

```python
# Python
class Persona:
    def __init__(self, nombre: str, edad: int):
        self.nombre = nombre
        self.edad = edad

    def saludar(self) -> str:
        return f"Hola, soy {self.nombre}"

    @staticmethod
    def crear(nombre: str, edad: int) -> "Persona":
        return Persona(nombre, edad)

class Empleado(Persona):
    def __init__(self, nombre: str, edad: int, empresa: str):
        super().__init__(nombre, edad)
        self.empresa = empresa
```

- `constructor` → `__init__`
- `this.` → `self.` (y `self` va siempre como primer parámetro)
- `extends` → el nombre de la clase entre paréntesis
- `static` → decorador `@staticmethod`

## Async/Await

En JavaScript, async/await es para Promises. En Python es para corrutinas. La sintaxis es **casi idéntica**:

```javascript
// JavaScript
async function obtenerUsuario(id) {
  try {
    const response = await fetch(`/api/users/${id}`)
    const data = await response.json()
    return data
  } catch (error) {
    console.error("Error:", error)
    throw error
  }
}

// Promise.all
const [user, posts] = await Promise.all([
  obtenerUsuario(1),
  obtenerPosts(1)
])
```

```python
# Python (con httpx o aiohttp en vez de fetch)
import httpx

async def obtener_usuario(id: int):
    try:
        async with httpx.AsyncClient() as client:
            response = await client.get(f"/api/users/{id}")
            data = response.json()
            return data
    except Exception as error:
        print(f"Error: {error}")
        raise

# asyncio.gather equivale a Promise.all
import asyncio
user, posts = await asyncio.gather(
    obtener_usuario(1),
    obtener_posts(1)
)
```

Para ejecutar código async en Python necesitas `asyncio.run()` o un framework async (FastAPI, Starlette):

```python
import asyncio

async def main():
    resultado = await obtener_usuario(1)
    print(resultado)

asyncio.run(main())
```

## Módulos

```javascript
// JavaScript (ES Modules)
// math.js
export const PI = 3.14159
export function sumar(a, b) { return a + b }
export default function multiplicar(a, b) { return a * b }

// main.js
import multiplicar, { PI, sumar } from './math.js'
import * as math from './math.js'
```

```python
# Python
# math_utils.py
PI = 3.14159
def sumar(a, b): return a + b
def multiplicar(a, b): return a * b

# main.py
from math_utils import PI, sumar, multiplicar
import math_utils                           # como import * as
from math_utils import multiplicar as mult  # alias
```

En Python **no hay `export`**. Todo lo definido en un módulo es importable por defecto.

## Gestión de paquetes: npm → pip + venv

```bash
# JavaScript
npm init
npm install express
npm install -D eslint
cat package.json

# Python equivalente
python -m venv venv          # crear entorno virtual (como node_modules aislado)
venv\Scripts\activate        # Windows
source venv/bin/activate     # Mac/Linux
pip install fastapi
pip install --dev ruff       # no hay -D exacto, usar grupos en pyproject.toml
cat requirements.txt         # o pyproject.toml
```

**Importante:** Siempre usa `venv` (entorno virtual). Es el equivalente a `node_modules` pero tienes que activarlo manualmente. Sin él, instalas paquetes globalmente (malo).

```bash
# Guardar dependencias (como package.json)
pip freeze > requirements.txt

# Instalar desde requirements.txt (como npm install)
pip install -r requirements.txt
```

Para proyectos modernos, usa **`uv`** en vez de pip — es el equivalente a npm para Python, mucho más rápido:

```bash
pip install uv
uv init mi-proyecto
uv add fastapi httpx
uv run python main.py
```

## El ecosistema Python para devs JS

| Necesidad | JavaScript | Python |
|---|---|---|
| API REST | Express, Fastify, Hono | **FastAPI**, Flask, Django |
| ORM | Prisma, Drizzle | **SQLAlchemy**, Tortoise, Peewee |
| Validación | Zod | **Pydantic** |
| Testing | Jest, Vitest | **pytest** |
| Linting | ESLint | **Ruff** |
| Formatter | Prettier | **Black**, Ruff |
| Build tool | Vite, esbuild | **uv**, Poetry |
| IA/ML | — | **PyTorch, LangChain, HuggingFace** |
| Data | — | **pandas, numpy, polars** |

## Tu primer API con FastAPI (el Express de Python)

```python
# main.py
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

# Modelo (como un schema de Zod)
class Usuario(BaseModel):
    nombre: str
    edad: int

usuarios = []

@app.get("/usuarios")
async def listar():
    return usuarios

@app.post("/usuarios")
async def crear(usuario: Usuario):
    usuarios.append(usuario)
    return {"mensaje": "creado", "usuario": usuario}

@app.get("/usuarios/{id}")
async def obtener(id: int):
    if id >= len(usuarios):
        return {"error": "no encontrado"}, 404
    return usuarios[id]
```

```bash
pip install fastapi uvicorn
uvicorn main:app --reload
# API corriendo en http://localhost:8000
# Docs automáticas en http://localhost:8000/docs ← ¡Esto no tiene Express!
```

FastAPI genera Swagger UI automáticamente. Sin instalar nada extra.

## Resumen rápido de diferencias

| Concepto | JavaScript | Python |
|---|---|---|
| Bloques | `{ }` | Indentación |
| Booleanos | `true`/`false` | `True`/`False` |
| Nulo | `null`/`undefined` | `None` |
| Array | `Array` | `list` |
| Objeto | `Object` | `dict` |
| String length | `.length` | `len()` |
| Console | `console.log()` | `print()` |
| Módulos | `import/export` | `import` (sin export) |
| Clases | `constructor`, `this` | `__init__`, `self` |
| Herencia | `extends` | `class Hijo(Padre)` |
| try/catch | `catch (e)` | `except Exception as e` |
| Ternario | `cond ? a : b` | `a if cond else b` |

Con esto tienes todo lo que necesitas para empezar. Pon en práctica con un script pequeño o construye una API con FastAPI — en 3 días ya no estarás consultando esta guía.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[React Native vs Flutter vs Expo 2026: Cuál Elegir para tu App Móvil]]></title>
      <link>https://francobosg.netlify.app/blog/react-native-vs-flutter-expo-2026/</link>
      <description><![CDATA[Comparativa honesta entre React Native, Flutter y Expo en 2026. Rendimiento, curva de aprendizaje, ecosistema, mercado laboral y para qué sirve cada uno. Con casos reales.]]></description>
      <pubDate>Wed, 29 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/react-native-vs-flutter-expo-2026/</guid>
      <category>React Native</category>
      <category>Flutter</category>
      <category>Mobile</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Quieres hacer una app móvil. Tienes tres opciones principales y nadie te da una respuesta directa porque "depende". Voy a dártela con contexto real.

## El resumen ejecutivo (para los que no quieren leer todo)

| | React Native + Expo | Flutter |
|---|---|---|
| **Lenguaje** | JavaScript / TypeScript | Dart |
| **Si ya sabes** | React → elección obvia | Dart o quieres máximo rendimiento |
| **Rendimiento** | Muy bueno (New Architecture) | Excelente (motor propio Skia/Impeller) |
| **Ecosistema** | Enorme (npm) | Creciendo rápido |
| **Mercado laboral** | ★★★★★ | ★★★☆☆ |
| **Curva aprendizaje** | Baja si sabes React | Media (aprender Dart) |
| **Build/Deploy** | EAS Build (fácil) | Manual (más complejo) |
| **Casos de uso ideales** | Startups, apps MVP, web+móvil | Apps con UI muy custom, fintech, Google |

**Mi recomendación directa:**
- **Sabes React/JS → Expo (React Native)**
- **Quieres rendimiento máximo con UI totalmente custom → Flutter**
- **Empresa con equipo mixto JS/móvil → Expo**

---

## React Native en 2026: dónde está

### La New Architecture (ya es el estándar)

React Native durante años tuvo un problema: el "bridge" entre JavaScript y el código nativo era lento. En 2024-2026 se completó la New Architecture, que lo soluciona:

- **JSI (JavaScript Interface)**: comunicación síncrona entre JS y nativo, sin serializar a JSON
- **Fabric**: nuevo sistema de rendering (antes era asíncrono)
- **TurboModules**: módulos nativos cargados lazy, mucho más rápidos

Resultado: la brecha de rendimiento con Flutter se redujo enormemente. Las apps con New Architecture habilitada son prácticamente indistinguibles en velocidad.

Para activarla en un proyecto Expo:

```json
// app.json
{
  "expo": {
    "newArchEnabled": true
  }
}
```

### Expo en 2026: ya no es opcional

Antes, Expo era "la versión limitada" de React Native. Ya no es así. Con **Expo SDK 52+**:

- **Expo Go**: prueba tu app en el móvil sin compilar (escaneas QR)
- **EAS Build**: compila tu app en la nube, sin necesitar un Mac para iOS
- **EAS Update**: actualizaciones over-the-air sin pasar por el App Store
- **Expo Router**: routing basado en ficheros (como Next.js, pero para móvil)
- **Expo Modules API**: crea módulos nativos custom con TypeScript + Kotlin/Swift

Crear un proyecto en 2026:

```bash
npx create-expo-app@latest mi-app
cd mi-app
npx expo start
```

Abres Expo Go en el móvil, escaneas el QR y ya estás viendo tu app. Sin Android Studio ni Xcode configurados.

### El ecosistema en 2026

Todo lo que usas en web tiene equivalente:

```bash
# Navegación
npx expo install expo-router

# UI Components
npm install react-native-paper      # Material Design
npm install nativewind              # Tailwind para RN
npm install @shopify/restyle        # Design system

# Gestos y animaciones
npx expo install react-native-reanimated react-native-gesture-handler

# Estado
npm install zustand @tanstack/react-query

# Storage
npx expo install expo-secure-store expo-sqlite @react-native-async-storage/async-storage

# Cámara, localización, notificaciones
npx expo install expo-camera expo-location expo-notifications
```

---

## Flutter en 2026: dónde está

### Dart: el elefante en la habitación

Dart es el mayor obstáculo para adoptar Flutter si vienes de JS. Pero tiene algunas ventajas:

- **Compilado a código nativo ARM**: no hay intérprete JS en medio
- **Type system sólido**: null safety obligatorio desde Dart 3
- **Código predecible**: sin "this" extraño, sin prototype chain

Un widget Flutter básico:

```dart
import 'package:flutter/material.dart';

class MiApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('Mi App')),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('Hola Mundo', style: TextStyle(fontSize: 24)),
              SizedBox(height: 16),
              ElevatedButton(
                onPressed: () => print('Pulsado'),
                child: Text('Púlsame'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```

Comparado con React Native:

```jsx
// React Native equivalente
import { View, Text, Button } from 'react-native'

export default function MiApp() {
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text style={{ fontSize: 24 }}>Hola Mundo</Text>
      <View style={{ height: 16 }} />
      <Button title="Púlsame" onPress={() => console.log('Pulsado')} />
    </View>
  )
}
```

### El motor de render de Flutter: la gran diferencia

Flutter tiene su propio motor gráfico (Skia, ahora Impeller). Esto significa:

**Ventaja:** La UI se ve **exactamente igual** en iOS y Android. No depende de los componentes nativos del sistema.

**Desventaja:** Las animaciones no siempre se sienten 100% nativas (el scroll de iOS tiene su física característica, por ejemplo).

Para apps donde el diseño es totalmente personalizado (fintech, apps de juegos, productos muy branded), Flutter es una ventaja: control total del pixel.

### Gestión de estado en Flutter

La curva de aprendizaje de Flutter tiene mucho que ver con la gestión de estado. Opciones:

```dart
// 1. Riverpod (el más recomendado en 2026)
final contadorProvider = StateProvider<int>((ref) => 0);

// 2. Bloc (empresas grandes, más verboso)
// 3. Provider (el clásico, más simple)
// 4. GetX (popular pero controvertido)
```

En React Native esto es Zustand, Jotai o Context. Más opciones, pero las JS ya las conoces.

---

## Comparativa técnica real

### Rendimiento

```
Animaciones complejas:  Flutter > RN New Arch ≈ RN Old Arch (mucho peor)
Startup time:           Flutter ≈ RN New Arch > RN Old Arch
Scroll performance:     Flutter ≈ RN New Arch
Tamaño del bundle:      Flutter (mayor, ~8MB mínimo) > RN (menor)
Hot reload:             Ambos tienen, Flutter más rápido en proyectos grandes
```

Con la New Architecture activa, React Native está muy cerca de Flutter en rendimiento real para el 95% de apps.

### Tiempo de desarrollo

**Primer MVP:**
- Expo (si sabes React): 2-3 días para un CRUD básico
- Flutter (si no sabes Dart): 1-2 semanas para el mismo CRUD

**App compleja con autenticación, BD, notificaciones:**
- Expo: 2-4 semanas
- Flutter: 3-5 semanas (si el equipo ya sabe Dart), 6-8 semanas si es nuevo

### Recursos de aprendizaje

- **React Native / Expo**: la doc de Expo es excelente. Documentación en español abundante.
- **Flutter**: la doc oficial de Flutter es de las mejores de cualquier framework. Muy completa.

### Mercado laboral

Ofertas en Infojobs/LinkedIn España (búsqueda aproximada 2026):
- React Native: ~350 ofertas activas
- Flutter: ~120 ofertas activas
- Ambas: claramente por detrás de iOS (Swift) y Android (Kotlin) nativo

Si tu objetivo es empleabilidad, React Native tiene más mercado. Pero Flutter está creciendo.

---

## Casos de uso donde cada uno gana

### Elige React Native + Expo si:

- **Ya sabes React**: reutilizas componentes, hooks, el mental model
- **Tienes también web**: puedes compartir lógica de negocio, stores, tipos TS
- **Necesitas ir rápido**: EAS Build, Expo Go, actualizaciones OTA
- **El equipo es frontend JS**: no hay que aprender Dart
- **Necesitas librerías JS**: acceso a todo npm

```tsx
// Puedes reutilizar esto en web y móvil con React Native Web
export function useUsuario(id: string) {
  return useQuery({
    queryKey: ['usuario', id],
    queryFn: () => api.getUsuario(id)
  })
}
```

### Elige Flutter si:

- **UI totalmente custom**: juegos, apps muy branded, fintech con animaciones complejas
- **Máximo rendimiento nativo**: apps con muchas animaciones a 60/120fps
- **Equipo con experiencia Dart/Android**: la curva de Dart no es problema
- **Apps para múltiples plataformas incluido desktop**: Flutter soporta iOS, Android, Web, Windows, macOS, Linux desde un solo codebase
- **Empresa que usa Firebase/GCP**: integración natural con el ecosistema Google

---

## Código real: misma feature en ambos

### Lista con búsqueda — React Native (Expo)

```tsx
import { useState } from 'react'
import { View, TextInput, FlatList, Text, StyleSheet } from 'react-native'

const datos = ['Manzana', 'Plátano', 'Naranja', 'Uva', 'Melocotón']

export default function ListaBusqueda() {
  const [busqueda, setBusqueda] = useState('')
  
  const filtrados = datos.filter(item =>
    item.toLowerCase().includes(busqueda.toLowerCase())
  )

  return (
    <View style={styles.contenedor}>
      <TextInput
        style={styles.input}
        placeholder="Buscar..."
        value={busqueda}
        onChangeText={setBusqueda}
      />
      <FlatList
        data={filtrados}
        keyExtractor={(item) => item}
        renderItem={({ item }) => (
          <Text style={styles.item}>{item}</Text>
        )}
      />
    </View>
  )
}

const styles = StyleSheet.create({
  contenedor: { flex: 1, padding: 16 },
  input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 8, marginBottom: 12 },
  item: { padding: 12, borderBottomWidth: 1, borderBottomColor: '#eee' },
})
```

### Lista con búsqueda — Flutter

```dart
import 'package:flutter/material.dart';

class ListaBusqueda extends StatefulWidget {
  @override
  _ListaBusquedaState createState() => _ListaBusquedaState();
}

class _ListaBusquedaState extends State<ListaBusqueda> {
  final List<String> datos = ['Manzana', 'Plátano', 'Naranja', 'Uva', 'Melocotón'];
  String busqueda = '';

  @override
  Widget build(BuildContext context) {
    final filtrados = datos
        .where((item) => item.toLowerCase().contains(busqueda.toLowerCase()))
        .toList();

    return Padding(
      padding: EdgeInsets.all(16),
      child: Column(
        children: [
          TextField(
            decoration: InputDecoration(
              hintText: 'Buscar...',
              border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
            ),
            onChanged: (value) => setState(() => busqueda = value),
          ),
          SizedBox(height: 12),
          Expanded(
            child: ListView.separated(
              itemCount: filtrados.length,
              separatorBuilder: (_, __) => Divider(),
              itemBuilder: (context, i) => Padding(
                padding: EdgeInsets.symmetric(vertical: 8),
                child: Text(filtrados[i]),
              ),
            ),
          ),
        ],
      ),
    );
  }
}
```

---

## Conclusión con número de palabras cero

Si eres developer JavaScript y quieres hacer una app móvil en 2026, **empieza con Expo**. Tendrás tu primera app corriendo en el móvil en menos de una hora.

Si en el futuro necesitas un rendimiento específico que Expo no cubre, o si trabajas en una empresa que usa Flutter, aprendes Dart en 2-3 semanas y ya. No son excluyentes.

Lo que no tiene ningún sentido es no hacer la app porque no eliges el framework "correcto". Ambos llevan apps a producción con millones de usuarios.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Tailwind CSS v4: Guía Completa y Migración desde v3 (2026)]]></title>
      <link>https://francobosg.netlify.app/blog/tailwind-css-v4-guia-migracion-2026/</link>
      <description><![CDATA[Todo lo que cambia en Tailwind CSS v4. Cómo migrar desde v3, las nuevas clases, la configuración con CSS nativo y por qué es el mayor cambio en la historia de Tailwind.]]></description>
      <pubDate>Wed, 29 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/tailwind-css-v4-guia-migracion-2026/</guid>
      <category>CSS</category>
      <category>Tailwind</category>
      <category>Frontend</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Tailwind CSS v4 no es una actualización más. Es una reescritura completa con un nuevo motor, nueva sintaxis de configuración y un cambio de filosofía: **Tailwind se convierte en un motor CSS nativo**, sin Node.js en runtime.

Si llevas proyectos con v3 o estás empezando uno nuevo, esto te afecta. Vamos al grano.

## ¿Qué cambia de verdad en Tailwind v4?

### 1. El motor Oxide — builds 100x más rápidos

El mayor cambio técnico. El nuevo motor está escrito en **Rust** usando Lightning CSS por debajo, en vez de PostCSS.

Benchmarks reales:

| Proyecto | v3 build | v4 build |
|---|---|---|
| Proyecto mediano (500 clases) | 1.2s | 50ms |
| Proyecto grande (3000+ clases) | 4.8s | 120ms |
| HMR (cambio en desarrollo) | 200ms | 5ms |

En dev, el cambio es brutal. HMR casi instantáneo.

### 2. Adiós a `tailwind.config.js` (en proyectos nuevos)

En v3, toda la configuración iba en `tailwind.config.js`:

```js
// v3 — tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: '#6366f1',
      },
      fontFamily: {
        sans: ['Inter', 'sans-serif'],
      }
    }
  }
}
```

En v4, la configuración va **en tu CSS** con `@theme`:

```css
/* v4 — styles.css */
@import "tailwindcss";

@theme {
  --color-brand: #6366f1;
  --font-family-sans: 'Inter', sans-serif;
  --spacing-128: 32rem;
}
```

Ventaja: tus tokens de diseño son **variables CSS nativas**. Puedes usarlos en cualquier parte, no solo en clases de Tailwind.

### 3. Variables CSS de primera clase

Todo en v4 genera variables CSS automáticamente. El color `brand` que defines en `@theme` está disponible como:
- Clase Tailwind: `bg-brand`, `text-brand`, `border-brand`
- Variable CSS: `var(--color-brand)` en cualquier `style` inline o CSS custom

```html
<!-- Usando la clase -->
<button class="bg-brand text-white">Botón</button>

<!-- Usando la variable directamente -->
<div style="border-left: 3px solid var(--color-brand)">...</div>
```

### 4. Clases que cambian o desaparecen

Las diferencias más importantes entre v3 y v4:

| v3 | v4 | Nota |
|---|---|---|
| `shadow-sm` | `shadow-xs` | Renombrado |
| `shadow` | `shadow-sm` | Renombrado |
| `rounded` | `rounded-sm` | Renombrado |
| `blur` | `blur-sm` | Renombrado |
| `ring` | `ring-3` | Ahora explícito |
| `basis-1/2` | igual | Sin cambio |
| `bg-opacity-50` | `bg-black/50` | Nueva sintaxis |
| `text-opacity-75` | `text-black/75` | Nueva sintaxis |

La regla de los `*-sm` / `*-xs`: en v4, la escala de tamaños se ajusta. Lo que antes era el valor "default" (sin sufijo) ahora tiene nombre explícito.

### 5. Variantes de nueva sintaxis

```html
<!-- v3 -->
<div class="hover:bg-blue-500 focus:ring-2 dark:bg-gray-900">

<!-- v4 — igual, pero con nuevas variantes añadidas -->
<div class="hover:bg-blue-500 starting:opacity-0 in-[.modal]:block **:text-sm">
```

Nuevas variantes en v4:
- `starting:` — para animaciones de entrada (antes necesitabas JS)
- `in-[.selector]:` — aplica estilo cuando el elemento está dentro de ese selector
- `**:` — aplica a todos los descendientes (como `*` pero recursivo)
- `not-[.active]:` — cuando NO tiene esa clase

## Cómo instalar Tailwind v4

### En proyecto nuevo con Vite

```bash
npm create vite@latest mi-proyecto -- --template vanilla
cd mi-proyecto
npm install -D tailwindcss @tailwindcss/vite
```

En `vite.config.js`:

```js
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    tailwindcss(),
  ],
})
```

En tu `src/style.css`:

```css
@import "tailwindcss";

/* Tu configuración aquí */
@theme {
  --color-brand: #6366f1;
}
```

**Sin `tailwind.config.js`. Sin `content: []`. Sin PostCSS.** Tailwind v4 detecta las clases automáticamente.

### En proyecto Next.js

```bash
npm install -D tailwindcss @tailwindcss/postcss
```

En `postcss.config.mjs`:

```js
const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};
export default config;
```

### En proyecto Astro

```bash
npx astro add tailwind
# Selecciona v4 cuando pregunte
```

O manualmente:

```bash
npm install -D @tailwindcss/vite
```

Y en `astro.config.mjs`:

```js
import tailwind from "@tailwindcss/vite";

export default defineConfig({
  vite: {
    plugins: [tailwind()],
  },
});
```

## Cómo migrar desde v3: paso a paso

### Opción 1: Migración automática (recomendada)

```bash
npx @tailwindcss/upgrade
```

Este comando:
1. Actualiza las dependencias en `package.json`
2. Migra `tailwind.config.js` a `@theme` en tu CSS
3. Renombra las clases que cambiaron (`shadow` → `shadow-sm`, etc.)
4. Convierte `bg-opacity-50` a `bg-black/50`, etc.

Cubre ~80-90% de los casos. Revisa el diff después.

### Opción 2: Migración manual

**Paso 1 — Actualizar dependencias:**

```bash
npm install -D tailwindcss@next @tailwindcss/vite
npm uninstall autoprefixer
```

**Paso 2 — Borrar `tailwind.config.js` y mover la config a CSS:**

```css
/* Antes (tailwind.config.js) */
/* theme.extend.colors.brand = '#6366f1' */

/* Ahora en globals.css */
@import "tailwindcss";

@theme {
  --color-brand: #6366f1;
  --color-brand-dark: #4f46e5;
}
```

**Paso 3 — Actualizar `vite.config.js` o `postcss.config.js`:**

Elimina los plugins de PostCSS para Tailwind y usa el plugin de Vite en su lugar.

**Paso 4 — Renombrar clases con búsqueda/reemplazo:**

Las más comunes:

```
shadow → shadow-sm
shadow-sm → shadow-xs
rounded → rounded-sm
blur → blur-sm
ring → ring-3
```

Usa este regex en VS Code para encontrarlos: `\b(shadow|rounded|blur)\b(?!-)` 

**Paso 5 — Migrar opacidades:**

```
bg-{color}-{shade}/opacity → bg-{color}-{shade}/{opacity}
bg-black bg-opacity-50 → bg-black/50
text-white text-opacity-75 → text-white/75
```

### Problemas comunes en la migración

**Error: `Cannot find module 'tailwindcss/plugin'`**

En v4 los plugins se importan diferente:

```js
// v3
const plugin = require('tailwindcss/plugin')

// v4
import plugin from 'tailwindcss/plugin'
// o en CSS:
// @plugin "./mi-plugin.js"
```

**Las clases de componentes de terceros se rompen**

Si usas Flowbite, DaisyUI, Shadcn, etc., pueden necesitar actualización. Comprueba la compatibilidad con v4 en sus docs.

**El dark mode no funciona igual**

En v3 podías usar `darkMode: 'class'` en config. En v4:

```css
@import "tailwindcss";

/* Activar dark mode por clase */
@variant dark (&:where(.dark, .dark *));
```

## Las funcionalidades que más me gustan de v4

### `@utility` — crea clases personalizadas reales

```css
@utility container {
  max-width: 1280px;
  margin-inline: auto;
  padding-inline: 1.5rem;
}
```

Ahora `container` es una clase de Tailwind real, con variantes responsivas: `sm:container`, `md:container`...

### `starting:` para animaciones sin JavaScript

```html
<dialog class="
  opacity-0 scale-95
  starting:opacity-0 starting:scale-95
  open:opacity-100 open:scale-100
  transition-all duration-200
">
```

Animas el estado inicial de un dialog o popover sin escribir ni una línea de JS.

### Gradientes dinámicos con variables

```html
<div class="bg-gradient-to-r from-[var(--color-brand)] to-purple-600">
  Gradiente dinámico
</div>
```

## ¿Vale la pena migrar ahora?

**Migra ahora si:**
- Es un proyecto nuevo — usa v4 directamente
- Tu build tarda más de 2s — la ganancia de rendimiento es enorme
- Quieres CSS moderno sin PostCSS
- Usas muchas animaciones — `starting:` y `in-out:` son un game changer

**Espera si:**
- Usas muchos plugins de terceros que aún no son compatibles con v4
- Tu proyecto tiene cientos de archivos y no puedes dedicar tiempo a revisar el diff
- Dependes de Tailwind UI (la versión v4 está en roadmap)

**Conclusión:** Para proyectos nuevos, v4 es la elección obvia. Para migrar proyectos existentes, usa `npx @tailwindcss/upgrade` y reserva 2-4 horas para revisar los casos que no migra automáticamente.

La ganancia de rendimiento y la config en CSS nativo hacen que valga la pena.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Portfolio Profesional a Medida: Caso Real con Next.js 16 y Framer Motion]]></title>
      <link>https://francobosg.netlify.app/blog/portfolio-profesional-a-medida-desarrollador-2026/</link>
      <description><![CDATA[Hago portfolios a medida para cualquier tipo de profesional. Caso real: portfolio para Laura Cobos, joyera artesanal. Next.js 16, carrusel infinito sin librerías, hover swap y 95/100 en Lighthouse.]]></description>
      <pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/portfolio-profesional-a-medida-desarrollador-2026/</guid>
      <category>Código</category>
      <category>Caso Real</category>
      <content:encoded><![CDATA[No solo hago SaaS o aplicaciones de empresa. También hago **portfolios a medida** para todo tipo de profesionales: fotógrafas, diseñadoras, joyeras, arquitectos, músicos, desarrolladores, consultores.

Este artículo es el caso real del portfolio que hice para **Laura Cobos**, joyera artesanal y Oficial de 1ª Categoría con más de 10 años de experiencia en alta joyería. Puedes verlo en [lauracobosg.netlify.app](https://lauracobosg.netlify.app/).

---

## Por qué un profesional creativo necesita un portfolio propio

LinkedIn es suficiente para un perfil básico. Pero cuando tu trabajo es visual — una pieza de joyería, una fotografía, un diseño de interiores — necesitas un espacio donde el **trabajo sea el protagonista**, no el algoritmo de una red social.

Un portfolio a medida permite:

- **Diseño 100% adaptado** a la estética de tu trabajo
- **Sin distracciones** — sin publicidad, sin notificaciones, sin perfiles de competencia al lado
- **URL propia** que puedes poner en tarjetas, Instagram, presupuestos
- **SEO real** — tu nombre aparece en Google cuando alguien te busca
- **Rendimiento** — carga en menos de 1.5 segundos

---

## El proyecto: portfolio para Laura Cobos

Laura necesitaba un portfolio que transmitiera la **elegancia de la alta joyería artesanal**. No una plantilla de Wix ni un tema de WordPress. Algo construido desde cero que se sintiera tan cuidado como sus piezas.

<video
  src="/videos/lauracobos.mp4"
  controls
  muted
  playsinline
  class="w-full rounded-xl my-6 shadow-lg"
  style="max-height: 480px; object-fit: cover;"
>
  Tu navegador no soporta vídeo HTML5.
</video>

### Stack elegido

| Tecnología | Rol |
|---|---|
| **Next.js 16 (App Router)** | Framework — Static Export para deploy sin servidor |
| **TypeScript strict** | Tipado total en los 8 componentes |
| **Tailwind CSS** | Estilos utility-first, responsive sin media queries |
| **Framer Motion** | Animaciones de entrada con HOC `FadeIn` reutilizable |
| **Lucide React** | Iconografía ligera sin dependencias pesadas |
| **Netlify** | Deploy automático desde Git, CDN global, SSL gratis |

El build genera una carpeta `/out` con HTML/CSS/JS estático puro — sin servidor Node, sin runtime, sin coste de infraestructura.

---

## Las partes técnicas más interesantes

### 1. Carrusel infinito vertical sin librerías

La sección de portfolio tenía que mostrar piezas en un carrusel vertical que girara sin fin. Sin Swiper, sin `react-slick`, sin dependencias externas.

La solución fue la **técnica del array triplicado**:

```typescript
// Triplicamos el array para poder hacer scroll infinito
const tripleCards = [...cards, ...cards, ...cards];
const [idx, setIdx] = useState(cards.length); // Empezamos en el tercio central

useEffect(() => {
  const interval = setInterval(() => {
    setIdx(prev => prev + 1);
  }, 3000);
  return () => clearInterval(interval);
}, []);

// Cuando llegamos al límite, reseteamos al tercio central sin animación
useEffect(() => {
  if (idx >= cards.length * 2) {
    setTimeout(() => {
      // Desactivamos la transición CSS 1 frame para evitar parpadeo
      setTransition(false);
      setIdx(cards.length);
      requestAnimationFrame(() => setTransition(true));
    }, 400);
  }
}, [idx]);
```

El truco es **desactivar la transición CSS durante 1 frame** para que el reset al centro sea invisible. El usuario ve un carrusel infinito; por debajo hay un array de 3×N elementos.

El tamaño de cada tarjeta también se calcula dinámicamente:

```typescript
const cardW = Math.round(window.innerHeight * 0.52 * 0.75);
// Para que la sección siempre encaje en pantalla sin overflow
```

### 2. Hover swap sin parpadeo

En el Hero hay una imagen principal que al hacer hover muestra otra imagen diferente. La implementación naive — cambiar el `src` del `<img>` — produce un **parpadeo** mientras la nueva imagen descarga.

La solución: **dos `<img>` superpuestas** con `position: absolute`, y una transición de opacidad de 700ms entre ellas:

```tsx
<div className="relative">
  <img
    src={imagenBase}
    alt={alt}
    className="transition-opacity duration-700 group-hover:opacity-0"
  />
  <img
    src={imagenHover}
    alt={alt}
    className="absolute inset-0 opacity-0 transition-opacity duration-700 group-hover:opacity-100"
  />
</div>
```

Ambas imágenes se cargan al inicio. No hay parpadeo, no hay cambio de `src`, no hay flash. La transición es suave porque solo estamos cambiando opacidad entre dos elementos ya cargados.

### 3. Galería masonry con overlay

La galería de piezas usa un **grid masonry** donde cada tarjeta tiene proporciones distintas. Al hacer hover aparece un overlay oscuro con el nombre de la pieza.

```tsx
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
  {piezas.map((pieza) => (
    <div key={pieza.id} className="group relative overflow-hidden rounded-xl">
      <img src={pieza.imagen} alt={pieza.nombre} className="w-full h-full object-cover" />
      <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100
                      transition-opacity duration-300 flex items-end p-4">
        <p className="text-white font-medium text-sm">{pieza.nombre}</p>
      </div>
    </div>
  ))}
</div>
```

### 4. Navbar con scroll spy

La barra de navegación resalta la sección activa mientras el usuario hace scroll, sin ninguna librería externa:

```typescript
useEffect(() => {
  const sections = document.querySelectorAll('section[id]');
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          setActiveSection(entry.target.id);
        }
      });
    },
    { threshold: 0.5 }
  );
  sections.forEach(s => observer.observe(s));
  return () => observer.disconnect();
}, []);
```

---

## Resultados Lighthouse

| Métrica | Puntuación |
|---|---|
| Performance | 95/100 |
| Accessibility | 97/100 |
| SEO | 100/100 |
| Best Practices | 100/100 |

Carga completa en menos de 1.5 segundos en 4G. El Static Export de Next.js sirve HTML/CSS/JS puro desde el CDN de Netlify — no hay servidor que arrancar, no hay tiempo de cold start.

---

## Qué tipo de portfolios hago

No solo para joyeras. He hecho y puedo hacer portfolios para:

- **Profesionales creativos** — joyeros, fotógrafos, ilustradores, diseñadores gráficos, arquitectos
- **Freelancers técnicos** — desarrolladores, diseñadores UX/UI, consultores
- **Músicos y artistas** — con secciones de discografía, fechas, vídeos
- **Profesores y formadores** — con cursos, recursos descargables y contacto
- **Cualquier profesional que quiera tener presencia digital propia**

Cada portfolio es diferente. El stack se adapta a lo que necesita el proyecto: si hay mucha animación, Next.js + Framer Motion. Si es más sencillo, Vite + Vanilla JS es suficiente y más rápido.

---

## ¿Te interesa un portfolio?

Si eres profesional y quieres un portfolio que represente tu trabajo de verdad — no una plantilla, no un Wix — puedes contactarme desde [la sección de contacto de mi portfolio](https://francobosg.netlify.app/#contacto) o escribirme directamente.

Te cuento qué necesito saber, qué stack tendría sentido para tu caso y una estimación de tiempo y coste.

---

*¿Quieres ver el making of de mi propio portfolio? Lo cuento en [este artículo](/blog/como-hice-mi-portfolio-vite-tailwind/).*]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Context Window de la IA: Cómo Aprovecharlo al Máximo en Proyectos Grandes]]></title>
      <link>https://francobosg.netlify.app/blog/context-window-ia-como-aprovecharlo-2026/</link>
      <description><![CDATA[El context window de Claude, GPT o Gemini es tu recurso más valioso y más limitado. Estrategias reales para gestionarlo bien en proyectos grandes sin perder coherencia entre sesiones.]]></description>
      <pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/context-window-ia-como-aprovecharlo-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Llevas una hora trabajando con el agente en un módulo complejo. Las primeras respuestas eran perfectas — entendía la arquitectura, el estilo de código, las convenciones del proyecto. Ahora está generando código inconsistente, olvidando cosas que le dijiste al principio, repitiendo preguntas que ya respondiste.

El context window se llenó. Y tú no lo viste venir.

El context window es el recurso más crítico y menos gestionado del trabajo con IA. Esta guía te explica cómo funciona y cómo aprovecharlo en proyectos reales.

## Qué es el context window y por qué importa

El context window es la "memoria de trabajo" del modelo — todo lo que puede ver a la vez. Incluye:

- El historial de mensajes de la conversación
- El código que has pegado o adjuntado
- Los archivos que el agente ha leído
- Las respuestas que el modelo ha generado
- Los resultados de herramientas (MCP, ejecución de código, etc.)

Cuando el contexto se llena, el modelo debe decidir qué olvidar. Generalmente olvida el inicio de la conversación — justo donde estaban tus instrucciones de arquitectura, el contexto inicial del proyecto y las convenciones que estableciste.

### Tamaños de contexto en 2026

| Modelo | Context window | En palabras aprox. |
|--------|---------------|-------------------|
| **Claude Sonnet 4.5** | 200K tokens | ~150.000 palabras |
| **Claude Opus 4** | 200K tokens | ~150.000 palabras |
| **GPT-4.1** | 128K tokens | ~96.000 palabras |
| **GPT-4o** | 128K tokens | ~96.000 palabras |
| **Gemini 1.5 Pro** | 1M tokens | ~750.000 palabras |
| **Gemini 2.0 Flash** | 1M tokens | ~750.000 palabras |
| **Llama 3.3 70B** | 128K tokens | ~96.000 palabras |

200K tokens suena a mucho. Pero un proyecto mediano de TypeScript con 50 archivos de 200 líneas ya son ~500K tokens. Un proyecto real con 200+ archivos supera fácilmente el millón.

---

## El problema del "Lost in the Middle"

Más contexto no siempre es mejor. Los modelos tienen un problema conocido: **prestan más atención al inicio y al final del contexto que al medio**.

```
Inicio del contexto → Alta atención ✅
...
Medio del contexto → Baja atención ⚠️
...
Final del contexto → Alta atención ✅
```

Esto significa que si metes 50 archivos de código en el medio de una conversación larga y luego haces una pregunta al final, el modelo puede ignorar archivos completos.

**Ejemplo real:**
```
Mensaje 1: "El proyecto usa NestJS con multi-tenancy. Cada query debe filtrar por tenantId."
[200 mensajes después, con código de 20 archivos en el medio]
Mensaje 201: "Añade un endpoint para listar campañas"

Resultado: el modelo genera el endpoint SIN filtrar por tenantId porque olvidó la instrucción del mensaje 1.
```

---

## Estrategias para gestionar el context window

### 1. El archivo de contexto persistente

En vez de explicar el proyecto al inicio de cada sesión, mantén un archivo `CONTEXT.md` en el proyecto:

```markdown
# CONTEXT.md — Atrapaclientes

## Arquitectura
- NestJS 11 + TypeORM
- Multi-tenancy a nivel de fila (TenantContextGuard inyecta tenantId en todas las queries)
- RBAC con 31 permisos — usar @RequirePermissions() en todos los endpoints protegidos
- PostgreSQL 17, 29 entidades

## Convenciones
- DTOs en kebab-case: create-campaign.dto.ts
- Todos los endpoints GET paginados: ?page=1&limit=10
- Respuestas de error: { error: string, statusCode: number }
- Nunca exponer IDs numéricos, siempre UUIDs

## Lo que NUNCA debe hacer la IA
- Hardcodear tenantId
- Omitir validación con class-validator en DTOs
- Usar any en TypeScript
- Crear endpoints sin guardia de auth
```

Al inicio de cada sesión: `@CONTEXT.md Implementa [tarea]`. El contexto relevante siempre está al inicio, donde el modelo tiene más atención.

### 2. Conversaciones cortas y específicas

En vez de una conversación larga de 50 mensajes para implementar un módulo completo, divide en conversaciones cortas:

```
Conversación 1: "Crea la entidad Campaign con sus relaciones"
Conversación 2: "Crea el CampaignService con los métodos CRUD"
Conversación 3: "Crea el CampaignController con los endpoints REST"
Conversación 4: "Añade tests para el CampaignService"
```

Cada conversación empieza limpia con contexto fresco. El agente no arrastra el ruido de las decisiones anteriores.

### 3. Selección de archivos, no proyectos enteros

Cuando uses `@codebase` o `@workspace` en Cursor/Copilot, el agente intenta leer todos los archivos relevantes. Esto consume contexto rápidamente.

Mejor práctica: especifica los archivos exactos que necesita:

```
❌ "@codebase ¿cómo funciona el módulo de auth?"
✅ "@src/modules/auth/auth.service.ts @src/modules/auth/auth.guard.ts ¿cómo funciona el módulo de auth?"
```

Ahorras tokens y el modelo tiene mejor señal.

### 4. Prompt caching para contexto repetido

Si pagas por API directa, el [prompt caching de Claude](/blog/prompt-caching-openai-claude-ahorrar-tokens-2026/) te permite marcar partes del prompt como cacheables. Si el contexto base del proyecto siempre es el mismo, el caching lo mantiene sin consumir tokens en cada llamada.

```python
# Con Anthropic SDK, el contexto del sistema se cachea automáticamente
# si tienes prompt caching habilitado en tu plan
messages = [
    {
        "role": "user",
        "content": [
            {
                "type": "text",
                "text": system_context,
                "cache_control": {"type": "ephemeral"}  # Cacheable
            },
            {
                "type": "text",
                "text": "Implementa el endpoint de login con JWT"
            }
        ]
    }
]
```

### 5. Resúmenes como checkpoint

En sesiones largas, cuando notes que el contexto empieza a llenarse, pide un resumen antes de continuar:

```
"Antes de continuar, resume en bullet points:
1. Qué hemos implementado en esta sesión
2. Las decisiones de arquitectura que tomamos
3. Lo que queda pendiente"
```

Guarda ese resumen. En la siguiente sesión, empieza con él en vez de con todo el historial anterior.

---

## Context window en diferentes editores

### Cursor

Cursor muestra el uso de tokens en la barra inferior del chat. Cuando se acerca al límite:
- La conversación se marca con un aviso
- Puedes usar **"New conversation"** para empezar limpio manteniendo los archivos abiertos

**Tip**: Las `.cursorrules` o las instrucciones de `Settings → Rules for AI` siempre se inyectan al inicio del contexto. Ponlas cortas y precisas — son tokens que consumes en cada mensaje.

### GitHub Copilot

Copilot gestiona el contexto automáticamente, priorizando el archivo activo y los archivos relacionados. Puedes controlar qué entra al contexto con:
- `#file:ruta/archivo.ts` — incluir un archivo específico
- `#selection` — solo el código seleccionado
- `@workspace` — todo el proyecto (costoso en tokens)

### Cline / Claude Dev

Muestra el uso de tokens en tiempo real. Cuando se acerca al límite, crea automáticamente un resumen de la sesión y empieza un nuevo contexto. Puedes configurar el umbral en la configuración.

---

## Cómo estructurar proyectos grandes para la IA

La arquitectura de tu proyecto afecta directamente qué tan bien trabaja la IA contigo. Algunos patrones que funcionan bien:

### Módulos pequeños y cohesivos

Un módulo de 5 archivos de 100 líneas es mejor que un archivo de 500 líneas. El agente puede leer el módulo completo sin saturar el contexto.

```
✅ Buena estructura para IA:
/modules/auth/
  auth.module.ts      (30 líneas)
  auth.service.ts     (150 líneas)
  auth.controller.ts  (80 líneas)
  auth.guard.ts       (60 líneas)
  dto/
    login.dto.ts      (20 líneas)
    register.dto.ts   (25 líneas)

❌ Mala estructura para IA:
/auth.ts              (500 líneas con todo mezclado)
```

### Nombres descriptivos

Los nombres de archivos y funciones son parte del contexto implícito. `getUsersByTenantWithActiveSubscription` le da más información al modelo que `getUsers2`.

### Comentarios de arquitectura, no de implementación

```typescript
// ✅ Útil para la IA (arquitectura)
// TenantContextGuard se ejecuta antes que este guard.
// req.user.tenantId está garantizado en este punto.

// ❌ Inútil para la IA (obvio)
// Obtiene el usuario por ID
async getUserById(id: string) {
```

---

## El patrón de "contexto mínimo viable"

Cuando trabajo en una tarea específica, construyo el contexto mínimo que la IA necesita:

```
1. Instrucciones de arquitectura (del CONTEXT.md)
2. El archivo que estoy modificando
3. Los archivos que el archivo importa
4. El tipo/interfaz de los datos que maneja
5. La descripción de la tarea

Nada más.
```

No meto 20 archivos "por si acaso". Más contexto no relevante = más ruido = peor respuesta.

Si el agente necesita más contexto, me lo pedirá. O puedo usar [MCP servers](/blog/mcp-servers-cursor-copilot-que-son-2026/) para que consulte lo que necesita sin que yo tenga que pegarlo manualmente.

---

## Síntomas de que el contexto está degradado

Reconocer cuándo el contexto está fallando te ahorra mucho tiempo:

| Síntoma | Causa probable |
|---------|---------------|
| El agente "olvida" convenciones del proyecto | Instrucciones iniciales desplazadas por el historial |
| Código inconsistente con lo que hizo antes | Lost in the middle — no ve el código anterior |
| Respuestas genéricas sin contexto del proyecto | Contexto demasiado lleno, modelo generalizando |
| El agente pregunta cosas que ya respondiste | Historial de conversación demasiado largo |
| Cambios que contradicen decisiones previas | Decisiones en el medio del contexto ignoradas |

Cuando veas estos síntomas: **nueva conversación con contexto fresco**.

---

## Gestión del contexto con agentes autónomos

Los [agentes autónomos](/blog/crear-agente-ia-langchain-nodejs-tutorial/) tienen un reto adicional: necesitan mantener contexto entre múltiples pasos de ejecución. Las estrategias para esto:

### Memoria externa

Guardar el estado del agente en un archivo o base de datos, no en el contexto del chat:

```javascript
// El agente guarda su "memoria" en un archivo
const memory = {
  task: "implementar módulo de auth",
  completedSteps: ["entity", "service"],
  pendingSteps: ["controller", "tests"],
  decisions: {
    "hash algorithm": "argon2",
    "token expiry": "7 days"
  }
};
fs.writeFileSync("agent-memory.json", JSON.stringify(memory));
```

### Resúmenes progresivos

Cada N pasos, el agente hace un resumen de lo completado y lo sustituye por el historial detallado. Comprime el contexto sin perder información relevante.

### Contexto por tarea, no por proyecto

En vez de tener un contexto global del proyecto, el agente carga solo el contexto relevante para la tarea actual. Similar al patrón RAG que cuento en [crear un chatbot con RAG](/blog/crear-chatbot-rag-openai-tutorial-2026/).

---

## Calculando el coste del contexto

Si pagas por API (no por suscripción de editor), el tamaño del contexto afecta directamente al coste. La [calculadora de precios de IA](/blog/calculadora-precios-ia-2026/) te ayuda a estimar, pero aquí están los precios orientativos para 2026:

| Modelo | Input (por 1M tokens) | Output (por 1M tokens) |
|--------|----------------------|------------------------|
| Claude Sonnet 4.5 | $3 | $15 |
| Claude Opus 4 | $15 | $75 |
| GPT-4.1 | $2 | $8 |
| Gemini 1.5 Pro | $1.25 | $5 |

Una sesión de trabajo de 2 horas con contexto largo (50K tokens de input promedio por mensaje) puede costar entre $1 y $10 dependiendo del modelo. Con prompt caching activo, el coste se reduce un 90% en el contexto repetido.

---

## Conclusión

El context window no es solo una limitación técnica — es una restricción que moldea cómo deberías estructurar tu trabajo con IA.

Las prácticas clave:
1. **CONTEXT.md** con la arquitectura del proyecto, siempre al inicio
2. **Conversaciones cortas** por tarea, no maratones de 3 horas
3. **Archivos específicos**, no @codebase a ciegas
4. **Nueva conversación** cuando los síntomas de degradación aparecen
5. **Módulos pequeños** — benefician tanto a humanos como a IAs

Un buen manejo del contexto es lo que separa el [vibe coding que funciona del que destroza](/blog/vibe-coding-bien-mal-experiencia-real-2026/) proyectos. No es sobre cuánto contexto metes — es sobre qué contexto es relevante.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[IA para Revisar Pull Requests: CodeRabbit, Copilot y Cómo Usarlos Bien]]></title>
      <link>https://francobosg.netlify.app/blog/ia-para-revisar-pull-requests-2026/</link>
      <description><![CDATA[Cómo uso IA para revisar Pull Requests en proyectos reales: CodeRabbit, GitHub Copilot PR review y revisión manual asistida. Qué detectan, qué se les escapa y cómo integrarlos en tu flujo.]]></description>
      <pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/ia-para-revisar-pull-requests-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Herramientas</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Un PR de 800 líneas llega a tu repo. El autor lo marca como "pequeño refactor". Tienes 15 minutos antes de la siguiente reunión. ¿Lo apruebas sin leer o lo dejas pendiente?

Esta es la situación real en la que la IA para revisar PRs tiene más valor. No para reemplazar la revisión — para que cuando llegues al PR, los problemas obvios ya estén identificados y puedas focalizarte en lo que realmente importa.

## Por qué la revisión de PRs es un cuello de botella

En equipos pequeños, la revisión de código es una de las tareas más importantes y más descuidadas. El motivo es simple: es costosa en tiempo y atención.

Revisar bien un PR de 500 líneas requiere:
- Entender el contexto del cambio
- Seguir la lógica de cada función modificada
- Pensar en edge cases
- Verificar que los tests son suficientes
- Detectar problemas de seguridad

Eso son 30-60 minutos por PR. En proyectos activos con varios PRs al día, la revisión se convierte en un cuello de botella o, peor, en una formalidad que no añade valor real.

La IA no resuelve esto completamente. Pero sí elimina el trabajo mecánico de detectar los problemas más obvios, para que tú puedas centrarte en los que realmente requieren criterio humano.

---

## Herramientas principales

### CodeRabbit

CodeRabbit es el más maduro del ecosistema. Se instala como GitHub App y revisa automáticamente cada PR que se abre o actualiza.

**Qué hace bien:**
- Resumen automático de los cambios en lenguaje natural
- Comentarios inline en líneas específicas del diff
- Detección de bugs potenciales y code smells
- Sugerencias de mejora con código de ejemplo
- Diagrama de flujo del walkthrough de cambios
- Revisión de tests — avisa si un cambio no tiene test asociado

**Cómo se ve en práctica:**

Cuando abres un PR con CodeRabbit instalado, en los primeros minutos aparece un comentario con:

```markdown
## Summary
This PR adds the campaign creation endpoint with multi-tenant support.

## Walkthrough
- `CampaignController` (+80 lines): New POST /campaigns endpoint
- `CampaignService` (+120 lines): createCampaign() with tenant isolation
- `create-campaign.dto.ts` (+35 lines): Validation with class-validator

## Potential Issues
⚠️ Line 45 in campaign.service.ts: The query doesn't filter by tenantId.
   Campaigns from other tenants could be exposed.

⚠️ Line 23 in campaign.controller.ts: No rate limiting on this endpoint.
   Could be abused for enumeration attacks.
```

El primer punto es exactamente el tipo de error de seguridad crítico que en una revisión rápida podría pasar desapercibido.

**Configuración recomendada** (`.coderabbit.yaml` en la raíz del repo):

```yaml
language: "es"
reviews:
  profile: "chill"  # assertive | chill — chill es menos ruidoso
  request_changes_workflow: false
  high_level_summary: true
  poem: false  # Desactiva el poema al final (es una feature, en serio)
  review_status: true
  collapse_walkthrough: true
  path_filters:
    - "!dist/**"
    - "!node_modules/**"
    - "!*.lock"
    - "!*.min.js"
chat:
  auto_reply: true
```

**Coste**: Gratis para repos públicos. Para repos privados, el plan gratuito incluye 50 reviews/mes. El plan Pro es $12/usuario/mes.

---

### GitHub Copilot Code Review

GitHub Copilot tiene una función de revisión de código integrada directamente en la interfaz de PRs. A diferencia de CodeRabbit (automático), Copilot review se activa manualmente.

**Cómo activarlo:**

En cualquier PR, en la pestaña "Files changed", hay un botón de Copilot en la esquina superior derecha. Al pulsarlo, Copilot analiza todos los cambios y añade comentarios inline.

**Qué hace bien:**
- Review interactivo — puedes hacerle preguntas sobre su propio comentario
- Entiende el contexto del repo si tienes `@workspace` configurado
- Mejor en sugerencias de refactor que en detección de seguridad
- Genera sugerencias de código con un botón "Accept suggestion"

**Lo que falta respecto a CodeRabbit:**
- No es automático — alguien tiene que activarlo en cada PR
- No genera el resumen de alto nivel del PR completo
- Menos especializado en seguridad

**Mejor uso de Copilot para PRs:** Como complemento a la revisión humana. Cuando estás en el PR leyendo el diff y tienes dudas, puedes preguntarle directamente:

```
"¿Este cambio en el TenantContextGuard puede afectar a los endpoints que no usan el guard?"
"¿Hay edge cases que no estamos manejando en este try/catch?"
"¿Los tests cubren el caso en que el tenantId es undefined?"
```

---

### Revisión asistida manual (sin herramienta de PR)

Si no usas CodeRabbit ni tienes Copilot Business, puedes hacer revisión asistida copiando el diff al chat de Claude o ChatGPT.

**El prompt que uso:**

```
Eres un senior developer revisando un Pull Request.
El proyecto es [descripción breve: NestJS multi-tenant SaaS con RBAC].
Las convenciones son: [listar las más relevantes].

Revisa este diff y dame:
1. Problemas críticos (bugs, seguridad, pérdida de datos)
2. Problemas medios (rendimiento, mantenibilidad)
3. Sugerencias menores (estilo, naming)

Para cada problema: línea, descripción, sugerencia de fix.

[DIFF AQUÍ]
```

Es manual pero funciona bien para PRs que no pasan por un repo con CodeRabbit configurado.

---

## Qué detecta la IA y qué no

### Detecta bien

**Bugs de lógica simple:**
```typescript
// La IA detecta esto
if (user.role === "admin" || "superadmin") {
  // Siempre true — "superadmin" es truthy
}

// Y sugiere esto
if (user.role === "admin" || user.role === "superadmin") {
```

**Problemas de seguridad por patrón:**
```javascript
// Detecta inyección SQL por interpolación
const sql = `SELECT * FROM users WHERE id = ${userId}`;

// Detecta credenciales hardcodeadas
const apiKey = "sk-proj-123abc...";

// Detecta CORS demasiado permisivo
app.use(cors({ origin: "*" }));
```

**Code smells:**
- Funciones demasiado largas
- Duplicación de código
- Nombres de variables poco descriptivos
- Comentarios desactualizados

**Tests insuficientes:**
- Funciones sin test correspondiente
- Tests que no cubren el caso de error
- Mocks vacíos que no prueban nada

---

### No detecta (o detecta mal)

**Vulnerabilidades de lógica de negocio:**
```typescript
// La IA NO siempre detecta esto si no entiende el modelo de datos
async getCampaigns(tenantId: string, userId: string) {
  // Falta validar que userId pertenece a tenantId
  return this.campaignRepo.find({ where: { tenantId } });
}
```

La IA no sabe que en tu sistema un userId puede pertenecer a otro tenant. Necesita conocer el modelo de negocio para detectarlo.

**Problemas de rendimiento no obvios:**
```typescript
// La IA no detecta N+1 queries en ORMs complejos
const orders = await Order.findAll();
for (const order of orders) {
  order.items = await OrderItem.findAll({ where: { orderId: order.id } }); // N+1
}
```

Algunos detectores lo pillan, otros no. Depende de lo explícito que sea el patrón.

**Errores de integración entre servicios:**
Si un cambio en el servicio A rompe el servicio B porque dependen de un contrato implícito, la IA solo lo detecta si tiene contexto de ambos servicios.

**Condiciones de carrera:**
```typescript
// Parece correcto, pero tiene race condition en alta concurrencia
const exists = await this.userRepo.findOne({ where: { email } });
if (!exists) {
  await this.userRepo.save({ email, ... }); // Otro hilo puede crear el mismo email aquí
}
```

---

## Flujo de trabajo con IA en la revisión de PRs

Este es mi flujo cuando tengo CodeRabbit + Copilot en el proyecto:

```
1. PR abierto
       ↓
2. CodeRabbit revisa automáticamente (2-5 min)
       ↓
3. Leer el resumen de CodeRabbit
   - ¿Hay críticos? → resolver antes de revisión humana
   - ¿Solo sugerencias? → continuar
       ↓
4. Revisión humana focalizada:
   - Lógica de negocio (lo que la IA no entiende)
   - Cambios en auth/seguridad
   - Impacto en otras partes del sistema
       ↓
5. Preguntas a Copilot inline para dudas concretas
       ↓
6. Aprobar o solicitar cambios
```

El resultado: reviews que antes me tomaban 45 minutos ahora me toman 15-20. No porque revise menos — sino porque no gasto tiempo en los problemas que la IA ya identificó.

---

## Configurar Copilot para que entienda tu proyecto

La calidad del review de Copilot mejora mucho si tiene contexto del proyecto. Usa el archivo de instrucciones:

```markdown
<!-- .github/copilot-instructions.md -->
# Instrucciones para Copilot

## Contexto del proyecto
- NestJS 11 multi-tenant SaaS
- Cada request tiene req.user.tenantId (inyectado por TenantContextGuard)
- TODAS las queries deben filtrar por tenantId — si no lo hacen, es un bug crítico
- RBAC con @RequirePermissions() — todos los endpoints protegidos lo necesitan

## Al revisar código, prioriza:
1. Falta de filtro por tenantId en queries → Bug crítico de seguridad
2. Endpoints sin @RequirePermissions → Escalada de privilegios
3. DTOs sin validación con class-validator → Input no validado
4. any en TypeScript → Riesgo de runtime errors
```

Este archivo también mejora el autocompletado y las respuestas de Copilot en general, no solo en revisiones. Lo cuento en [cómo sacar el máximo a GitHub Copilot](/blog/cursor-vs-copilot-vs-windsurf-2026/).

---

## Revisión de PRs generados por IA

Si usas [vibe coding](/blog/vibe-coding-bien-mal-experiencia-real-2026/) y el agente genera PRs completos, la revisión de IA es especialmente útil — no para sustituir tu revisión, sino para tener una segunda "opinión" sobre el código que la IA generó.

El patrón que funciona: **un modelo revisa lo que generó otro modelo**. CodeRabbit (basado en Claude) revisando código generado por Cursor (también Claude) parece redundante, pero en la práctica el review model tiene más contexto del proyecto y detecta inconsistencias que el generation model ignoró.

```bash
# El agente generó el módulo → abres PR → CodeRabbit revisa
# Resultado habitual: 2-3 puntos que el agente pasó por alto

# Los más comunes:
# - Falta filter por tenantId (el agente olvida el contexto de multi-tenancy)
# - Test no cubre el caso de error 404
# - Import innecesario que quedó del código anterior
```

---

## Métricas: ¿funciona realmente?

En proyectos donde he tenido CodeRabbit configurado durante varios meses:

- **~30% de los PRs** tenían al menos un comentario de CodeRabbit que resultó en un cambio real
- **~8% de los PRs** tenían un problema detectado por CodeRabbit que se hubiera ido a producción sin la revisión automática
- Los problemas más frecuentes detectados: queries sin filtro de tenant (2 veces), tests insuficientes (5 veces), código duplicado que debería haberse refactorizado (4 veces)

No es perfecto. Pero el 8% de PRs con bugs reales detectados antes de producción tiene un ROI clarísimo.

---

## Alternativas y complementos

| Herramienta | Especialidad | Precio |
|-------------|-------------|--------|
| **CodeRabbit** | Review automático completo | Gratis / $12/usuario |
| **GitHub Copilot** | Review interactivo | $10-19/usuario |
| **Sourcery** | Refactor y code quality | Gratis / $14/usuario |
| **DeepSource** | Análisis estático + IA | Gratis / $12/usuario |
| **Snyk** | Seguridad y vulnerabilidades | Gratis / desde $25 |
| **SonarQube** | Calidad y deuda técnica | Gratis (self-hosted) |

Para proyectos pequeños o en solitario: CodeRabbit gratuito + Copilot Pro es suficiente.
Para equipos: CodeRabbit Pro + Copilot Business + Snyk para seguridad.

---

## Conclusión

La IA para revisión de PRs no reemplaza al revisor humano — elimina el ruido para que el revisor humano pueda focalizarse en lo que importa.

Configura CodeRabbit en tu repo (es gratuito para empezar), ajusta el `.coderabbit.yaml` para reducir el ruido, y úsalo como primera capa de revisión. Reserva tu atención para la lógica de negocio, los cambios de arquitectura y los edge cases que solo tú puedes evaluar.

Y si generas código con IA, combinarlo con un revisor IA es el mínimo que puedes hacer antes de mergear a producción. Los problemas que la IA generadora pasó por alto, la IA revisora los puede pillar — especialmente si usas [MCP servers](/blog/mcp-servers-cursor-copilot-que-son-2026/) para que el revisor tenga contexto real del proyecto.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[MCP Servers: Qué Son, Para Qué Sirven y Cómo Usarlos en Cursor y Copilot]]></title>
      <link>https://francobosg.netlify.app/blog/mcp-servers-cursor-copilot-que-son-2026/</link>
      <description><![CDATA[MCP (Model Context Protocol) permite a tu IA conectarse a bases de datos, APIs y herramientas externas en tiempo real. Guía completa con ejemplos reales en Cursor y GitHub Copilot 2026.]]></description>
      <pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/mcp-servers-cursor-copilot-que-son-2026/</guid>
      <category>IA</category>
      <category>Herramientas</category>
      <category>Código</category>
      <content:encoded><![CDATA[Llevabas meses copiando y pegando contexto en el chat de tu IA. El schema de la base de datos. El output del último error. La documentación de la API. Con **MCP servers**, tu agente consulta todo eso directamente, sin que tú hagas nada.

MCP (Model Context Protocol) es el cambio más importante en cómo funcionan los agentes de IA desde que apareció el autocompletado. Aquí te explico qué es, cómo funciona y cómo configurarlo en Cursor y GitHub Copilot con ejemplos reales.

## ¿Qué es MCP exactamente?

MCP es un **protocolo abierto** creado por Anthropic (los de Claude) que define cómo un modelo de IA puede comunicarse con herramientas externas de forma estandarizada.

Antes de MCP, si querías que tu IA leyera tu base de datos tenías dos opciones:
1. Copiar y pegar el schema manualmente en el chat
2. Escribir function calling personalizado para cada herramienta y cada modelo

Con MCP:
1. Configuras un MCP server una sola vez
2. Cualquier cliente compatible (Cursor, Copilot, Claude Desktop, Cline) puede usarlo

```
Sin MCP:
Tú → copias schema → pegas en chat → IA responde

Con MCP:
Tú → "qué tablas tiene mi BD?" → IA consulta MCP server → IA responde con datos reales
```

### La arquitectura MCP en 3 capas

```
┌─────────────────────────────────────────┐
│           MCP Client                    │
│  (Cursor, GitHub Copilot, Cline...)     │
└──────────────────┬──────────────────────┘
                   │ JSON-RPC sobre stdio/HTTP
┌──────────────────▼──────────────────────┐
│           MCP Server                    │
│  (proceso local o remoto)               │
│  - Expone "tools" que el modelo invoca  │
│  - Expone "resources" (contexto estático)│
│  - Expone "prompts" (plantillas)        │
└──────────────────┬──────────────────────┘
                   │
┌──────────────────▼──────────────────────┐
│           Fuente de datos               │
│  (PostgreSQL, GitHub API, filesystem,   │
│   Jira, Slack, tu propia API...)        │
└─────────────────────────────────────────┘
```

El modelo **decide cuándo llamar** al MCP server. Si le preguntas "¿cuántos usuarios se registraron ayer?", el agente invoca el tool de SQL, ejecuta la query y te devuelve la respuesta — sin que tú hayas escrito ninguna query.

---

## MCP vs Function Calling: la diferencia real

Esta confusión es muy común. Son cosas distintas que se complementan:

| Concepto | Qué es | Dónde vive |
|----------|--------|------------|
| **Function calling** | Capacidad del modelo de invocar funciones definidas en el prompt | En el modelo (OpenAI, Anthropic, Gemini) |
| **MCP** | Protocolo de transporte para comunicar cliente ↔ servidor de herramientas | En el cliente y el servidor |

MCP **usa** function calling por debajo. El MCP server registra sus tools como funciones que el modelo puede invocar. La diferencia es que MCP estandariza el protocolo, así que el mismo servidor funciona con cualquier modelo compatible.

---

## MCP servers que uso en el día a día

### 1. `@modelcontextprotocol/server-filesystem`

Accede a archivos y carpetas fuera del proyecto abierto. Útil cuando trabajas con monorepos o necesitas que el agente lea documentación en otra ruta.

```bash
npm install -g @modelcontextprotocol/server-filesystem
```

**Caso de uso real**: "Lee el README del proyecto de infraestructura y explícame cómo desplegar el backend." El agente lee directamente `/home/fran/infra/README.md` sin que yo copie nada.

---

### 2. `@modelcontextprotocol/server-github`

Conecta con la API de GitHub. El agente puede leer issues, PRs, código de otros repos y comentarios.

```bash
npm install -g @modelcontextprotocol/server-github
```

**Caso de uso real**: "¿Hay algún issue abierto sobre el timeout de la transcripción?" El agente busca en tus issues de GitHub y te resume los relevantes. Sin salir del editor.

---

### 3. `@modelcontextprotocol/server-postgres`

Conecta directamente a PostgreSQL. El agente puede ejecutar queries, inspeccionar schemas y analizar datos.

```bash
npm install -g @modelcontextprotocol/server-postgres
```

**Caso de uso real**: "¿Cuántos tenants activos hay en producción y cuál es el que más requests genera?" El agente escribe y ejecuta la query, sin que tú la escribas.

> Usa siempre un usuario de solo lectura para esto. Nunca conectes el MCP server con credenciales de superusuario.

---

### 4. `@modelcontextprotocol/server-sqlite`

Igual que Postgres pero para SQLite. Muy útil en proyectos con base de datos local o para desarrollo.

---

### 5. `mcp-server-fetch`

Permite al agente hacer peticiones HTTP. Útil para consultar documentación online, APIs REST o webhooks.

```bash
npm install -g mcp-server-fetch
```

**Caso de uso real**: "Mira la documentación de Coolify y dime cómo configurar las variables de entorno." El agente consulta `docs.coolify.io` directamente.

---

## Configurar MCP en Cursor

Cursor guarda la configuración de MCP en `~/.cursor/mcp.json` (global) o `.cursor/mcp.json` en la raíz del proyecto (local, tiene prioridad).

### Configuración básica

```json
{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-filesystem",
        "/Users/fran/proyectos",
        "/Users/fran/documentos"
      ]
    },
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_tu_token_aqui"
      }
    },
    "postgres": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-postgres",
        "postgresql://readonly_user:password@localhost:5432/mi_bd"
      ]
    }
  }
}
```

### Verificar que funciona

1. Abre Cursor
2. Ve a **Settings → MCP** (o `Cmd/Ctrl+Shift+P` → "MCP")
3. Deberías ver los servidores listados con estado ✅ o ❌
4. En el chat, empieza con `@` para ver las herramientas disponibles

Si un servidor falla, comprueba el log en la pestaña MCP de Settings. El error más común es que `npx` no encuentra el paquete — en ese caso instálalo globalmente primero.

---

## Configurar MCP en GitHub Copilot (VS Code)

GitHub Copilot añadió soporte MCP en VS Code. La configuración va en `settings.json` o en `.vscode/mcp.json` del proyecto.

### Via `.vscode/mcp.json` (recomendado por proyecto)

```json
{
  "servers": {
    "github": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_PERSONAL_ACCESS_TOKEN": "${env:GITHUB_TOKEN}"
      }
    },
    "filesystem": {
      "type": "stdio",
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-filesystem",
        "${workspaceFolder}"
      ]
    }
  }
}
```

### Via `settings.json` (global)

```json
{
  "github.copilot.chat.mcp.enabled": true,
  "mcp": {
    "servers": {
      "postgres": {
        "type": "stdio",
        "command": "npx",
        "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://..."]
      }
    }
  }
}
```

Para usar las herramientas MCP en Copilot, abre el chat en **modo agente** (icono de agente) y el modelo las invocará automáticamente cuando sea relevante.

---

## Crear tu propio MCP server en Node.js

Si quieres conectar el agente a tu propia API o lógica personalizada, puedes crear un MCP server en minutos.

### Estructura mínima

```bash
npm init -y
npm install @modelcontextprotocol/sdk
```

```javascript
// server.js
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

const server = new Server(
  { name: "mi-api-server", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

// Definir los tools disponibles
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "get_clientes_activos",
      description: "Devuelve el número de clientes activos en la plataforma",
      inputSchema: {
        type: "object",
        properties: {
          tenant_id: {
            type: "string",
            description: "ID del tenant a consultar",
          },
        },
        required: ["tenant_id"],
      },
    },
  ],
}));

// Implementar la lógica de cada tool
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "get_clientes_activos") {
    const { tenant_id } = request.params.arguments;

    // Tu lógica real aquí: consulta a BD, llamada a API, etc.
    const count = await fetchClientesActivos(tenant_id);

    return {
      content: [
        {
          type: "text",
          text: `El tenant ${tenant_id} tiene ${count} clientes activos.`,
        },
      ],
    };
  }

  throw new Error(`Tool desconocido: ${request.params.name}`);
});

// Arrancar el servidor
const transport = new StdioServerTransport();
await server.connect(transport);
```

Luego añades el server a tu configuración de Cursor o Copilot:

```json
{
  "mcpServers": {
    "mi-api": {
      "command": "node",
      "args": ["/ruta/a/tu/server.js"],
      "env": {
        "API_KEY": "tu_clave"
      }
    }
  }
}
```

---

## Casos de uso reales donde MCP marca la diferencia

### Debugging con contexto de producción

Sin MCP: "Tengo este error en producción: [error]" → la IA adivina  
Con MCP: el agente consulta los logs reales de tu servidor y el estado actual de la BD para dar una respuesta precisa

### Revisión de PRs con contexto del proyecto

Sin MCP: pegar el diff en el chat  
Con MCP + GitHub server: "revisa el PR #47 y dime si hay problemas de seguridad" — el agente lee el PR, los archivos cambiados y el historial de la rama

Esto enlaza directamente con lo que cuento en [Cómo usar IA para revisar Pull Requests](/blog/ia-para-revisar-pull-requests-2026/) — los MCP servers son la pieza que hace esa revisión realmente útil.

### Generación de código con schema real

Sin MCP: pegar el schema de la BD  
Con MCP + Postgres server: "crea el DTO de TypeScript para la tabla `campaigns`" — el agente inspecciona la tabla directamente y genera el DTO correcto al 100%

### Documentación actualizada siempre

Sin MCP: documentación desactualizada en el contexto  
Con MCP + Fetch server: el agente consulta la documentación oficial en tiempo real antes de responder

---

## Errores comunes al configurar MCP

### El servidor no aparece en Cursor

```
Error: spawn npx ENOENT
```

Node.js no está en el PATH que usa Cursor. Solución: usa la ruta absoluta:

```json
{
  "command": "/usr/local/bin/node",
  "args": ["/usr/local/bin/npx", "-y", "@modelcontextprotocol/server-github"]
}
```

### El servidor aparece pero las tools no se invocan

El modelo no invoca tools automáticamente si la pregunta no es lo suficientemente específica. Sé explícito:

```
❌ "¿Cómo van los usuarios?"
✅ "Consulta la tabla users de la base de datos y dime cuántos se registraron esta semana"
```

### Errores de autenticación con GitHub

El token debe tener permisos `repo` (lectura) y `read:org` si necesitas repos de organización. Genera uno en GitHub → Settings → Developer settings → Personal access tokens.

---

## MCP vs Agentes con herramientas propias

Si ya usas [agentes con LangChain](/blog/crear-agente-ia-langchain-nodejs-tutorial/) o function calling propio, ¿para qué migrar a MCP?

La ventaja principal es la **portabilidad**. Un MCP server funciona con Cursor, con Copilot, con Claude Desktop y con Cline sin cambiar nada. Si mañana cambias de editor, tus herramientas siguen funcionando.

Si solo usas un editor y ya tienes herramientas personalizadas, no hay urgencia. MCP brilla especialmente cuando:
- Quieres compartir herramientas entre varios editores o agentes
- Usas herramientas estándar (GitHub, Postgres, Filesystem) y no quieres implementarlas desde cero
- Trabajas en equipo y quieres que todos tengan el mismo contexto disponible

---

## El ecosistema MCP en 2026

El catálogo oficial de MCP servers está en [modelcontextprotocol.io/servers](https://modelcontextprotocol.io/servers). Algunos destacados además de los mencionados:

- **Sentry** — el agente puede buscar errores y trazas directamente
- **Jira / Linear** — gestión de tareas sin salir del editor
- **Slack** — leer mensajes y contexto de canales
- **Puppeteer** — automatización de navegador
- **Redis** — consultar caché y datos en memoria
- **Docker** — inspeccionar contenedores y logs

La comunidad está creciendo rápido. Si construyes un MCP server para una herramienta que usas, considera publicarlo — es una buena forma de contribuir al ecosistema.

---

## Conclusión

MCP no es una moda pasajera — es la pieza que faltaba para que los agentes de IA dejen de ser asistentes que responden preguntas y se conviertan en herramientas que actúan sobre contexto real.

Si usas Cursor o GitHub Copilot a diario, configurar los MCP servers de GitHub y Postgres te va a cambiar cómo trabajas. El setup son 10 minutos y el retorno es inmediato.

Y si quieres ir más lejos, echa un vistazo a cómo funcionan los [agentes autónomos con LangChain](/blog/crear-agente-ia-langchain-nodejs-tutorial/) — MCP y agentes con memoria son el siguiente paso natural.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Vibe Coding: Lo Bueno, Lo Malo y Lo que Nadie te Cuenta (Experiencia Real 2026)]]></title>
      <link>https://francobosg.netlify.app/blog/vibe-coding-bien-mal-experiencia-real-2026/</link>
      <description><![CDATA[Vibe coding es programar delegando casi todo a la IA. Llevo meses haciéndolo en proyectos reales. Esto es lo que funciona, lo que falla y cuándo destruye más de lo que construye.]]></description>
      <pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/vibe-coding-bien-mal-experiencia-real-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Andrej Karpathy tuiteó en febrero de 2025: *"There's a new kind of coding I call 'vibe coding', where you fully give in to the vibes, embrace exponentials, and forget that the code even exists."*

El término explotó. De repente todo el mundo hablaba de programar sin programar. La IA escribe, tú aceptas, el producto existe.

Llevo meses haciendo vibe coding en proyectos reales — desde scripts de un día hasta plataformas SaaS completas. Esto es lo que nadie te cuenta.

## Qué es realmente el vibe coding

Vibe coding no es nuevo. Siempre hemos usado generadores de código, frameworks, librerías. Lo nuevo es la **escala**: ahora la IA puede generar sistemas completos funcionales en minutos, no fragmentos de código.

La definición práctica: describes lo que quieres, el agente genera el código, tú aceptas sin leer en detalle, el sistema funciona (o no). El ciclo es:

```
Descripción en lenguaje natural
        ↓
Agente genera código
        ↓
Tú aceptas (o rechazas con feedback rápido)
        ↓
Test superficial ("¿funciona visualmente?")
        ↓
Siguiente feature
```

La diferencia con usar GitHub Copilot para autocompletar es que en vibe coding **la IA toma decisiones de arquitectura**. Qué estructura de archivos. Qué librería usar. Cómo modelar los datos. Tú pides resultados, no código específico.

---

## Lo bueno: dónde el vibe coding es imbatible

### Prototipos y MVPs

Es el caso de uso perfecto. Tienes una idea, la describes, en 2 horas tienes algo que funciona suficientemente bien para mostrar a un cliente o validar con usuarios.

En el [caso real de iECO](/blog/caso-real-ia-reuniones-gemini-supabase/), el prototipo con Streamlit tardó menos de un día haciendo vibe coding puro. Sin ese prototipo rápido, no hubiera podido validar que la idea era viable antes de invertir semanas en la arquitectura real.

### Scripts y automatizaciones one-shot

Scripts de migración de datos, procesadores de archivos, integraciones simples con APIs. Código que ejecutas una vez, revisas el output, y descartas. El riesgo de no entender el código es casi cero porque el contexto es limitado y el resultado es verificable.

```
"Crea un script que lea todos los CSV de esta carpeta,
 combine las columnas 'nombre' y 'apellidos',
 y exporte un único CSV con email, nombre_completo y telefono"
```

Tiempo sin IA: 20 minutos. Con vibe coding: 2 minutos. Y el resultado es el mismo.

### Explorar tecnologías desconocidas

Quieres probar Svelte pero nunca lo has usado. Haces vibe coding con el agente, en una tarde tienes una app funcionando, entiendes los conceptos principales sin leer la documentación entera. Luego profundizas donde necesitas.

### Código repetitivo y boilerplate

Tests unitarios básicos, DTOs de TypeScript, migraciones de base de datos simples, componentes de formulario. Todo lo que sabes exactamente qué tiene que hacer pero que es tedioso de escribir.

En [Atrapaclientes](/blog/caso-real-saas-atrapaclientes-nestjs-react/), generé los 29 DTOs de TypeORM con un prompt por entidad. Lo que hubiera tardado un día tardó una hora. No porque no supiera escribirlos, sino porque el agente los escribía correctamente y yo solo revisaba.

---

## Lo malo: dónde el vibe coding te destroza

### Seguridad

Este es el problema gordo. La IA genera código funcional que **parece seguro pero no lo es**. Los errores más comunes que he visto en código generado con vibe coding:

**Inyección SQL por interpolación:**
```javascript
// Lo que genera el agente con vibe coding descuidado
const query = `SELECT * FROM users WHERE email = '${email}'`;

// Lo correcto
const query = "SELECT * FROM users WHERE email = $1";
const result = await db.query(query, [email]);
```

**IDOR sin validación:**
```javascript
// El agente genera esto — cualquier usuario puede ver cualquier recurso
app.get('/api/campaigns/:id', async (req, res) => {
  const campaign = await Campaign.findById(req.params.id);
  res.json(campaign);
});

// Necesitas validar que el recurso pertenece al usuario autenticado
app.get('/api/campaigns/:id', authMiddleware, async (req, res) => {
  const campaign = await Campaign.findOne({
    where: { id: req.params.id, tenantId: req.user.tenantId }
  });
  if (!campaign) return res.status(404).json({ error: 'Not found' });
  res.json(campaign);
});
```

**Credenciales hardcodeadas:**
```javascript
// El agente las pone "para que funcione el ejemplo"
const apiKey = "sk-proj-abc123..."; // ← va directo al repo si no lo revisas
```

Si estás haciendo vibe coding en producción, lee siempre cualquier código que maneje auth, queries a base de datos y llamadas a APIs externas.

### Deuda técnica silenciosa

El agente optimiza para que funcione ahora, no para que sea mantenible. Con vibe coding puro acabas con:

- **Funciones de 300 líneas** que "hacen lo que pediste" pero son imposibles de modificar
- **Duplicación masiva** — el agente no refactoriza lo que ya existe, añade
- **Nombres inconsistentes** — `getUserById`, `fetchUser`, `getUser` para hacer lo mismo en distintas partes
- **Sin manejo de errores** — el happy path funciona, los edge cases explotan en producción

Yo lo llamo la "deuda de vibe" — a corto plazo avanzas rápido, a medio plazo el código es una mina.

### El agente en bucle

Si no supervisas, el agente puede entrar en un bucle intentando arreglar errores que él mismo creó. Cuesta dinero y tiempo. Lo cuento en detalle en [cómo parar un agente en bucle infinito](/blog/agente-ia-bucle-infinito-cline-cursor-2026/).

### Dependencia del contexto

Si el proyecto crece y el agente pierde contexto de la arquitectura inicial, empieza a tomar decisiones inconsistentes. Sin un buen manejo del [context window](/blog/context-window-ia-como-aprovecharlo-2026/), el código generado en la sesión 1 y el de la sesión 10 parecen escritos por personas diferentes.

---

## Mi enfoque real: vibe coding supervisado

Después de meses, mi forma de trabajar no es vibe coding puro ni programación tradicional. Es algo intermedio que llamo **vibe coding supervisado**:

### 1. Arquitectura manual, implementación con IA

Diseño la estructura del proyecto yo: qué módulos, qué modelos de datos, qué patrón de arquitectura. La IA implementa dentro de ese marco.

```
Yo defino:
- /modules/auth/     → JWT + refresh tokens
- /modules/campaigns/ → CRUD + estado
- /modules/users/    → multi-tenancy con guard

La IA implementa cada módulo según el patrón que yo he definido
```

### 2. Revisión de código en puntos críticos

No leo todo el código generado, pero sí leo cualquier cosa relacionada con:
- Autenticación y autorización
- Queries a base de datos
- Manejo de datos del usuario
- Configuración de CORS, headers de seguridad
- Variables de entorno y secrets

Para el resto (componentes UI, lógica de negocio sin seguridad crítica), confío más en el agente.

### 3. Tests como red de seguridad

Si el agente genera código que pasa los tests, hay más probabilidades de que sea correcto. Los tests los pide el agente también, pero los reviso para asegurar que prueban lo que importa, no que hagan pasar el test con mocks vacíos.

Lo cuento en [cómo testear código generado por IA](/blog/testear-codigo-generado-ia-copilot-cursor-2026/).

### 4. Commits frecuentes

Antes de pedirle al agente una tarea grande, hago commit. Si el agente rompe algo, `git checkout .` y vuelves al estado anterior. Sin commits frecuentes, el vibe coding puede costarte horas de recuperación.

```bash
# Antes de cualquier tarea del agente
git add -A && git commit -m "checkpoint antes de refactor auth"

# Si el agente lo rompe todo
git checkout .
```

---

## Cuándo usar vibe coding y cuándo no

| Situación | Vibe coding | Supervisado | Manual |
|-----------|-------------|-------------|--------|
| Prototipo/MVP | ✅ | - | - |
| Script one-shot | ✅ | - | - |
| Boilerplate/DTOs | ✅ | - | - |
| Feature nueva en prod | - | ✅ | - |
| Refactor grande | - | ✅ | - |
| Sistema de auth | - | - | ✅ |
| Gestión de pagos | - | - | ✅ |
| Queries críticas | - | ✅ | - |
| Infraestructura/DevOps | - | ✅ | - |

---

## El elefante en la habitación: ¿está matando el vibe coding a los developers?

La respuesta honesta: **no, pero está cambiando qué skills importan**.

Lo que importa cada vez menos:
- Memorizar sintaxis
- Escribir boilerplate rápido
- Conocer todos los métodos de una API de memoria

Lo que importa cada vez más:
- Saber si el código generado es correcto y seguro
- Diseñar arquitecturas que el agente pueda implementar bien
- Debuggear cuando la IA falla (y falla)
- Entender el negocio para hacer las preguntas correctas

El vibe coding sin conocimientos previos produce código que funciona en el demo pero explota en producción. Con conocimientos previos, multiplica tu velocidad sin sacrificar calidad.

La IA es tan buena como el criterio de quien la dirige.

---

## Herramientas para hacer vibe coding bien

- **Cursor** con Agent mode — el mejor para proyectos con múltiples archivos. [Comparativa completa aquí](/blog/cursor-vs-copilot-vs-windsurf-2026/)
- **Cline** — agente open source para VS Code, muy configurable
- **GitHub Copilot** — más conservador pero más integrado con el flujo de trabajo
- **MCP servers** — dan al agente contexto real (BD, repos, APIs). [Cómo configurarlos](/blog/mcp-servers-cursor-copilot-que-son-2026/)
- **git** — tu red de seguridad. Sin commits frecuentes, el vibe coding es ruleta rusa

---

## Conclusión

El vibe coding es real y es útil. No es el fin de la programación ni la salvación del mundo. Es una herramienta más, con sus casos de uso y sus límites.

Mi conclusión después de meses: **vibe coding en proyectos de exploración, vibe coding supervisado en producción, cero vibe coding en seguridad**.

Y siempre, siempre, commits frecuentes.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Next.js vs Remix en 2026: Cuál Elegir Según Tu Proyecto (Comparativa Real)]]></title>
      <link>https://francobosg.netlify.app/blog/nextjs-vs-remix-cual-elegir-proyecto-2026/</link>
      <description><![CDATA[Comparativa honesta entre Next.js y Remix en 2026: rendimiento, modelo de datos, deploy, DX y cuándo usar cada uno. Basada en experiencia real con ambos frameworks.]]></description>
      <pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/nextjs-vs-remix-cual-elegir-proyecto-2026/</guid>
      <category>JavaScript</category>
      <category>React</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Llevo tiempo usando los dos en proyectos reales y la pregunta "¿Next.js o Remix?" sigue siendo legítima en 2026. No hay una respuesta universal. Hay casos donde uno gana claramente.

Esta comparativa no es un benchmark de marketing — es mi experiencia construyendo apps con ambos.

## El estado actual de cada uno

**Next.js 15** (Vercel) sigue dominando el ecosistema React. App Router ya es el estándar, Server Components son estables, y el ecosistema de librerías es enorme. Prácticamente cualquier librería de React tiene ejemplos de integración con Next.js.

**Remix / React Router v7** (Shopify → fusión con React Router en 2025) apostó por las APIs nativas del navegador: `fetch`, `FormData`, `Response`. El resultado es un framework que se siente más "web" y menos "framework inventando cosas nuevas".

---

## Modelo de datos: donde la diferencia es más grande

Esta es la diferencia filosófica más importante.

### Next.js: Server Actions y Route Handlers

```typescript
// app/productos/page.tsx — Server Component
async function ProductosPage() {
  // Fetch directo en el servidor
  const productos = await db.producto.findMany();
  
  return <ListaProductos productos={productos} />;
}

// app/actions.ts — Server Action para mutaciones
"use server";

export async function crearProducto(formData: FormData) {
  const nombre = formData.get("nombre") as string;
  const precio = Number(formData.get("precio"));
  
  await db.producto.create({ data: { nombre, precio } });
  revalidatePath("/productos");
}
```

### Remix: Loaders y Actions

```typescript
// app/routes/productos.tsx
import { json, type LoaderFunction, type ActionFunction } from "@remix-run/node";
import { useLoaderData, Form } from "@remix-run/react";

export const loader: LoaderFunction = async () => {
  const productos = await db.producto.findMany();
  return json(productos);
};

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const nombre = formData.get("nombre") as string;
  const precio = Number(formData.get("precio"));
  
  await db.producto.create({ data: { nombre, precio } });
  return json({ ok: true });
};

export default function Productos() {
  const productos = useLoaderData<typeof loader>();
  
  return (
    <>
      <Form method="post">
        <input name="nombre" />
        <input name="precio" type="number" />
        <button type="submit">Crear</button>
      </Form>
      <ListaProductos productos={productos} />
    </>
  );
}
```

**La diferencia clave**: En Remix, el `<Form>` funciona **sin JavaScript** (progressive enhancement real). Si el JS falla o tarda en cargar, el formulario sigue enviando datos. En Next.js con Server Actions, el formulario depende del JS del cliente para la mayoría de los casos.

---

## Rendimiento: cuándo importa cuál

En apps con mucha navegación e interactividad, Remix tiene una ventaja arquitectural: **prefetching inteligente de datos y código en paralelo**.

Next.js tiende a cargar datos en cascada si no tienes cuidado (un componente espera al padre, que espera al abuelo). Con Remix, los loaders de la ruta actual y sus sub-rutas se ejecutan en paralelo.

```
Next.js con anidación de fetch sin cuidado:
Layout fetch → Page fetch → Component fetch (cascada: 3x lento)

Remix (por defecto):
Todos los loaders en paralelo (1x rápido)
```

Para apps de contenido estático (blogs, marketing, documentación), **Next.js gana** gracias a SSG e ISR que Remix no tiene nativamente.

Este blog está hecho con Astro precisamente porque para contenido estático se puede ir más lejos que con Next.js. Pero para apps SaaS interactivas, la comparativa es válida. Hablo más sobre esto en el artículo de [migración de React a Astro](/blog/migracion-react-astro-rendimiento-lighthouse-2026/).

---

## Deploy: dónde puedes alojar cada uno

### Next.js
- **Vercel**: el deploy más sencillo posible, es de la misma empresa
- **Netlify**: soporte oficial
- **AWS, GCP**: con adaptadores
- **Docker / VPS**: funciona, pero necesitas configuración extra
- **Coolify**: [para self-hosting sin DevOps](/blog/coolify-self-hosting-deploy-sin-devops-2026/), funciona razonablemente bien

### Remix / React Router v7
- **Cloudflare Workers**: excelente integración, latencia mínima global
- **Vercel**: soporte
- **Fly.io**: popular en la comunidad Remix por el control que da
- **Node.js nativo**: más sencillo que Next.js porque es un servidor HTTP estándar

Si priorizas la libertad de deploy y no depender de Vercel, Remix da más opciones nativas. La discusión de [Vercel vs VPS](/blog/vercel-vs-vps-coste-real-nextjs-2026/) es relevante aquí.

---

## Developer Experience: lo que sientes trabajando

### Qué hace Next.js mejor
- Documentación más completa y actualizada
- Más tutoriales, más Stack Overflow, más ejemplos en GitHub
- `next/image` para optimización de imágenes es excelente
- `next/font` para fuentes sin layout shifts
- El ecosistema de librerías UI (Shadcn, NextUI) está mayoritariamente pensado para Next.js

### Qué hace Remix mejor
- Los errores de routing son más claros y predecibles
- El modelo mental de "ruta = loader + action + componente" es más simple una vez lo entiendes
- Los `error boundaries` por ruta (si falla un loader, solo falla esa sección, no la app entera)
- Progressive enhancement real sin configurar nada

### Lo que los dos hacen mal
- **Next.js**: el sistema de caché en App Router ha confundido a toda la comunidad. En v15 lo cambiaron casi todo respecto a v13. La inestabilidad de la API de caché es un problema real
- **Remix**: la comunidad es más pequeña y hay menos recursos de aprendizaje. Algunos patrones necesitan más boilerplate

---

## Autenticación en cada uno

La autenticación es uno de los casos donde más noto la diferencia práctica.

**Con Next.js** uso [Auth.js (antes NextAuth)](/blog/auth-js-nextauth-implementacion-real-2026/) que tiene integración nativa. Funciona muy bien.

**Con Remix** la autenticación se implementa con sessions nativas del servidor usando cookies HTTP. Más control, más verboso:

```typescript
// remix — session nativa
import { createCookieSessionStorage, redirect } from "@remix-run/node";

const { getSession, commitSession } = createCookieSessionStorage({
  cookie: {
    name: "__session",
    secrets: [process.env.SESSION_SECRET!],
    secure: process.env.NODE_ENV === "production",
    httpOnly: true,
    sameSite: "lax"
  }
});

export async function login(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));
  session.set("userId", "123");
  
  return redirect("/dashboard", {
    headers: { "Set-Cookie": await commitSession(session) }
  });
}
```

Más código, pero entiendes exactamente qué pasa. Para [proteger APIs con JWT](/blog/proteger-api-nodejs-jwt-auth-guia-2026/), Remix te da más control sobre las cabeceras HTTP.

---

## Tabla comparativa

| Aspecto | Next.js 15 | Remix / RR v7 |
|---------|------------|---------------|
| Ecosistema | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| Documentación | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Rendimiento SSG/ISR | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| Rendimiento apps dinámicas | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Progressive enhancement | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| Modelo de datos (claridad) | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Libertad de deploy | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Curva de aprendizaje | Suave | Moderada |

---

## Cuándo elegir cada uno

### Elige Next.js si:
- Es tu primer framework de React y quieres el camino con más recursos
- Necesitas SSG o ISR para contenido que se regenera poco (blog, landing, documentación)
- Tu equipo ya conoce Next.js
- Quieres el deploy más sencillo posible en Vercel
- Usas muchas librerías de UI del ecosistema React (Shadcn, etc.)

### Elige Remix / React Router v7 si:
- La app tiene muchos formularios, mutaciones de datos y flujos de usuario complejos
- Priorizas el rendimiento en navegaciones y quieres datos en paralelo automáticamente
- Quieres deploy en Cloudflare Workers (latencia mínima globalmente)
- El equipo entiende bien los conceptos web (HTTP, cookies, forms)
- Quieres progressive enhancement sin configurar nada

---

## Mi recomendación honesta

Si empiezas un proyecto nuevo en 2026 y no tienes una razón específica para uno u otro: **empieza con Next.js**.

No porque sea mejor técnicamente en todo. Sino porque tiene más comunidad, más ejemplos, más soluciones a problemas concretos. Cuando atasques (y atascarás), encontrarás solución más rápido.

Si llevas tiempo con Next.js y te frustra el modelo de caché o quieres un framework que "piense en web" en vez de "pensar en React", dale una oportunidad a Remix. El cambio mental merece la pena para ciertos tipos de apps.

Y si lo que necesitas es un blog o sitio de contenido estático, ninguno de los dos es la respuesta correcta — te interesa más Astro, como cuento en [cómo migré de React a Astro](/blog/migracion-react-astro-rendimiento-lighthouse-2026/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[PostgreSQL vs MySQL en 2026: Cuál Elegir para Tu Proyecto (Guía Definitiva)]]></title>
      <link>https://francobosg.netlify.app/blog/postgresql-vs-mysql-cual-elegir-2026/</link>
      <description><![CDATA[Comparativa técnica y práctica entre PostgreSQL y MySQL en 2026. Diferencias reales de rendimiento, funcionalidades, ecosistema y cuándo usar cada uno según el tipo de proyecto.]]></description>
      <pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/postgresql-vs-mysql-cual-elegir-2026/</guid>
      <category>Backend</category>
      <category>Bases de Datos</category>
      <category>Código</category>
      <content:encoded><![CDATA[La pregunta PostgreSQL vs MySQL aparece en cada proyecto que empieza desde cero. No hay una respuesta única, pero hay una respuesta correcta para cada tipo de proyecto.

Llevo años usando los dos en producción y este artículo es la guía que me habría ahorrado elegir mal al principio.

## El estado actual en 2026

**PostgreSQL 17** es el estándar de facto para proyectos modernos. Supabase lo popularizó enormemente en el ecosistema JavaScript. Prisma, el ORM más usado con Node.js, recomienda PostgreSQL explícitamente.

**MySQL 9** sigue siendo el más usado en términos absolutos (millones de apps en producción, WordPress, etc.). Oracle lo mantiene. MariaDB es la alternativa open source que muchos prefieren.

La tendencia es clara: proyectos nuevos eligen PostgreSQL. Proyectos legacy siguen con MySQL.

---

## Diferencias técnicas que importan en el día a día

### 1. Tipos de datos: donde PostgreSQL gana claramente

```sql
-- PostgreSQL tiene tipos que MySQL no tiene nativamente:

-- Arrays
CREATE TABLE etiquetas_articulo (
  id SERIAL PRIMARY KEY,
  articulo_id INT,
  tags TEXT[]  -- Array nativo
);

INSERT INTO etiquetas_articulo (articulo_id, tags) 
VALUES (1, ARRAY['typescript', 'javascript', 'react']);

SELECT * FROM etiquetas_articulo 
WHERE 'typescript' = ANY(tags);  -- Búsqueda en array

-- JSON/JSONB (indexable, no solo almacenamiento)
CREATE TABLE configuraciones (
  id SERIAL PRIMARY KEY,
  usuario_id INT,
  datos JSONB
);

-- Índice GIN en columna JSONB
CREATE INDEX idx_config_datos ON configuraciones USING GIN(datos);

SELECT * FROM configuraciones 
WHERE datos @> '{"tema": "dark"}';  -- Query sobre JSON indexado

-- Enums nativos
CREATE TYPE estado_pedido AS ENUM ('pendiente', 'procesando', 'enviado', 'entregado');

CREATE TABLE pedidos (
  id SERIAL PRIMARY KEY,
  estado estado_pedido DEFAULT 'pendiente'
);
```

MySQL tiene JSON desde v5.7 y arrays se simulan con VARCHAR o tablas pivote. La diferencia es que PostgreSQL hace estos tipos **indexables y eficientes**.

### 2. Transacciones y ACID

PostgreSQL tiene cumplimiento ACID más estricto por diseño. Un ejemplo real donde importa:

```sql
-- PostgreSQL — DDL dentro de transacciones (MySQL no soporta esto)
BEGIN;
  ALTER TABLE usuarios ADD COLUMN premium BOOLEAN DEFAULT false;
  UPDATE usuarios SET premium = true WHERE plan = 'pro';
  -- Si algo falla, el ALTER TABLE también se revierte
ROLLBACK; -- o COMMIT
```

En MySQL, los comandos DDL (`ALTER TABLE`, `CREATE INDEX`, etc.) hacen commit implícito. No puedes revertirlos en una transacción.

### 3. Consultas avanzadas: CTEs, Window Functions, LATERAL

```sql
-- PostgreSQL — CTE recursivo para jerarquías (comentarios anidados, categorías)
WITH RECURSIVE comentarios_hilo AS (
  SELECT id, contenido, padre_id, 0 AS nivel
  FROM comentarios
  WHERE id = 1  -- Comentario raíz
  
  UNION ALL
  
  SELECT c.id, c.contenido, c.padre_id, h.nivel + 1
  FROM comentarios c
  INNER JOIN comentarios_hilo h ON c.padre_id = h.id
)
SELECT * FROM comentarios_hilo ORDER BY nivel;

-- Window Functions (disponibles en ambos, pero más potentes en PG)
SELECT 
  nombre,
  salario,
  AVG(salario) OVER (PARTITION BY departamento) AS media_dept,
  RANK() OVER (PARTITION BY departamento ORDER BY salario DESC) AS ranking
FROM empleados;
```

MySQL soporta CTEs y Window Functions desde v8.0, pero el soporte de PostgreSQL es más completo y maduro.

### 4. Full-Text Search

```sql
-- PostgreSQL FTS nativo — muy potente
SELECT 
  titulo,
  ts_rank(to_tsvector('spanish', contenido), query) AS relevancia
FROM articulos,
     to_tsquery('spanish', 'typescript & javascript') query
WHERE to_tsvector('spanish', contenido) @@ query
ORDER BY relevancia DESC;

-- Con índice GIN para FTS rápido
CREATE INDEX idx_articulos_fts ON articulos 
USING GIN(to_tsvector('spanish', contenido));
```

MySQL tiene FTS pero con capacidades más limitadas. Para un blog, un buscador interno, o una app con búsqueda de contenido, PostgreSQL es superior.

---

## Herramientas y ecosistema

### ORMs con Node.js

| ORM | PostgreSQL | MySQL |
|-----|------------|-------|
| **Prisma** | Soporte completo, recomendado | Soporte completo |
| **Drizzle** | Soporte nativo | Soporte nativo |
| **TypeORM** | Soporte completo | Soporte completo |
| **Sequelize** | Soporte completo | Soporte completo |

Si usas [Supabase vs Firebase](/blog/supabase-vs-firebase-experiencia-produccion-2026/), Supabase te da PostgreSQL gestionado con API REST y Realtime incluidos.

### Servicios gestionados

**PostgreSQL gestionado:**
- **Supabase**: la opción más popular para proyectos JavaScript, tier gratuito generoso
- **Neon**: PostgreSQL serverless, paga por uso
- **Railway**: fácil de configurar, buena DX
- **Render**: opción sólida con tier gratuito
- **AWS RDS**: enterprise, costoso pero muy estable

**MySQL gestionado:**
- **PlanetScale**: MySQL serverless con branching (ahora de pago)
- **AWS RDS MySQL**: muy estable
- **Google Cloud SQL**: buen soporte
- **Hosting compartido**: la mayoría incluye MySQL, menos PostgreSQL

Si haces [self-hosting con Coolify](/blog/coolify-self-hosting-deploy-sin-devops-2026/), ambas bases de datos están disponibles como contenedores con un click.

---

## Cuándo MySQL sigue siendo la respuesta correcta

No todo es PostgreSQL. MySQL tiene ventajas reales en algunos escenarios:

**1. Hosting compartido básico**
Si tu cliente tiene un hosting de 5€/mes, probablemente solo ofrece MySQL (cPanel, Plesk). No vale la pena la discusión.

**2. Proyectos WordPress / PHP**
WordPress es MySQL. El ecosistema PHP está más orientado a MySQL. Si tu stack es PHP, no te compliques.

**3. Equipo con experiencia profunda en MySQL**
Si tu equipo lleva 10 años con MySQL, conoce sus quirks, sabe optimizarlo y tiene procedimientos internos, no hay razón para cambiar por moda.

**4. Replicación master-slave simple**
MySQL tiene una replicación más sencilla de configurar para casos de lectura escalada. PostgreSQL tiene streaming replication pero es más complejo.

---

## Rendimiento: los benchmarks honestos

Los benchmarks de bases de datos son notoriamente difíciles de interpretar porque dependen del workload. Lo que sí puedo decir con experiencia:

**Lecturas simples con índice**: MySQL puede ser marginalmente más rápido (1-10%). En la práctica, irrelevante.

**Escrituras concurrentes**: PostgreSQL gana. El sistema MVCC de PostgreSQL maneja mejor la concurrencia sin bloqueos de tabla.

**Queries complejas (JOINs múltiples, agregaciones, CTEs)**: PostgreSQL gana claramente. El planificador de queries es más sofisticado.

**JSON / datos semi-estructurados**: PostgreSQL con JSONB gana ampliamente.

**Full-text search**: PostgreSQL gana.

La conclusión es que para el 95% de las apps web, el rendimiento de la base de datos no es el cuello de botella — son los índices mal diseñados, las N+1 queries y la falta de caché.

---

## Ejemplo real: migrar un proyecto de MySQL a PostgreSQL

Cuando migré un proyecto de MySQL a PostgreSQL, los cambios más comunes fueron:

```sql
-- MySQL                          -- PostgreSQL
AUTO_INCREMENT                 → SERIAL o GENERATED ALWAYS AS IDENTITY
TINYINT(1)                     → BOOLEAN
TEXT con collation              → TEXT (sin necesidad de especificar)
LIMIT x, y (offset, rows)      → LIMIT y OFFSET x
IFNULL(col, 'default')         → COALESCE(col, 'default')
GROUP BY sin agrupar todo       → Error (PG es más estricto, y correcto)
Backticks `nombre_tabla`       → Comillas dobles "nombre_tabla" (o nada)
```

El cambio más molesto: PostgreSQL es case-sensitive en identificadores sin comillas y los convierte a minúsculas. `SELECT "UserName" FROM Users` en MySQL funciona, en PostgreSQL necesitas comillas si usaste mayúsculas al crear la tabla.

---

## Integración con IA y APIs modernas

Para proyectos que usan IA, PostgreSQL tiene una ventaja significativa: **pgvector**.

```sql
-- Instalar extensión pgvector
CREATE EXTENSION vector;

-- Tabla para embeddings de IA
CREATE TABLE documentos (
  id SERIAL PRIMARY KEY,
  contenido TEXT,
  embedding vector(1536)  -- Dimensiones de OpenAI text-embedding-3-small
);

-- Búsqueda por similitud coseno
SELECT contenido, 1 - (embedding <=> $1) AS similitud
FROM documentos
ORDER BY embedding <=> $1
LIMIT 10;
```

Si construyes un chatbot RAG o un sistema de búsqueda semántica como el que explico en [crear un chatbot RAG con OpenAI](/blog/crear-chatbot-rag-openai-tutorial-2026/), pgvector convierte PostgreSQL en una base de datos vectorial sin necesitar servicios externos como Pinecone o Weaviate.

---

## Veredicto final

```
¿Proyecto nuevo en 2026?
├── ¿Usa WordPress o PHP legacy? → MySQL
├── ¿Solo tienes hosting compartido? → MySQL (o busca mejor hosting)
├── ¿Equipo con experiencia profunda en MySQL? → Quédate en MySQL
└── Cualquier otro caso → PostgreSQL
```

PostgreSQL es mejor en casi todo lo que importa en 2026: tipos de datos modernos, JSONB, arrays, FTS, ACID estricto, pgvector para IA, consultas complejas.

La única razón para elegir MySQL es una razón práctica (ecosistema existente, hosting, equipo) no técnica.

Si estás construyendo un SaaS nuevo, lee [lo que nadie te cuenta sobre construir un SaaS en 2026](/blog/construir-saas-2026-lo-que-nadie-te-cuenta/) — la elección de base de datos es uno de los puntos que trato.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[TypeScript para Desarrolladores JavaScript: La Guía Práctica que Ojalá Hubiera Tenido]]></title>
      <link>https://francobosg.netlify.app/blog/typescript-para-javascript-developers-guia-2026/</link>
      <description><![CDATA[Aprende TypeScript si ya sabes JavaScript: tipos, interfaces, generics y los errores más comunes que cometerás. Sin teoría innecesaria, todo con ejemplos reales de proyectos.]]></description>
      <pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/typescript-para-javascript-developers-guia-2026/</guid>
      <category>TypeScript</category>
      <category>JavaScript</category>
      <category>Código</category>
      <content:encoded><![CDATA[Llevaba 3 años escribiendo JavaScript y convencido de que TypeScript era para proyectos enterprise con 50 desarrolladores. Me equivocaba. Lo adopté en un proyecto mediano y en dos semanas detectó 4 bugs reales que habrían llegado a producción.

Esta es la guía que me habría ahorrado los primeros tropiezos.

## Por qué TypeScript vale la pena (con datos reales)

Antes de entrar en código, el argumento más honesto que puedo darte:

**JavaScript** no te dice que esto es un error hasta que el usuario lo sufre:

```javascript
function calcularPrecio(precio, descuento) {
  return precio - precio * descuento;
}

calcularPrecio("100", 0.1); // "100" - "100" * 0.1 = "100" - 10 = "10010" 🔥
```

**TypeScript** lo detecta antes de ejecutar:

```typescript
function calcularPrecio(precio: number, descuento: number): number {
  return precio - precio * descuento;
}

calcularPrecio("100", 0.1); // Error: Argument of type 'string' is not assignable to parameter of type 'number'
```

El error aparece en tu editor, no en producción.

---

## Paso 1: Instalar y configurar TypeScript

```bash
npm install -D typescript ts-node @types/node
npx tsc --init
```

El `tsconfig.json` básico que uso en todos mis proyectos:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
```

La opción clave es `"strict": true`. Activa todas las comprobaciones importantes. No la desactives aunque duela al principio.

---

## Tipos básicos: el 80% de lo que usarás

```typescript
// Primitivos
const nombre: string = "Fran";
const edad: number = 30;
const activo: boolean = true;

// Arrays
const tags: string[] = ["typescript", "javascript"];
const puntuaciones: number[] = [8, 9, 7];

// Objetos con tipo inline
const usuario: { nombre: string; edad: number } = {
  nombre: "Fran",
  edad: 30
};

// Union types — cuando puede ser más de un tipo
function formatearId(id: string | number): string {
  return id.toString();
}

// Optional — cuando puede no estar
function saludar(nombre: string, apellido?: string): string {
  return apellido ? `${nombre} ${apellido}` : nombre;
}
```

---

## Interfaces vs Types: la pregunta que todo el mundo hace

La respuesta corta: **usa `interface` para objetos, `type` para todo lo demás**.

```typescript
// Interface — ideal para objetos y contratos de clase
interface Usuario {
  id: number;
  nombre: string;
  email: string;
  rol: "admin" | "editor" | "viewer";
}

// Type — ideal para unions, intersecciones y tipos complejos
type Resultado<T> = 
  | { ok: true; datos: T }
  | { ok: false; error: string };

type IdONombre = string | number;
```

La diferencia práctica más importante: las interfaces se pueden **extender y combinar**, los types no se pueden redeclarar:

```typescript
interface Animal {
  nombre: string;
}

interface Animal {
  patas: number; // Válido — TypeScript las fusiona
}

// ✅ Ahora Animal tiene nombre Y patas

type Coche = { marca: string };
type Coche = { modelo: string }; // ❌ Error: Duplicate identifier 'Coche'
```

Para trabajar en proyectos como los que describo en [construir un SaaS desde cero](/blog/construir-saas-2026-lo-que-nadie-te-cuenta/), tener bien tipadas las interfaces de tu dominio es la diferencia entre un código mantenible y un caos.

---

## Generics: el superpoder de TypeScript

Los generics asustan al principio. No son más que **tipos reutilizables con parámetro**:

```typescript
// Sin generics — necesito duplicar para cada tipo
function primeraStringArray(arr: string[]): string {
  return arr[0];
}
function primerNumberArray(arr: number[]): number {
  return arr[0];
}

// Con generics — una función para todos los tipos
function primero<T>(arr: T[]): T {
  return arr[0];
}

primero(["a", "b", "c"]); // TypeScript infiere T = string
primero([1, 2, 3]);       // TypeScript infiere T = number
primero([true, false]);   // TypeScript infiere T = boolean
```

El patrón de generic que más uso en APIs:

```typescript
// Respuesta tipada de API
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

interface Producto {
  id: number;
  nombre: string;
  precio: number;
}

async function fetchProducto(id: number): Promise<ApiResponse<Producto>> {
  const res = await fetch(`/api/productos/${id}`);
  return res.json();
}

const respuesta = await fetchProducto(1);
respuesta.data.nombre; // TypeScript sabe que es string ✅
respuesta.data.precio; // TypeScript sabe que es number ✅
```

---

## Tipado de funciones async y promesas

El punto donde más gente se atasca:

```typescript
// Forma correcta de tipar funciones async
async function obtenerUsuario(id: number): Promise<Usuario> {
  const res = await fetch(`/api/usuarios/${id}`);
  
  if (!res.ok) {
    throw new Error(`HTTP error: ${res.status}`);
  }
  
  return res.json() as Promise<Usuario>;
}

// Con manejo de errores tipado
type ResultadoApi<T> = 
  | { success: true; data: T }
  | { success: false; error: string };

async function obtenerUsuarioSafe(id: number): Promise<ResultadoApi<Usuario>> {
  try {
    const usuario = await obtenerUsuario(id);
    return { success: true, data: usuario };
  } catch (error) {
    return { 
      success: false, 
      error: error instanceof Error ? error.message : "Error desconocido"
    };
  }
}
```

Este patrón se combina muy bien con [proteger tu API con JWT en Node.js](/blog/proteger-api-nodejs-jwt-auth-guia-2026/), donde los tipos te obligan a manejar todos los casos de error.

---

## Errores comunes y cómo evitarlos

### ❌ Error 1: Usar `any` para todo

```typescript
// Mal — pierdes todo el beneficio de TypeScript
function procesarDatos(datos: any) {
  return datos.nombre.toUpperCase(); // Runtime error si datos no tiene nombre
}

// Bien — usa unknown y valida
function procesarDatos(datos: unknown) {
  if (typeof datos === "object" && datos !== null && "nombre" in datos) {
    const { nombre } = datos as { nombre: string };
    return nombre.toUpperCase();
  }
  throw new Error("Datos inválidos");
}
```

### ❌ Error 2: No usar type guards

```typescript
// Mal — TypeScript no sabe qué tipo es
function procesarEvento(evento: MouseEvent | KeyboardEvent) {
  console.log(evento.key); // Error: 'key' no existe en MouseEvent
}

// Bien — type guard
function procesarEvento(evento: MouseEvent | KeyboardEvent) {
  if (evento instanceof KeyboardEvent) {
    console.log(evento.key); // ✅ TypeScript sabe que es KeyboardEvent
  } else {
    console.log(evento.clientX); // ✅ TypeScript sabe que es MouseEvent
  }
}
```

### ❌ Error 3: `as` para silenciar errores

```typescript
// Trampa — le dices a TypeScript que confíe en ti ciegamente
const usuario = await fetch("/api/user").then(r => r.json()) as Usuario;
usuario.nombre; // TypeScript confía en ti, pero puede ser undefined en runtime

// Mejor — valida con zod o una función de validación
import { z } from "zod";

const UsuarioSchema = z.object({
  id: z.number(),
  nombre: z.string(),
  email: z.string().email()
});

const datos = await fetch("/api/user").then(r => r.json());
const usuario = UsuarioSchema.parse(datos); // Lanza error si no coincide
```

---

## TypeScript con React y Next.js

En proyectos React, el tipado más usado:

```typescript
import { FC, ReactNode, useState, useEffect } from "react";

// Componente tipado
interface TarjetaProductoProps {
  producto: Producto;
  onAgregar: (id: number) => void;
  children?: ReactNode;
}

const TarjetaProducto: FC<TarjetaProductoProps> = ({ producto, onAgregar, children }) => {
  return (
    <div className="card">
      <h2>{producto.nombre}</h2>
      <p>{producto.precio}€</p>
      <button onClick={() => onAgregar(producto.id)}>Añadir al carrito</button>
      {children}
    </div>
  );
};

// useState con tipo explícito
const [productos, setProductos] = useState<Producto[]>([]);
const [loading, setLoading] = useState<boolean>(false);

// useEffect tipado
useEffect(() => {
  const cargar = async () => {
    const data = await fetchProductos();
    setProductos(data);
  };
  cargar();
}, []);
```

Si usas [Auth.js / NextAuth con Next.js](/blog/auth-js-nextauth-implementacion-real-2026/), TypeScript te permite tipar la sesión correctamente y evitar acceder a campos que no existen.

---

## TypeScript con Node.js y Express

```typescript
import express, { Request, Response, NextFunction } from "express";

// Extender el tipo Request para añadir propiedades custom
declare global {
  namespace Express {
    interface Request {
      usuario?: Usuario;
    }
  }
}

// Middleware tipado
const autenticar = (req: Request, res: Response, next: NextFunction): void => {
  const token = req.headers.authorization?.split(" ")[1];
  
  if (!token) {
    res.status(401).json({ error: "Token requerido" });
    return;
  }
  
  // Verificar token...
  req.usuario = { id: 1, nombre: "Fran", email: "fran@example.com", rol: "admin" };
  next();
};

// Route handler tipado
const obtenerPerfil = async (req: Request, res: Response): Promise<void> => {
  if (!req.usuario) {
    res.status(401).json({ error: "No autenticado" });
    return;
  }
  
  res.json({ usuario: req.usuario });
};
```

---

## Herramientas esenciales del ecosistema TypeScript

| Herramienta | Para qué sirve |
|-------------|----------------|
| **Zod** | Validación de datos en runtime con inferencia de tipos |
| **tRPC** | APIs tipadas end-to-end sin código extra |
| **Prisma** | ORM con tipos generados automáticamente desde el schema |
| **ts-node** | Ejecutar TypeScript directamente sin compilar |
| **tsx** | Alternativa más rápida a ts-node |
| **type-fest** | Utilidades de tipos para casos avanzados |

Para proyectos que usan IA, TypeScript brilla especialmente: puedes tipar las respuestas de la API de OpenAI o Claude y asegurarte de que [parseas el JSON correctamente](/blog/parsear-json-ia-sin-errores-openai-claude-2026/).

---

## Plan de aprendizaje en 4 semanas

**Semana 1**: Tipos básicos, interfaces, funciones tipadas. Convierte un archivo JS tuyo a TS.

**Semana 2**: Generics básicos, union types, type guards, async/await. Convierte un módulo completo.

**Semana 3**: Tipos de utilidad (`Partial`, `Required`, `Pick`, `Omit`, `Record`). Empieza a usar Zod.

**Semana 4**: Tipos condicionales, infer, decorators si usas NestJS. Proyecto completo en TS.

El objetivo no es entender TODO TypeScript. Es eliminar el 90% de los errores de tipo que te llegarían a producción.

---

## Recursos adicionales

- Si usas IA para programar, leer código TypeScript tipado le da **mucho más contexto** a herramientas como Cursor o Copilot — te genera código mejor
- TypeScript es casi obligatorio si sigues los patrones de [NestJS sobre Express](/blog/por-que-nestjs-sobre-express-saas-2026/)
- Para estructurar bien tus carpetas TypeScript en proyectos React, echa un vistazo a [estructura de carpetas en React/Next.js](/blog/estructura-carpetas-react-nextjs-2026/)

TypeScript no es una carga — es un radar que detecta los misiles antes de que impacten. Cuanto antes lo adoptes, más bugs evitarás sin esfuerzo adicional.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Variables de Entorno en Node.js y Next.js: La Guía Completa para No Filtrar Secretos]]></title>
      <link>https://francobosg.netlify.app/blog/variables-de-entorno-nodejs-nextjs-guia-2026/</link>
      <description><![CDATA[Aprende a gestionar variables de entorno en Node.js, Next.js y Vite correctamente: .env files, seguridad, validación con Zod, y cómo evitar filtrar claves API a producción o al repositorio.]]></description>
      <pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/variables-de-entorno-nodejs-nextjs-guia-2026/</guid>
      <category>Node.js</category>
      <category>Backend</category>
      <category>Seguridad</category>
      <content:encoded><![CDATA[Cometí este error en mi primer proyecto: subí el `.env` a GitHub con la clave de la API de OpenAI. En 20 minutos había bots escaneando el repo y consumiendo mi crédito. La lección fue cara.

Esta guía cubre todo lo que necesitas saber sobre variables de entorno para no cometer los mismos errores.

## Lo primero: el .gitignore correcto

Antes de cualquier otra cosa, asegúrate de que tu `.gitignore` tiene esto:

```gitignore
# Variables de entorno — NUNCA subir al repo
.env
.env.local
.env.*.local
.env.development.local
.env.test.local
.env.production.local

# Sí puedes subir estos (sin secretos reales):
# .env.example
# .env.development (solo si no tiene secretos)
```

El archivo `.env.example` es la convención para documentar qué variables necesita el proyecto sin revelar los valores reales:

```bash
# .env.example — sí va al repositorio
DATABASE_URL=postgresql://user:password@localhost:5432/miapp
OPENAI_API_KEY=sk-...
JWT_SECRET=cambia-esto-por-una-cadena-aleatoria-larga
REDIS_URL=redis://localhost:6379
NEXT_PUBLIC_APP_URL=http://localhost:3000
```

---

## Node.js: cómo cargar variables de entorno

### Opción 1: Node.js 20.6+ nativo (sin dependencias)

```bash
node --env-file=.env src/index.js

# Para múltiples archivos
node --env-file=.env --env-file=.env.local src/index.js
```

### Opción 2: dotenv (la forma clásica)

```bash
npm install dotenv
```

```javascript
// src/index.js — primera línea del entry point
require("dotenv").config();

// Si usas ES Modules
import "dotenv/config";

// Ya puedes acceder a las variables
const dbUrl = process.env.DATABASE_URL;
const apiKey = process.env.OPENAI_API_KEY;
```

### Opción 3: dotenv con configuración avanzada

```javascript
import dotenv from "dotenv";
import path from "path";

dotenv.config({
  path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`),
});

// Carga .env.development, .env.production, etc.
```

---

## Next.js: el sistema de variables de entorno automático

Next.js carga las variables automáticamente según el entorno, sin instalar dotenv:

```
.env                  → Siempre (base)
.env.local            → Siempre (sobreescribe, no va a git)
.env.development      → Solo con next dev
.env.production       → Solo con next build / next start
.env.test             → Solo con jest / vitest
```

**La regla de oro de Next.js**: solo las variables con prefijo `NEXT_PUBLIC_` llegan al cliente.

```bash
# .env
DATABASE_URL=postgresql://...          # Solo servidor ✅
JWT_SECRET=mi-secreto                  # Solo servidor ✅
OPENAI_API_KEY=sk-...                  # Solo servidor ✅

NEXT_PUBLIC_APP_URL=https://mi-app.com # Cliente Y servidor ⚠️
NEXT_PUBLIC_STRIPE_KEY=pk_live_...     # Cliente Y servidor ⚠️
```

```typescript
// En un Server Component o API Route — seguro
const secreto = process.env.JWT_SECRET; // ✅

// En un Client Component — PELIGRO
const secreto = process.env.JWT_SECRET; // undefined en cliente, no explota pero no funciona
const publica = process.env.NEXT_PUBLIC_APP_URL; // ✅ Funciona en cliente
```

---

## Validar variables de entorno con Zod (imprescindible)

El problema con `process.env` es que siempre devuelve `string | undefined`. Si falta una variable crítica, tu app falla en runtime con un error críptico, no al arrancar.

La solución: validar las variables al arrancar la app con Zod.

```typescript
// src/env.ts — valida al importar, falla rápido si falta algo
import { z } from "zod";

const envSchema = z.object({
  // Base de datos
  DATABASE_URL: z.string().url("DATABASE_URL debe ser una URL válida"),
  
  // Auth
  JWT_SECRET: z.string().min(32, "JWT_SECRET debe tener al menos 32 caracteres"),
  
  // APIs externas
  OPENAI_API_KEY: z.string().startsWith("sk-"),
  
  // Opcionales con defaults
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  
  // Variables públicas (Next.js)
  NEXT_PUBLIC_APP_URL: z.string().url().optional(),
});

// Valida al importar — si falta algo, falla inmediatamente con mensaje claro
const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error("❌ Variables de entorno inválidas:");
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;
```

Ahora en lugar de `process.env.DATABASE_URL` (string | undefined), usas `env.DATABASE_URL` (string garantizado):

```typescript
import { env } from "@/env";

// TypeScript sabe que es string, no puede ser undefined
const db = new PrismaClient({ datasources: { db: { url: env.DATABASE_URL } } });
```

Si usas [TypeScript en tu proyecto](/blog/typescript-para-javascript-developers-guia-2026/), este patrón es especialmente poderoso porque obtienes autocompletado de todas tus variables de entorno.

---

## Gestión de secretos en producción

El `.env` es solo para desarrollo local. En producción, nunca subas un archivo `.env` al servidor.

### En Vercel

```bash
# Por interfaz web o CLI
vercel env add DATABASE_URL production
vercel env add JWT_SECRET production
```

### En Netlify

```bash
netlify env:set DATABASE_URL "postgresql://..."
netlify env:set JWT_SECRET "mi-secreto-largo"
```

### En Docker / VPS

```bash
# docker-compose.yml — nunca hardcodees valores reales aquí
services:
  app:
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - JWT_SECRET=${JWT_SECRET}
    env_file:
      - .env.production  # Este archivo va en el servidor, no en el repo
```

Si haces [self-hosting con Coolify](/blog/coolify-self-hosting-deploy-sin-devops-2026/), tiene una interfaz para gestionar variables de entorno por proyecto directamente desde el panel.

### En GitHub Actions

```yaml
# .github/workflows/deploy.yml
env:
  DATABASE_URL: ${{ secrets.DATABASE_URL }}
  JWT_SECRET: ${{ secrets.JWT_SECRET }}
```

Los secretos se configuran en GitHub → Repositorio → Settings → Secrets and variables → Actions.

---

## Organizar variables por entorno

Para proyectos con múltiples entornos (development, staging, production):

```bash
# Estructura recomendada
.env.example          # Documentación — sí al repo
.env                  # Valores locales — NO al repo
.env.staging          # Valores staging — NO al repo (o sí si no tiene secretos reales)
.env.production       # NUNCA en el repo
```

Un truco para cambiar entre entornos en local:

```bash
# package.json
{
  "scripts": {
    "dev": "node --env-file=.env src/index.js",
    "dev:staging": "node --env-file=.env.staging src/index.js",
    "build": "tsc",
    "start": "NODE_ENV=production node dist/index.js"
  }
}
```

---

## Seguridad: errores que no debes cometer

### ❌ Error 1: Loguear las variables de entorno

```javascript
// NUNCA hagas esto en producción
console.log("Config:", process.env); // Filtra todos los secretos a los logs

// Bien
console.log("Database conectado:", env.DATABASE_URL.split("@")[1]); // Solo el host
```

### ❌ Error 2: Exponer variables en el frontend

```typescript
// ❌ MAL — en Next.js, esto expone la clave al bundle del cliente
export const config = {
  apiKey: process.env.OPENAI_API_KEY, // Undefined en cliente, pero el nombre ya da info
};

// ✅ BIEN — crear un proxy API en el servidor
// pages/api/chat.ts
export default async function handler(req, res) {
  const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // Solo servidor
  // ...
}
```

Esto es especialmente importante si usas APIs de IA como explico en [usar la API de ChatGPT o Claude](/blog/usar-api-chatgpt-claude-gratis-2026/) — la clave nunca debe llegar al cliente.

### ❌ Error 3: Hardcodear secretos aunque sean temporales

```javascript
// NUNCA — aunque sea "solo para probar"
const apiKey = "sk-proj-abc123..."; // Acaba en git, acaba filtrado

// SIEMPRE variables de entorno
const apiKey = process.env.OPENAI_API_KEY;
```

### ❌ Error 4: Usar el mismo secreto en dev y producción

```bash
# .env (desarrollo)
JWT_SECRET=desarrollo-secreto-simple

# .env.production (producción — secreto diferente y largo)
JWT_SECRET=xK9mP2vQ8nR4tY7wZ1bA6cD3eF5gH0iJ
```

Los secretos de producción deben ser distintos, aleatorios y largos. Puedes generarlos con:

```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```

---

## Qué hacer si filtraste un secreto a GitHub

1. **Rota la clave inmediatamente** — no esperes, ve al panel de la API y genera una nueva
2. **Revoca la clave comprometida** — desactívala en el proveedor
3. **Limpia el historial de git**:

```bash
# Instala BFG Repo Cleaner
# https://rtyley.github.io/bfg-repo-cleaner/

# Borra el archivo del historial completo
java -jar bfg.jar --delete-files .env .git

# O borra un valor específico
java -jar bfg.jar --replace-text passwords.txt .git

git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force
```

4. **Añade `gitleaks`** a tu flujo para detectar futuros problemas antes de hacer push:

```bash
# Pre-commit hook con gitleaks
gitleaks detect --source . --exit-code 1
```

---

## Herramientas del ecosistema

| Herramienta | Para qué sirve |
|-------------|----------------|
| **dotenv** | Cargar .env en Node.js |
| **@t3-oss/env-nextjs** | Validación de env con Zod integrada para Next.js |
| **zod** | Validar el schema de variables |
| **1Password CLI** | Inyectar secretos desde un gestor de contraseñas |
| **Doppler** | Gestión centralizada de secretos para equipos |
| **gitleaks** | Detectar secretos en código fuera del repositorio |
| **HashiCorp Vault** | Gestión enterprise de secretos |

Para proyectos personales, la combinación Zod + `.env.local` + variables en Vercel/Netlify es más que suficiente.

Para equipos o proyectos con múltiples servicios, considera Doppler o 1Password Teams para centralizar los secretos.

Si usas [protección de API con JWT en Node.js](/blog/proteger-api-nodejs-jwt-auth-guia-2026/), la gestión correcta del `JWT_SECRET` que aquí describes es el primer paso crítico de seguridad.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Construir un SaaS en 2026: Lo que Nadie te Cuenta Antes de Empezar]]></title>
      <link>https://francobosg.netlify.app/blog/construir-saas-2026-lo-que-nadie-te-cuenta/</link>
      <description><![CDATA[He lanzado dos SaaS en el último año. Aquí van las decisiones técnicas, los errores caros y lo que haría diferente: arquitectura, autenticación, multitenancy, pricing, despliegue y lo que nadie menciona.]]></description>
      <pubDate>Thu, 23 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/construir-saas-2026-lo-que-nadie-te-cuenta/</guid>
      <category>SaaS</category>
      <category>Arquitectura</category>
      <category>Backend</category>
      <category>Full-Stack</category>
      <category>NestJS</category>
      <category>FastAPI</category>
      <category>Docker</category>
      <category>Negocio</category>
      <content:encoded><![CDATA[He lanzado dos SaaS en el último año: [Atrapaclientes](/blog/caso-real-saas-atrapaclientes-nestjs-react/) (NestJS + React, gestión de campañas multitenant) y [iECO](/blog/caso-real-ia-reuniones-gemini-supabase/) (FastAPI + Next.js, SaaS de IA para reuniones). Ambos están en producción con clientes reales.

Esto es lo que me habría ahorrado tiempo saber antes de empezar.

---

## 1. La arquitectura que no puedes cambiar después

Hay dos decisiones que si las tomas mal te obligan a reescribir el proyecto entero:

### Multitenancy desde el día 1

Si tu SaaS tiene múltiples empresas o usuarios con datos separados, el `company_id` (o `tenant_id`) tiene que estar en **todas** las tablas desde el principio. No "cuando lo necesite". Ahora.

```sql
-- MAL: añadir tenant_id después
ALTER TABLE meetings ADD COLUMN company_id UUID;
-- Ahora tienes que hacer data migration, actualizar todos los queries,
-- añadir índices, revisar todos los endpoints... 2-3 días de trabajo.

-- BIEN: desde el diseño inicial
CREATE TABLE meetings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  company_id UUID NOT NULL REFERENCES companies(id),
  title VARCHAR(255) NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_meetings_company_id ON meetings(company_id);
```

Y lo más importante: nunca filtres por `company_id` en el controller. Hazlo en una capa de middleware que lo inyecte automáticamente:

```typescript
// NestJS: Guard que inyecta el tenant en cada request
@Injectable()
export class TenantGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    req.companyId = req.user.companyId; // del JWT
    return true;
  }
}

// El service solo ve los datos de su tenant
async findAll(companyId: string): Promise<Meeting[]> {
  return this.repo.find({ where: { companyId } });
}
```

Si te olvidas de añadir el filtro en un endpoint, tienes un data leak entre clientes. El guard lo hace imposible de olvidar.

> Para ver este patrón en acción en un proyecto real, revisa [Caso Real: SaaS Multitenant con NestJS y React](/blog/caso-real-saas-atrapaclientes-nestjs-react/).

### Monolito modular, no microservicios

Todos los tutoriales de SaaS en YouTube muestran microservicios. La realidad:

- Con microservicios necesitas service discovery, comunicación entre servicios (gRPC/mensajería), distributed tracing, y cada servicio tiene su propia CI/CD, logs y monitoring.
- Un solo developer o equipo pequeño no tiene tiempo para todo eso.

**Lo que funciona en la práctica:**

```
src/
  modules/
    auth/          # JWT, refresh tokens, RBAC
    companies/     # Gestión de tenants
    users/         # Usuarios por tenant
    billing/       # Stripe, suscripciones
    [feature]/     # Módulos de negocio
  common/
    guards/        # TenantGuard, AuthGuard, RolesGuard
    decorators/    # @CurrentUser(), @Roles()
    filters/       # Manejo global de errores
```

Cada módulo es independiente. Si en el futuro necesitas extraer `billing` como microservicio, lo puedes hacer. Pero no lo hagas prematuramente.

---

## 2. El auth que parece simple y no lo es

JWT de "Hello World" son 30 minutos. JWT de producción en un SaaS son 2-3 días.

Lo que necesitas realmente:

```typescript
// Lista de lo que necesitas implementar, no un "npm install passport"

// ✅ Access token (15-60 min expiración)
// ✅ Refresh token (7-30 días, rotación en cada uso)
// ✅ Revocación de refresh tokens (tabla en BD o Redis)
// ✅ Logout en todos los dispositivos
// ✅ Rate limiting en /auth/login
// ✅ Bloqueo por intentos fallidos
// ✅ Roles: superadmin / company_admin / company_user
// ✅ Permisos granulares dentro de cada rol
// ✅ Middleware que verifica rol en cada endpoint
```

El flujo de refresh tokens es lo que más gente omite y luego duele:

```typescript
@Post('refresh')
async refresh(@Body() dto: RefreshTokenDto) {
  // 1. Verificar que el refresh token existe en BD y no está revocado
  const stored = await this.tokenRepo.findOne({ token: dto.refreshToken });
  if (!stored || stored.revokedAt) throw new UnauthorizedException();

  // 2. Verificar expiración
  if (stored.expiresAt < new Date()) throw new UnauthorizedException();

  // 3. Rotación: revocar el anterior, crear uno nuevo
  await this.tokenRepo.update(stored.id, { revokedAt: new Date() });
  const newRefreshToken = await this.tokenRepo.save({
    userId: stored.userId,
    token: generateSecureToken(),
    expiresAt: addDays(new Date(), 30),
  });

  return {
    accessToken: this.jwt.sign({ sub: stored.userId }),
    refreshToken: newRefreshToken.token,
  };
}
```

> Para una guía completa de JWT en Node.js: [Cómo Proteger una API Node.js con JWT](/blog/proteger-api-nodejs-jwt-auth-guia-2026/).

---

## 3. El flujo de registro que nadie piensa

En un SaaS multitenant, el flujo de registro no es solo "email + contraseña". Necesitas decidir:

**¿Registro libre o con aprobación?**
- Registro libre: el usuario se registra y accede inmediatamente. Simple, bueno para B2C.
- Con aprobación: el usuario solicita acceso, un admin lo aprueba. Necesario cuando el acceso tiene coste o cuando quieres controlar quién usa el producto.

El flujo con aprobación que implementé en iECO:

```
Usuario rellena formulario de solicitud
    ↓
Estado: PENDING (no puede hacer login)
    ↓
Notificación al superadmin
    ↓
Superadmin aprueba → Estado: ACTIVE → email de confirmación
Superadmin rechaza → Estado: REJECTED → email de rechazo
    ↓
Usuario activo puede hacer login
```

En la BD:

```sql
CREATE TYPE user_status AS ENUM ('pending', 'active', 'rejected', 'suspended');

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email VARCHAR(255) UNIQUE NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  status user_status DEFAULT 'pending',
  company_id UUID REFERENCES companies(id),
  role VARCHAR(50) DEFAULT 'company_user',
  approved_at TIMESTAMPTZ,
  approved_by UUID REFERENCES users(id)
);
```

---

## 4. El panel de admin que necesitarás aunque no lo planifiques

Todo SaaS acaba necesitando un panel de administración. Si no lo planificas, lo construirás reactivamente cuando un cliente llame diciendo que no puede entrar.

Lo mínimo viable:

- **Gestión de usuarios**: listar, suspender, cambiar rol, resetear contraseña
- **Gestión de empresas** (si es multitenant): crear, editar, suspender tenant
- **Solicitudes pendientes**: aprobar/rechazar registros
- **Logs básicos**: últimos accesos, errores críticos

Un truco: separa claramente los endpoints de admin con un prefijo y un guard dedicado:

```typescript
@Controller('admin')
@UseGuards(AuthGuard, RolesGuard)
@Roles('superadmin')
export class AdminController {
  // Solo superadmin puede acceder aquí
}
```

---

## 5. El despliegue: self-hosted es la opción más inteligente a escala pequeña

Los costes de Vercel/Railway/Render escalan rápido. Para un SaaS small con <50.000 visitas/mes:

| Opción | Coste/mes | Control | Setup |
|--------|-----------|---------|-------|
| Vercel Pro | $20+ | Bajo | Mínimo |
| Railway | $5-30+ | Medio | Fácil |
| Hetzner VPS + Coolify | ~5€ | Total | 2-3h inicial |

Con Coolify en un VPS tienes SSL automático, deploys desde GitHub, gestión de variables de entorno y todas las apps que quieras en el mismo servidor.

> Guía completa: [Coolify: Despliega tus Apps sin ser DevOps](/blog/coolify-self-hosting-deploy-sin-devops-2026/) y el [Análisis de costes Vercel vs VPS](/blog/vercel-vs-vps-coste-real-nextjs-2026/).

---

## 6. Lo que nadie menciona: el tiempo no técnico

El código es la parte fácil. Lo que no verás en tutoriales:

**Stripe y los impuestos**: integrar Stripe Billing para suscripciones recurrentes lleva 1 semana. Gestionar IVA para clientes europeos, facturas, créditos y upgrades/downgrades: otra semana más.

**Los emails transaccionales**: registro, bienvenida, aprobación, factura, aviso de pago fallido, recordatorio de trial... fácilmente 15-20 emails distintos. Cada uno con HTML que funcione en Outlook y en Gmail. No los subestimes.

**El onboarding**: el usuario que se registra a las 11 de la noche y no entiende cómo funciona el producto va a abrir un ticket. El onboarding (guía inicial, tooltips, email de día 1, email de día 7) reduce el churn más que cualquier feature nueva.

**El GDPR**: para clientes europeos necesitas política de privacidad, aviso de cookies, mecanismo de borrado de cuenta y portabilidad de datos. No es opcional.

---

## Stack que volvería a usar

Después de dos SaaS en producción, esto es lo que elegiría hoy:

```
Backend:   NestJS + TypeORM + PostgreSQL
           (FastAPI si hay mucho procesamiento IA)
Frontend:  Next.js 15 + TypeScript + Tailwind CSS + shadcn/ui
Auth:      JWT propio (access + refresh) o Auth.js para auth social
Pagos:     Stripe Billing
Email:     Resend + React Email
Deploy:    Hetzner VPS + Coolify
Monitoring: Sentry (errores) + Plausible (analytics)
```

Lo que **no** volvería a usar para un SaaS pequeño:
- Microservicios (complejidad innecesaria)
- Supabase Auth (pierde control sobre los tokens y los roles)
- MongoDB (las relaciones entre datos en un SaaS son relacionales por naturaleza)

---

## Por dónde empezar

Si vas a construir un SaaS hoy:

1. Define los roles y el multitenancy **antes** de escribir código
2. Diseña el schema de BD con `tenant_id` desde el día 1
3. Implementa auth completo (access + refresh + roles) en la primera semana
4. Construye el panel de admin básico en paralelo con el producto
5. Despliega en VPS desde el inicio, no esperes a tener clientes

El orden importa. El multitenancy y el auth son los únicos elementos que no puedes añadir sin reescribir. Todo lo demás (features, UI, integraciones) se puede añadir después.

---

## Recursos relacionados

- [Caso Real: SaaS IA para Reuniones — iECO (FastAPI + Next.js + Multitenancy)](/blog/caso-real-ia-reuniones-gemini-supabase/)
- [Caso Real: SaaS Multitenant con NestJS y React — Atrapaclientes](/blog/caso-real-saas-atrapaclientes-nestjs-react/)
- [Por qué NestJS sobre Express para un SaaS](/blog/por-que-nestjs-sobre-express-saas-2026/)
- [Proteger una API Node.js con JWT: guía real](/blog/proteger-api-nodejs-jwt-auth-guia-2026/)
- [Coolify: Despliega tus Apps sin ser DevOps](/blog/coolify-self-hosting-deploy-sin-devops-2026/)
- [Vercel vs VPS: el coste real de desplegar Next.js](/blog/vercel-vs-vps-coste-real-nextjs-2026/)
- [Docker para Developers: guía práctica sin teoría innecesaria](/blog/docker-para-developers-guia-practica-2026/)]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Coolify: Despliega tus Apps con Docker sin ser DevOps (2026)]]></title>
      <link>https://francobosg.netlify.app/blog/coolify-self-hosting-deploy-sin-devops-2026/</link>
      <description><![CDATA[Guía práctica para montar Coolify en un VPS desde cero: instala el servidor, conecta tu repositorio y despliega con SSL automático. Sin saber Kubernetes ni DevOps avanzado.]]></description>
      <pubDate>Thu, 23 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/coolify-self-hosting-deploy-sin-devops-2026/</guid>
      <category>Docker</category>
      <category>DevOps</category>
      <category>Self-hosting</category>
      <category>Tutorial</category>
      <category>Coolify</category>
      <category>Traefik</category>
      <category>Backend</category>
      <content:encoded><![CDATA[Llevo usando Coolify en producción para dos proyectos propios. El resultado: pago ~5€/mes por el VPS y tengo 4 apps desplegadas con SSL automático, deploys automáticos al hacer push y monitorización básica.

Antes pagaba ~$40/mes entre Vercel Pro y Railway. El cambio mereció la pena.

## Qué es Coolify (y qué no es)

Coolify no es un orquestador de contenedores como Kubernetes. Es una capa de abstracción sobre Docker y Traefik que te da una interfaz web para hacer lo que harías manualmente con `docker-compose.yml` y configuración de nginx.

**Lo que hace por ti:**
- Genera y gestiona los `docker-compose.yml` automáticamente
- Configura Traefik como reverse proxy con SSL Let's Encrypt
- Conecta con GitHub/GitLab para deploys automáticos en cada push
- Gestiona variables de entorno de forma segura
- Logs en tiempo real desde la interfaz
- Deploy de bases de datos con 1 click (PostgreSQL, Redis, etc.)

**Lo que NO hace:**
- Escalar horizontalmente entre varios servidores (para eso necesitas Kubernetes)
- Gestionar clusters complejos
- Sustituir un DevOps para aplicaciones con requisitos críticos de disponibilidad

Para un SaaS small/medium, para proyectos propios o para apps de clientes sin millones de usuarios: perfecto.

---

## El servidor: qué necesitas

Cualquier VPS con Debian 12 o Ubuntu 22/24 LTS. Lo mínimo recomendado:

| Recurso | Mínimo | Recomendado |
|---------|--------|-------------|
| CPU | 1 vCPU | 2 vCPU |
| RAM | 2 GB | 4 GB |
| Disco | 20 GB | 40 GB SSD |
| OS | Debian 12 / Ubuntu 22 LTS | Debian 12 |

En Hetzner, el **CX22** (2 vCPU, 4 GB RAM, 40 GB SSD) cuesta ~4.50€/mes. Es lo que uso para correr Coolify + 3 apps + 2 bases de datos.

> Si quieres comparar costes reales de self-hosting vs plataformas como Vercel, lee [Vercel vs VPS: Cuánto Cuesta Realmente una App Next.js](/blog/vercel-vs-vps-coste-real-nextjs-2026/).

---

## Instalación paso a paso

### 1. Prepara el servidor (10 min)

Entra por SSH como root y crea un usuario no-root:

```bash
# Crear usuario
adduser francobosg
usermod -aG sudo francobosg

# Copiar SSH keys al nuevo usuario
rsync --archive --chown=francobosg:francobosg ~/.ssh /home/francobosg

# Desde ahora, usa este usuario
```

Configura el firewall básico:

```bash
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 8000/tcp   # Puerto de Coolify
ufw enable
```

### 2. Instala Docker

```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Cierra y vuelve a abrir la sesión SSH
```

Verifica:

```bash
docker run hello-world
# Hello from Docker!
```

> Si es tu primera vez con Docker, revisa [Docker para Desarrolladores: Guía Práctica sin Teoría Innecesaria](/blog/docker-para-developers-guia-practica-2026/) antes de continuar.

### 3. Instala Coolify (2 min)

```bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
```

Eso es todo. El script instala Docker si no lo tienes, configura Traefik como reverse proxy y arranca el panel de Coolify.

Abre `http://TU_IP:8000` en el navegador. Verás el wizard de setup inicial.

---

## Configuración inicial de Coolify

### Cuenta de admin

En el wizard, crea tu cuenta de administrador. Email y contraseña robusta — esta es la única cuenta con acceso total.

### Apunta tu dominio

Antes de configurar SSL necesitas:
1. Un dominio (o subdominio) apuntando a la IP de tu servidor
2. Esperar a que propague (5-60 min)

En Coolify: **Settings → General** → pon tu dominio en "Instance Domain". Coolify generará automáticamente SSL para `coolify.tudominio.com`.

### Conecta GitHub

**Sources → Add → GitHub App**

Coolify crea una GitHub App en tu cuenta con permisos mínimos (solo read de repos). Así puede hacer checkout del código y configurar webhooks para deploys automáticos.

---

## Desplegar tu primera app

### Ejemplo: API FastAPI

1. **New Project → New Resource → Application**
2. Selecciona el repo de GitHub
3. Branch: `main`
4. Build Pack: `Dockerfile` (o Nixpacks si no tienes Dockerfile)

Si tienes un `Dockerfile` en la raíz del repo:

```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
```

Coolify detecta el puerto expuesto (`8080`) y configura el reverse proxy automáticamente.

5. **Domain**: `api.tudominio.com` — Coolify genera el certificado SSL solo
6. **Variables de entorno**: añade `DATABASE_URL`, `SECRET_KEY`, etc. de forma segura
7. **Deploy** — el primer deploy tarda 2-3 min (build de la imagen)

Desde ese momento, cada `git push` a `main` dispara un deploy automático.

### Desplegar PostgreSQL

No instales PostgreSQL directamente en el servidor. Usa el servicio de Coolify:

**New Resource → Database → PostgreSQL**

Coolify levanta un contenedor PostgreSQL con:
- Contraseña generada automáticamente
- Volumen persistente para datos
- Red interna de Docker (no expuesta al exterior por defecto)

Copia la connection string interna (`postgresql://user:pass@postgres:5432/db`) y pégala en las variables de entorno de tu app.

---

## Deploys automáticos y rollback

Coolify guarda las últimas N builds. Si un deploy rompe algo:

1. Panel de la app → **Deployments**
2. Elige una build anterior
3. **Redeploy** — vuelves al estado anterior en 30 segundos

Para deploys en ramas de feature antes de mergear a main:

```yaml
# En Coolify: configurar Preview Deployments
# Cada PR genera una URL temporal: pr-123.tudominio.com
```

---

## Monitorización básica

Coolify incluye monitorización básica out of the box:
- **Logs**: streaming en tiempo real de cada contenedor
- **Métricas**: CPU y RAM del servidor (gráfica simple)
- **Health checks**: reinicia el contenedor automáticamente si falla

Para monitorización avanzada puedes conectar Prometheus + Grafana como servicio adicional en Coolify, o usar Plausible (que también se despliega en Coolify) para métricas de usuario.

---

## Lo que aprendí usándolo en producción

**1. El primer setup tarda más de lo que dicen.** El script de instalación son 5 min, pero preparar el servidor, configurar el DNS, crear la GitHub App y hacer el primer deploy bien configurado son fácilmente 2-3 horas la primera vez.

**2. Usa dominios internos para las bases de datos.** PostgreSQL y Redis no deben estar expuestos con dominio público. La connection string interna de Docker (`postgres:5432`) es más rápida y segura.

**3. Los volumes de Docker son tu backup.** Los datos de las BDs están en volumes de Docker. Haz snapshots del VPS periódicamente o configura backups automáticos en Coolify.

**4. Nixpacks es magia para apps sin Dockerfile.** Para un proyecto Next.js o FastAPI sin Dockerfile, Nixpacks detecta el framework y construye la imagen automáticamente. Funciona el 90% de las veces.

**5. Coolify no reemplaza conocer Docker.** Si algo falla, necesitas saber qué es un contenedor, cómo ver logs y qué hace un `docker-compose.yml`. No entres en producción sin haber leído antes [Docker para Desarrolladores](/blog/docker-para-developers-guia-practica-2026/).

---

## ¿Cuándo no usar Coolify?

- **Alta disponibilidad real** (99.99% uptime): necesitas Kubernetes + múltiples nodos
- **Equipo de DevOps dedicado**: probablemente ya tienen su stack
- **Apps con requisitos de compliance estrictos** (PCI-DSS, HIPAA): los managed services tienen certificaciones que tú tendrías que conseguir solo

Para todo lo demás — proyectos propios, SaaS small, apps de clientes, prototipos en producción — Coolify es la mejor relación calidad/precio que he encontrado.

---

## Recursos

- [Documentación oficial de Coolify](https://coolify.io/docs)
- [Vercel vs VPS: comparativa de costes reales](/blog/vercel-vs-vps-coste-real-nextjs-2026/)
- [Docker para Developers: guía práctica sin teoría innecesaria](/blog/docker-para-developers-guia-practica-2026/)
- [Caso real: SaaS de IA con Coolify en servidor dedicado montado desde cero](/blog/caso-real-ia-reuniones-gemini-supabase/)
- [Caso real: SaaS multitenant con NestJS y React desplegado con Coolify](/blog/caso-real-saas-atrapaclientes-nestjs-react/)]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[React 19: Las Novedades que Cambian tu Código en 2026]]></title>
      <link>https://francobosg.netlify.app/blog/react-19-novedades-que-cambian-tu-codigo-2026/</link>
      <description><![CDATA[Actions, use(), useOptimistic, Server Components estables y el compilador de React. Qué cambia de verdad en React 19 con ejemplos de código reales y cómo migrar sin romper nada.]]></description>
      <pubDate>Thu, 23 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/react-19-novedades-que-cambian-tu-codigo-2026/</guid>
      <category>React</category>
      <category>Tutorial</category>
      <category>Frontend</category>
      <category>JavaScript</category>
      <category>TypeScript</category>
      <category>Next.js</category>
      <content:encoded><![CDATA[React 19 salió en diciembre de 2024. Si usas Next.js 15, ya lo tienes. Si sigues en React 18 con Vite, probablemente aún no has migrado porque "nada está roto".

Aquí van las novedades que de verdad cambian cómo escribes código, con ejemplos antes/después para que veas el impacto real.

---

## 1. Actions: adiós al patrón isPending manual

El patrón más repetido en React: formulario asíncrono con estado de carga y manejo de errores.

**React 18 — el patrón clásico:**

```tsx
function FormularioContacto() {
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [success, setSuccess] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setIsPending(true);
    setError(null);
    try {
      await enviarFormulario(new FormData(e.target as HTMLFormElement));
      setSuccess(true);
    } catch (err) {
      setError('Error al enviar. Inténtalo de nuevo.');
    } finally {
      setIsPending(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* campos del formulario */}
      <button disabled={isPending}>{isPending ? 'Enviando...' : 'Enviar'}</button>
      {error && <p className="text-red-500">{error}</p>}
    </form>
  );
}
```

**React 19 — con Actions y useActionState:**

```tsx
import { useActionState } from 'react';

async function enviarAction(prevState: any, formData: FormData) {
  try {
    await enviarFormulario(formData);
    return { success: true, error: null };
  } catch {
    return { success: false, error: 'Error al enviar. Inténtalo de nuevo.' };
  }
}

function FormularioContacto() {
  const [state, action, isPending] = useActionState(enviarAction, {
    success: false,
    error: null,
  });

  return (
    <form action={action}>
      {/* campos del formulario */}
      <button disabled={isPending}>{isPending ? 'Enviando...' : 'Enviar'}</button>
      {state.error && <p className="text-red-500">{state.error}</p>}
    </form>
  );
}
```

El `isPending`, el try/catch y el reset de estado lo gestiona React. Tu código se encarga solo de la lógica de negocio.

---

## 2. useOptimistic: UI instantánea antes de que el servidor responda

Uno de los patrones más útiles para UX en apps con servidor. Antes necesitabas hacerlo a mano.

**Caso de uso**: lista de comentarios donde el nuevo comentario aparece al instante al enviarlo, antes de que el servidor confirme.

```tsx
import { useOptimistic, useActionState } from 'react';

function ListaComentarios({ comentariosIniciales }: { comentariosIniciales: Comentario[] }) {
  const [comentarios, addOptimistic] = useOptimistic(
    comentariosIniciales,
    (state, nuevoComentario: string) => [
      ...state,
      { id: Date.now(), texto: nuevoComentario, pendiente: true },
    ]
  );

  const [, action, isPending] = useActionState(async (_: any, formData: FormData) => {
    const texto = formData.get('comentario') as string;
    addOptimistic(texto); // UI actualiza INSTANTÁNEAMENTE
    await guardarComentario(texto); // esto puede tardar 300ms
  }, null);

  return (
    <div>
      {comentarios.map((c) => (
        <div key={c.id} className={c.pendiente ? 'opacity-60' : ''}>
          {c.texto}
        </div>
      ))}
      <form action={action}>
        <input name="comentario" />
        <button disabled={isPending}>Comentar</button>
      </form>
    </div>
  );
}
```

El comentario aparece en la lista inmediatamente. Si el servidor falla, React hace rollback automático.

---

## 3. El hook use(): Promises y Context en medio de un render

`use()` es el primer hook que puedes llamar dentro de condicionales (rompiendo la regla de siempre).

**Leer una Promise (con Suspense):**

```tsx
import { use, Suspense } from 'react';

function Usuario({ promesaUsuario }: { promesaUsuario: Promise<User> }) {
  // Esto se puede llamar dentro de un if, un bucle, etc.
  const usuario = use(promesaUsuario);
  return <p>Hola, {usuario.nombre}</p>;
}

function App() {
  const promesa = fetchUsuario(1); // Promise<User>
  return (
    <Suspense fallback={<p>Cargando...</p>}>
      <Usuario promesaUsuario={promesa} />
    </Suspense>
  );
}
```

**Leer Context de forma condicional:**

```tsx
function Boton({ mostrarAdmin }: { mostrarAdmin: boolean }) {
  if (mostrarAdmin) {
    // Antes esto era imposible — los hooks no se pueden usar en condicionales
    const admin = use(AdminContext);
    return <button onClick={admin.logout}>Cerrar sesión</button>;
  }
  return <button>Entrar</button>;
}
```

---

## 4. ref como prop — adiós a forwardRef

Uno de los cambios más celebrados. Antes, para pasar una `ref` a un componente hijo necesitabas `forwardRef`:

**React 18:**
```tsx
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
  <input ref={ref} {...props} />
));
```

**React 19:**
```tsx
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />;
}

// Uso igual que antes:
const inputRef = useRef<HTMLInputElement>(null);
<Input ref={inputRef} />
```

`forwardRef` sigue funcionando pero está deprecado. La migración es mecánica.

---

## 5. El React Compiler: memoización automática

Esta es la novedad más grande en el largo plazo. El compilador analiza tu código en build time y añade `useMemo` y `useCallback` automáticamente donde es necesario.

**Antes (React 18 con optimización manual):**

```tsx
function ProductoCard({ producto, onAgregar }: Props) {
  const precioFormateado = useMemo(
    () => new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(producto.precio),
    [producto.precio]
  );

  const handleAgregar = useCallback(() => {
    onAgregar(producto.id);
  }, [onAgregar, producto.id]);

  return (
    <div>
      <p>{producto.nombre}</p>
      <p>{precioFormateado}</p>
      <button onClick={handleAgregar}>Agregar</button>
    </div>
  );
}
```

**Con React Compiler:**

```tsx
function ProductoCard({ producto, onAgregar }: Props) {
  // Sin useMemo ni useCallback — el compilador los añade solo
  const precioFormateado = new Intl.NumberFormat('es-ES', {
    style: 'currency', currency: 'EUR'
  }).format(producto.precio);

  return (
    <div>
      <p>{producto.nombre}</p>
      <p>{precioFormateado}</p>
      <button onClick={() => onAgregar(producto.id)}>Agregar</button>
    </div>
  );
}
```

El output compilado incluye la memoización, el código fuente no. Limpio y eficiente.

Para activarlo en Vite:

```bash
npm install babel-plugin-react-compiler
```

```js
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [['babel-plugin-react-compiler', {}]],
      },
    }),
  ],
});
```

---

## 6. Document metadata nativo

Ya no necesitas `react-helmet` para gestionar `<title>`, `<meta>` y `<link>` en el head:

```tsx
function PaginaBlog({ post }: { post: Post }) {
  return (
    <>
      <title>{post.titulo} | Mi Blog</title>
      <meta name="description" content={post.descripcion} />
      <link rel="canonical" href={`https://misite.com/blog/${post.slug}`} />

      <article>
        <h1>{post.titulo}</h1>
        {/* contenido */}
      </article>
    </>
  );
}
```

React eleva estos elementos al `<head>` automáticamente, sin portals ni librerías externas.

---

## Cómo migrar desde React 18

```bash
npm install react@19 react-dom@19 @types/react@19 @types/react-dom@19
```

Los cambios que probablemente romperán algo:

1. **`ReactDOM.render` eliminado** → usa `ReactDOM.createRoot`
2. **`react-dom/test-utils` eliminado** → importa de `@testing-library/react`
3. **`defaultProps` en funciones** → usa parámetros por defecto de JS
4. **`propTypes` eliminado** → usa TypeScript

Para el 95% de proyectos modernos (Vite + TypeScript, Next.js 14+), la migración es sin cambios o con 1-2 pequeños ajustes.

---

## Cuándo adoptar cada feature

| Feature | ¿Cuándo usar? |
|---------|--------------|
| `useActionState` | Formularios con lógica asíncrona. Ya. |
| `useOptimistic` | UX de apps tipo chat, likes, listas. |
| `use()` con Promises | Data fetching con Suspense. |
| `ref` como prop | Migra cuando toques esos componentes. |
| React Compiler | Proyectos nuevos. Pruébalo en uno existente. |
| Document metadata | Sustituye react-helmet cuando puedas. |

---

## Recursos relacionados

- [Estructura de carpetas en React y Next.js que escala](/blog/estructura-carpetas-react-nextjs-2026/)
- [Por qué dejé Redux y qué uso ahora](/blog/por-que-deje-redux-alternativas-2026/)
- [Animaciones con Framer Motion: el nivel que parece de agencia](/blog/animaciones-framer-motion-premium-2026/)
- [Autenticación con Auth.js (NextAuth v5): implementación real](/blog/auth-js-nextauth-implementacion-real-2026/)
- [Caso real: SaaS con React 19 en producción](/blog/caso-real-saas-atrapaclientes-nestjs-react/)]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Deploy Gratis con GitHub Actions: Automatiza tu Web en Netlify y Vercel (2026)]]></title>
      <link>https://francobosg.netlify.app/blog/deploy-gratis-github-actions-netlify-vercel-2026/</link>
      <description><![CDATA[Configura GitHub Actions para hacer deploy automático en Netlify o Vercel. CI/CD paso a paso: build, tests, preview deployments y deploy a producción. Gratis.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/deploy-gratis-github-actions-netlify-vercel-2026/</guid>
      <category>Tutorial</category>
      <category>Código</category>
      <category>Herramientas</category>
      <category>Gratis</category>
      <content:encoded><![CDATA[Haces `git push`, te vas a tomar café, y cuando vuelves tu web ya está actualizada en producción. Sin tocar ningún panel, sin ejecutar comandos manuales, sin errores por olvidar un paso.

Eso es CI/CD con **GitHub Actions**. Y es gratis.

## ¿Por qué no solo usar el auto-deploy de Netlify/Vercel?

Netlify y Vercel ya hacen deploy automático cuando pusheas. Pero no ejecutan **tus** tests. Si tienes un error de TypeScript, un test roto o un linting que falla, se despliega igual.

Con GitHub Actions añades una capa de control:

```
git push → GitHub Actions → Tests ✅ → Build ✅ → Deploy a Netlify/Vercel
                              ↓
                          Tests ❌ → Deploy cancelado (no se rompe producción)
```

## Opción 1: Deploy a Netlify con GitHub Actions

### Paso 1: Obtener tokens de Netlify

1. Ve a [app.netlify.com/user/applications](https://app.netlify.com/user/applications)
2. Genera un **Personal Access Token** — cópialo, solo se muestra una vez
3. En tu sitio de Netlify, copia el **Site ID** (Site settings → General → Site ID)

### Paso 2: Configurar secretos en GitHub

En tu repositorio → Settings → Secrets and variables → Actions:

- `NETLIFY_AUTH_TOKEN`: tu Personal Access Token
- `NETLIFY_SITE_ID`: el Site ID de tu sitio

### Paso 3: El workflow

```yaml
# .github/workflows/deploy.yml
name: Build & Deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout código
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'

      - name: Instalar dependencias
        run: npm ci

      - name: Lint
        run: npm run lint --if-present

      - name: Tests
        run: npm test --if-present

      - name: Build
        run: npm run build

      # Solo deploy en push a main (no en PRs)
      - name: Deploy a Netlify
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: nwtgck/actions-netlify@v3
        with:
          publish-dir: './dist'
          production-deploy: true
        env:
          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

      # Preview deploy en PRs
      - name: Preview Deploy
        if: github.event_name == 'pull_request'
        uses: nwtgck/actions-netlify@v3
        with:
          publish-dir: './dist'
          production-deploy: false
          alias: pr-${{ github.event.pull_request.number }}
        env:
          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
```

### ¿Qué hace cada paso?

| Paso | Qué hace | Si falla... |
|------|----------|-------------|
| Checkout | Clona tu repo | No arranca nada |
| Setup Node | Instala Node 22 con caché de npm | No instala deps |
| npm ci | Instala dependencias (exactas del lockfile) | Error de deps |
| Lint | Verifica estilo de código | Cancela deploy |
| Tests | Ejecuta tests unitarios | Cancela deploy |
| Build | Genera la carpeta `dist/` | Cancela deploy |
| Deploy | Sube `dist/` a Netlify | Solo si todo pasó |

> **Preview Deploys**: Cuando abres un PR, GitHub Actions genera una URL temporal como `pr-42--tu-sitio.netlify.app`. Puedes revisar los cambios antes de mergear.

## Opción 2: Deploy a Vercel con GitHub Actions

### Paso 1: Obtener token de Vercel

```bash
# Instala Vercel CLI
npm i -g vercel

# Login y linkea tu proyecto
vercel login
vercel link
```

Esto genera `.vercel/project.json` con tu `orgId` y `projectId`.

### Paso 2: Secretos en GitHub

- `VERCEL_TOKEN`: desde [vercel.com/account/tokens](https://vercel.com/account/tokens)
- `VERCEL_ORG_ID`: de `.vercel/project.json`
- `VERCEL_PROJECT_ID`: de `.vercel/project.json`

### Paso 3: El workflow

```yaml
# .github/workflows/deploy-vercel.yml
name: Deploy to Vercel

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'
      - run: npm ci
      - run: npm run lint --if-present
      - run: npm test --if-present

  deploy:
    needs: test  # Solo si los tests pasan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Vercel CLI
        run: npm i -g vercel

      # Producción (push a main)
      - name: Deploy Production
        if: github.event_name == 'push'
        run: vercel --prod --token=${{ secrets.VERCEL_TOKEN }}

      # Preview (PRs)
      - name: Deploy Preview
        if: github.event_name == 'pull_request'
        run: vercel --token=${{ secrets.VERCEL_TOKEN }}
```

**Nota**: separar `test` y `deploy` en dos jobs permite que se ejecuten en paralelo si no hay dependencia, y deja claro que el deploy necesita que los tests pasen primero (`needs: test`).

## Ejemplo real: Portfolio + Blog (Vite + Astro)

Este es un workflow adaptado al setup que uso en este blog — un [portfolio Vite + blog Astro](/blog/como-hice-mi-portfolio-vite-tailwind/) con build combinado:

```yaml
# .github/workflows/deploy.yml
name: Deploy Portfolio + Blog

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'

      # Portfolio (Vite)
      - name: Install portfolio deps
        run: npm ci

      - name: Build portfolio
        run: npx vite build

      # Blog (Astro)
      - name: Install blog deps
        working-directory: ./blog
        run: npm ci

      - name: Build blog
        working-directory: ./blog
        run: npm run build

      # Deploy combinado
      - name: Deploy to Netlify
        uses: nwtgck/actions-netlify@v3
        with:
          publish-dir: './dist'
          production-deploy: true
        env:
          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
```

## Optimización: Caché de dependencias

Si tu build tarda mucho, la mayor parte del tiempo es `npm ci`. Con caché de node_modules, los builds posteriores son mucho más rápidos:

```yaml
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'  # Cachea node_modules basándose en package-lock.json
```

Si tienes un monorepo con múltiples `package-lock.json`:

```yaml
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'
          cache-dependency-path: |
            package-lock.json
            blog/package-lock.json
```

## Notificaciones: Saber si el deploy falló

### Opción A: GitHub Status Checks

Ya viene incluido. En cada PR ves el estado del workflow: ✅ verde o ❌ rojo.

### Opción B: Notificación por email

GitHub te notifica por email si un workflow falla (activado por defecto en Settings → Notifications).

### Opción C: Discord webhook

```yaml
      - name: Notificar a Discord
        if: failure()
        run: |
          curl -X POST ${{ secrets.DISCORD_WEBHOOK }} \
            -H "Content-Type: application/json" \
            -d '{"content": "❌ Deploy fallido en `${{ github.repository }}`\nCommit: ${{ github.sha }}\nAutor: ${{ github.actor }}"}'
```

## Workflow avanzado: Matrix builds

Si quieres probar tu código en múltiples versiones de Node:

```yaml
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test
```

Esto ejecuta los tests en Node 20 y Node 22 en paralelo. Si falla en alguna versión, lo sabrás antes de deployar.

## Errores comunes

### "npm ci can only install with an existing package-lock.json"

```bash
# Asegúrate de tener package-lock.json commiteado
git add package-lock.json
git commit -m "add lockfile"
git push
```

### El deploy funciona pero la web no se actualiza

Verifica que `publish-dir` apunte a la carpeta correcta:

```yaml
# ❌ Incorrecto si tu build genera dist/
publish-dir: './build'

# ✅ Correcto
publish-dir: './dist'
```

### Secretos no encontrados

Los secretos de un fork NO se comparten. Si alguien hace fork + PR, el workflow no tendrá acceso a tus secrets. Esto es intencional (seguridad).

## ¿Cuánto cuesta?

| Plan | Repos públicos | Repos privados |
|------|---------------|----------------|
| GitHub Free | Minutos ilimitados | 2.000 min/mes |
| GitHub Pro | Minutos ilimitados | 3.000 min/mes |

Un build típico de Vite + Astro tarda ~2 minutos. Con 2.000 minutos al mes puedes hacer **1.000 deploys/mes** en repos privados. Para proyectos personales, es gratis de facto.

## Resumen

```
1. Configura secretos en GitHub (tokens de Netlify/Vercel)
2. Crea .github/workflows/deploy.yml
3. El workflow: checkout → install → lint → test → build → deploy
4. Push a main → deploy a producción
5. PR → preview deploy con URL temporal
6. Si los tests fallan → deploy cancelado
```

## Recursos relacionados

- [Cómo hice mi portfolio con Vite y Tailwind](/blog/como-hice-mi-portfolio-vite-tailwind/) — el proyecto que desplegamos con este workflow
- [Docker para desarrolladores](/blog/docker-para-developers-guia-practica-2026/) — containeriza tu app antes de deployar
- [Comandos Git esenciales](/blog/comandos-git-esenciales-2026/) — domina Git antes de automatizar con Actions]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Docker para Desarrolladores: Guía Práctica sin Teoría Innecesaria (2026)]]></title>
      <link>https://francobosg.netlify.app/blog/docker-para-developers-guia-practica-2026/</link>
      <description><![CDATA[Aprende Docker desde cero con ejemplos reales: contenedores, Dockerfile, docker-compose y deploy. Sin rodeos, solo lo que necesitas para desarrollo y producción.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/docker-para-developers-guia-practica-2026/</guid>
      <category>Docker</category>
      <category>Tutorial</category>
      <category>Código</category>
      <category>Backend</category>
      <content:encoded><![CDATA[Llevas 3 horas intentando instalar PostgreSQL 16 en tu máquina. En Windows te pide un servicio, en Mac se pelea con Homebrew, y tu compañero tiene la versión 14 porque "a él le funciona así". 

Con Docker, es un comando:

```bash
docker run -d --name postgres -e POSTGRES_PASSWORD=secret -p 5432:5432 postgres:16
```

PostgreSQL 16 corriendo en 5 segundos. Misma versión para todo el equipo. Sin instalar nada en tu sistema.

## Lo mínimo que necesitas saber

### Imagen vs Contenedor

- **Imagen**: La receta. Un paquete con el sistema operativo, dependencias y tu código. Es inmutable.
- **Contenedor**: El plato cocinado. Una instancia ejecutándose de esa imagen. Puedes tener varios contenedores de la misma imagen.

```bash
# Descargar una imagen
docker pull node:22-alpine

# Crear y ejecutar un contenedor desde la imagen
docker run -it node:22-alpine node -e "console.log('Hola Docker')"
```

### Comandos que vas a usar el 90% del tiempo

```bash
# Ver contenedores corriendo
docker ps

# Ver TODOS (incluidos los parados)
docker ps -a

# Parar un contenedor
docker stop nombre_o_id

# Eliminar un contenedor
docker rm nombre_o_id

# Ver logs
docker logs nombre_o_id

# Entrar en un contenedor
docker exec -it nombre_o_id sh
```

## Tu primer Dockerfile

Un Dockerfile define cómo construir la imagen de tu aplicación. Este es para una app Node.js:

```dockerfile
# Imagen base ligera
FROM node:22-alpine

# Directorio de trabajo dentro del contenedor
WORKDIR /app

# Copiar solo package.json primero (aprovecha caché de capas)
COPY package*.json ./
RUN npm ci --only=production

# Copiar el resto del código
COPY . .

# Puerto que expone la app
EXPOSE 3000

# Comando para arrancar
CMD ["node", "src/index.js"]
```

### Por qué `COPY package*.json` va primero

Docker cachea cada instrucción como una "capa". Si copias primero `package.json` y luego haces `npm ci`, Docker solo reinstala dependencias cuando cambias `package.json`. Si cambias un archivo de código, salta directamente al `COPY . .` — **el build tarda segundos en vez de minutos**.

### Construir y ejecutar

```bash
# Construir la imagen
docker build -t mi-api .

# Ejecutar el contenedor
docker run -d -p 3000:3000 --name mi-api mi-api

# Verificar que funciona
curl http://localhost:3000
```

## .dockerignore — No copies basura al contenedor

Crea un `.dockerignore` en la raíz del proyecto:

```
node_modules
npm-debug.log
.git
.env
dist
*.md
```

Sin esto, Docker copia `node_modules` (que puede pesar 500MB+) dentro del contenedor y luego los reinstala con `npm ci`. Doble desperdicio.

## Docker Compose — Varios servicios con un comando

Tu API necesita base de datos, Redis para caché y tal vez Elasticsearch. Sin Docker Compose, necesitas 3 terminales con 3 comandos. Con Compose, un solo archivo:

```yaml
# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      - db
      - cache

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:
```

### Comandos de Compose

```bash
# Levantar todo (en segundo plano)
docker compose up -d

# Ver logs de todos los servicios
docker compose logs -f

# Parar todo
docker compose down

# Parar y eliminar volúmenes (⚠️ borra datos de DB)
docker compose down -v
```

> **Truco**: `depends_on` solo espera a que el contenedor arranque, NO a que el servicio esté listo. Para esperar a que PostgreSQL acepte conexiones, usa un health check.

### Health checks

```yaml
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  api:
    build: .
    depends_on:
      db:
        condition: service_healthy
```

Ahora `api` no arranca hasta que PostgreSQL esté realmente listo para recibir conexiones.

## Dockerfile para producción (multi-stage)

El Dockerfile básico funciona, pero incluye herramientas de build (npm, compiladores) que no necesitas en producción. Multi-stage build lo soluciona:

```dockerfile
# --- Stage 1: Build ---
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# --- Stage 2: Production ---
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]
```

### ¿Qué cambia?

| Aspecto | Single-stage | Multi-stage |
|---------|-------------|-------------|
| Tamaño imagen | ~400MB | ~150MB |
| Herramientas de build | Incluidas | Excluidas |
| Superficie de ataque | Mayor | Menor |
| `USER node` | No | Sí (no corre como root) |

> **Importante**: Nunca corras contenedores como root en producción. La línea `USER node` usa el usuario `node` que viene incluido en las imágenes oficiales de Node.js.

## Ejemplo real: API NestJS + PostgreSQL + Redis

Este es un `docker-compose.yml` de un proyecto real, similar al que usamos en [el caso real de Atrapaclientes](/blog/caso-real-saas-atrapaclientes-nestjs-react/):

```yaml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    ports:
      - "3000:3000"
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASS}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASS}
    volumes:
      - redisdata:/data
    restart: unless-stopped

volumes:
  pgdata:
  redisdata:
```

**Notas clave:**
- `env_file: .env` carga variables de entorno. **Nunca hardcodees contraseñas** en el compose.
- `restart: unless-stopped` hace que los contenedores se reinicien si caen (excepto si los paras manualmente).
- Los volúmenes (`pgdata`, `redisdata`) persisten datos entre reinicios del contenedor.

## Errores típicos y cómo evitarlos

### 1. "Port already in use"

```bash
# Algo ya está usando el puerto 5432
docker compose up -d
# Error: port is already allocated

# Solución: encuentra qué lo usa
# Windows
netstat -ano | findstr :5432

# Linux/Mac
lsof -i :5432
```

### 2. Cambios en el código no se reflejan

En desarrollo, monta tu código como volumen para que los cambios se apliquen en caliente:

```yaml
  api:
    build: .
    volumes:
      - ./src:/app/src  # Monta el código fuente
    command: npm run dev  # Usa nodemon o tsx --watch
```

### 3. La imagen pesa demasiado

```bash
# Ver el tamaño de tus imágenes
docker images

# Usa alpine como base (mucho más ligera)
FROM node:22-alpine    # ~130MB
# vs
FROM node:22           # ~1.1GB
```

### 4. "Cannot connect to database" al arrancar

Tu API arranca antes que la DB esté lista. Usa health checks (explicado arriba) o añade retry en tu código:

```javascript
// Retry de conexión a la DB
async function connectWithRetry(retries = 5, delay = 3000) {
  for (let i = 0; i < retries; i++) {
    try {
      await dataSource.initialize();
      console.log('DB conectada');
      return;
    } catch (err) {
      console.log(`Intento ${i + 1}/${retries} fallido, reintentando...`);
      await new Promise(r => setTimeout(r, delay));
    }
  }
  throw new Error('No se pudo conectar a la DB');
}
```

## Cheatsheet: Comandos Docker esenciales

```bash
# --- Imágenes ---
docker build -t nombre .          # Construir imagen
docker images                      # Listar imágenes
docker rmi nombre                  # Eliminar imagen
docker image prune -a             # Limpiar imágenes sin usar

# --- Contenedores ---
docker run -d -p 3000:3000 nombre # Ejecutar en background
docker ps                          # Ver contenedores activos
docker stop $(docker ps -q)       # Parar TODOS
docker rm $(docker ps -aq)        # Eliminar TODOS

# --- Compose ---
docker compose up -d              # Levantar servicios
docker compose down               # Parar servicios
docker compose logs -f api        # Logs de un servicio
docker compose exec api sh        # Entrar en un servicio

# --- Limpieza ---
docker system prune -a            # Limpiar TODO lo que no se usa
docker volume prune               # Limpiar volúmenes huérfanos
```

## Próximos pasos

- Si quieres automatizar el deploy de tu imagen Docker, mira cómo hacerlo con [GitHub Actions y Netlify/Vercel](/blog/deploy-gratis-github-actions-netlify-vercel-2026/)
- Para proyectos reales con NestJS y Docker, revisa el [caso real de Atrapaclientes](/blog/caso-real-saas-atrapaclientes-nestjs-react/)
- Si estás aprendiendo desarrollo, la [guía para estudiantes DAW/DAM](/blog/guia-estudiantes-daw-dam-smr-2026/) incluye recursos de Docker para principiantes]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Proteger tu API en Node.js con JWT: Guía Completa de Autenticación (2026)]]></title>
      <link>https://francobosg.netlify.app/blog/proteger-api-nodejs-jwt-auth-guia-2026/</link>
      <description><![CDATA[Implementa autenticación JWT en Node.js paso a paso: login, refresh tokens, middleware de protección y buenas prácticas de seguridad. Con Express y NestJS.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/proteger-api-nodejs-jwt-auth-guia-2026/</guid>
      <category>Backend</category>
      <category>Tutorial</category>
      <category>Código</category>
      <category>NestJS</category>
      <content:encoded><![CDATA[Tu API está abierta. Cualquiera puede hacer `curl https://tu-api.com/users` y obtener todos los datos. Necesitas autenticación, y JWT es el estándar de facto para APIs REST.

El problema es que el 80% de los tutoriales de JWT que encuentras online tienen agujeros de seguridad: guardan tokens en `localStorage`, no implementan refresh tokens, o usan tiempos de expiración de 30 días.

Esta guía implementa autenticación JWT **como se hace en producción**.

## Arquitectura

```
[Login]  →  POST /auth/login  →  { accessToken, refreshToken (cookie) }
                                          │
[Petición protegida]  →  GET /users  →  Authorization: Bearer {accessToken}
                                          │
[Token expirado]  →  POST /auth/refresh  →  Nueva pareja de tokens
```

**Dos tokens, dos propósitos:**
- **Access token**: Corta duración (15 min). Va en el header `Authorization`. Autoriza peticiones.
- **Refresh token**: Larga duración (7 días). Va en cookie `httpOnly`. Solo sirve para obtener un nuevo access token.

## Paso 1: Setup del proyecto

```bash
mkdir auth-api && cd auth-api
npm init -y
npm install express jsonwebtoken bcryptjs cookie-parser
npm install -D typescript @types/express @types/jsonwebtoken @types/bcryptjs tsx
```

```typescript
// src/config.ts
export const config = {
  accessTokenSecret: process.env.ACCESS_TOKEN_SECRET!,
  refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET!,
  accessTokenExpiry: '15m',
  refreshTokenExpiry: '7d',
  port: process.env.PORT || 3000,
} as const;
```

> **Genera secretos seguros** con: `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`. Nunca uses strings como `"mi-secreto"`.

## Paso 2: Generar y verificar tokens

```typescript
// src/tokens.ts
import jwt from 'jsonwebtoken';
import { config } from './config';

interface TokenPayload {
  userId: string;
  email: string;
}

export function generateAccessToken(payload: TokenPayload): string {
  return jwt.sign(payload, config.accessTokenSecret, {
    expiresIn: config.accessTokenExpiry,
  });
}

export function generateRefreshToken(payload: TokenPayload): string {
  return jwt.sign(payload, config.refreshTokenSecret, {
    expiresIn: config.refreshTokenExpiry,
  });
}

export function verifyAccessToken(token: string): TokenPayload {
  return jwt.verify(token, config.accessTokenSecret) as TokenPayload;
}

export function verifyRefreshToken(token: string): TokenPayload {
  return jwt.verify(token, config.refreshTokenSecret) as TokenPayload;
}
```

**¿Por qué dos secretos diferentes?** Si alguien roba el access token secret, no puede generar refresh tokens (y viceversa). Minimizas el daño de una filtración.

## Paso 3: Middleware de autenticación

```typescript
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../tokens';

declare global {
  namespace Express {
    interface Request {
      user?: { userId: string; email: string };
    }
  }
}

export function authenticate(req: Request, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Token no proporcionado' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const payload = verifyAccessToken(token);
    req.user = payload;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Token inválido o expirado' });
  }
}
```

## Paso 4: Rutas de autenticación

```typescript
// src/routes/auth.ts
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '../tokens';

const router = Router();

// Simulación de DB — en producción usa PostgreSQL, MongoDB, etc.
const users = new Map<string, { id: string; email: string; passwordHash: string }>();

// --- REGISTRO ---
router.post('/register', async (req: Request, res: Response) => {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ error: 'Email y contraseña requeridos' });
  }

  if (users.has(email)) {
    return res.status(409).json({ error: 'El email ya está registrado' });
  }

  // Hash de contraseña — NUNCA guardes contraseñas en texto plano
  const passwordHash = await bcrypt.hash(password, 12);
  const id = crypto.randomUUID();

  users.set(email, { id, email, passwordHash });

  return res.status(201).json({ message: 'Usuario creado' });
});

// --- LOGIN ---
router.post('/login', async (req: Request, res: Response) => {
  const { email, password } = req.body;

  const user = users.get(email);
  if (!user) {
    // Mensaje genérico — no reveles si el email existe o no
    return res.status(401).json({ error: 'Credenciales inválidas' });
  }

  const validPassword = await bcrypt.compare(password, user.passwordHash);
  if (!validPassword) {
    return res.status(401).json({ error: 'Credenciales inválidas' });
  }

  const payload = { userId: user.id, email: user.email };
  const accessToken = generateAccessToken(payload);
  const refreshToken = generateRefreshToken(payload);

  // Refresh token en cookie httpOnly (inaccesible desde JS del navegador)
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,     // No accesible desde JavaScript
    secure: true,       // Solo HTTPS
    sameSite: 'strict', // Protección CSRF
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 días
    path: '/auth/refresh', // Solo se envía a esta ruta
  });

  return res.json({ accessToken });
});

// --- REFRESH TOKEN ---
router.post('/refresh', (req: Request, res: Response) => {
  const token = req.cookies?.refreshToken;

  if (!token) {
    return res.status(401).json({ error: 'Refresh token no proporcionado' });
  }

  try {
    const payload = verifyRefreshToken(token);
    const newPayload = { userId: payload.userId, email: payload.email };

    // Rotación de tokens: genera AMBOS de nuevo
    const newAccessToken = generateAccessToken(newPayload);
    const newRefreshToken = generateRefreshToken(newPayload);

    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000,
      path: '/auth/refresh',
    });

    return res.json({ accessToken: newAccessToken });
  } catch {
    // Refresh token inválido o expirado — el usuario debe hacer login de nuevo
    res.clearCookie('refreshToken');
    return res.status(401).json({ error: 'Sesión expirada, inicia sesión de nuevo' });
  }
});

// --- LOGOUT ---
router.post('/logout', (_req: Request, res: Response) => {
  res.clearCookie('refreshToken', { path: '/auth/refresh' });
  return res.json({ message: 'Sesión cerrada' });
});

export default router;
```

### Detalles de seguridad clave

| Decisión | Por qué |
|----------|---------|
| `httpOnly: true` en cookie | El JS del navegador no puede leer el refresh token → previene XSS |
| `secure: true` | La cookie solo se envía por HTTPS → previene man-in-the-middle |
| `sameSite: 'strict'` | La cookie no se envía en peticiones cross-origin → previene CSRF |
| `path: '/auth/refresh'` | La cookie solo se envía al endpoint de refresh → minimiza exposición |
| Mensajes genéricos en login | No revelas si un email existe → previene enumeración de usuarios |
| Rotación de refresh tokens | Si roban un refresh token viejo, ya no sirve → reduce ventana de ataque |

## Paso 5: Servidor principal

```typescript
// src/index.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import { config } from './config';
import authRoutes from './routes/auth';
import { authenticate } from './middleware/auth';

const app = express();

app.use(express.json());
app.use(cookieParser());

// Rutas públicas
app.use('/auth', authRoutes);

// Rutas protegidas — necesitan access token válido
app.get('/me', authenticate, (req, res) => {
  res.json({ user: req.user });
});

app.get('/dashboard', authenticate, (req, res) => {
  res.json({ message: `Bienvenido, ${req.user!.email}` });
});

app.listen(config.port, () => {
  console.log(`API corriendo en http://localhost:${config.port}`);
});
```

## Flujo completo

```bash
# 1. Registrar usuario
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "dev@example.com", "password": "S3gur0!2026"}'

# 2. Login → recibe accessToken + refreshToken en cookie
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "dev@example.com", "password": "S3gur0!2026"}' \
  -c cookies.txt
# Respuesta: { "accessToken": "eyJhbGc..." }

# 3. Petición protegida con el access token
curl http://localhost:3000/me \
  -H "Authorization: Bearer eyJhbGc..."

# 4. Cuando expira → refresh
curl -X POST http://localhost:3000/auth/refresh \
  -b cookies.txt -c cookies.txt
# Respuesta: { "accessToken": "eyJhbGc...(nuevo)" }

# 5. Logout
curl -X POST http://localhost:3000/auth/logout -b cookies.txt
```

## Versión NestJS (Guards)

Si usas NestJS (como en [nuestro caso real con Atrapaclientes](/blog/caso-real-saas-atrapaclientes-nestjs-react/)), la autenticación se implementa con Guards:

```typescript
// auth/jwt.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const authHeader = request.headers.authorization;

    if (!authHeader?.startsWith('Bearer ')) {
      throw new UnauthorizedException('Token no proporcionado');
    }

    try {
      const token = authHeader.split(' ')[1];
      const payload = this.jwtService.verify(token);
      request.user = payload;
      return true;
    } catch {
      throw new UnauthorizedException('Token inválido o expirado');
    }
  }
}
```

```typescript
// users/users.controller.ts
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt.guard';

@Controller('users')
export class UsersController {
  @Get('me')
  @UseGuards(JwtAuthGuard)
  getProfile(@Req() req) {
    return { user: req.user };
  }
}
```

## Errores comunes de seguridad (y cómo evitarlos)

### 1. Guardar JWT en localStorage

```javascript
// ❌ NUNCA hagas esto
localStorage.setItem('token', accessToken);
// Un ataque XSS puede leer localStorage y robar el token

// ✅ Guarda el access token en memoria (variable JS)
let accessToken = null;

async function login(email, password) {
  const res = await fetch('/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
    credentials: 'include', // Para recibir la cookie httpOnly
  });
  const data = await res.json();
  accessToken = data.accessToken; // Solo en memoria
}
```

### 2. Access token con expiración larga

```typescript
// ❌ Expiración de 30 días — si roban el token, tienen acceso un mes
jwt.sign(payload, secret, { expiresIn: '30d' });

// ✅ Expiración corta (15 min) + refresh token para renovar
jwt.sign(payload, secret, { expiresIn: '15m' });
```

### 3. No hashear contraseñas (o usar MD5/SHA)

```typescript
// ❌ MD5 — se crackea en segundos con rainbow tables
const hash = md5(password);

// ❌ SHA256 — más seguro que MD5 pero no tiene salt automático
const hash = crypto.createHash('sha256').update(password).digest('hex');

// ✅ bcrypt — salt automático, computacionalmente costoso
const hash = await bcrypt.hash(password, 12);
```

### 4. Revelar si un email existe en el login

```typescript
// ❌ Revela que el email existe
if (!user) return res.status(404).json({ error: 'Usuario no encontrado' });
if (!validPassword) return res.status(401).json({ error: 'Contraseña incorrecta' });

// ✅ Mensaje genérico para ambos casos
return res.status(401).json({ error: 'Credenciales inválidas' });
```

## Checklist de seguridad para tu API

- [ ] Secretos JWT generados con `crypto.randomBytes(64)`
- [ ] Access token con expiración <= 15 minutos
- [ ] Refresh token en cookie `httpOnly`, `secure`, `sameSite`
- [ ] Contraseñas hasheadas con bcrypt (rounds >= 12)
- [ ] HTTPS obligatorio en producción
- [ ] Mensajes de error genéricos en login
- [ ] Rotación de refresh tokens
- [ ] Rate limiting en `/auth/login` para prevenir fuerza bruta
- [ ] CORS configurado solo para dominios de confianza
- [ ] Headers de seguridad (Helmet en Express)

## Recursos relacionados

- [Error 429 Too Many Requests: cómo manejar rate limiting](/blog/error-429-too-many-requests-api-ia-2026/) — aplica también a tus endpoints de login
- [Por qué NestJS sobre Express para un SaaS](/blog/por-que-nestjs-sobre-express-saas-2026/) — Guards, interceptores y módulos para auth escalable
- [Caso real SaaS Atrapaclientes](/blog/caso-real-saas-atrapaclientes-nestjs-react/) — autenticación JWT en un producto real con multi-tenancy]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Mi Agente de IA Ha Entrado en un Bucle Infinito: Cómo Pararlo y Prevenirlo]]></title>
      <link>https://francobosg.netlify.app/blog/agente-ia-bucle-infinito-cline-cursor-2026/</link>
      <description><![CDATA[¿Tu agente de IA (Cline, Cursor, Claude Dev) no para de ejecutar comandos en bucle? Por qué ocurre, cómo detenerlo sin perder tu trabajo y cómo prevenirlo.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/agente-ia-bucle-infinito-cline-cursor-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Son las 11 de la noche. Le pides a Cline que "arregle el error de TypeScript en el módulo de auth". Aceptas la primera edición. El agente ejecuta el build. Falla con otro error. Edita otro archivo. Build de nuevo. Falla de nuevo. Edita. Build. Falla. Edita.

Miras la factura de la API: **47 llamadas en 3 minutos. $4.20 quemados.**

El agente estaba en un **bucle infinito** — intentando arreglar un error que su propio fix anterior creó. Es el problema más caro y frustrante de los agentes de IA autónomos.

## Por qué los agentes entran en bucle

### El ciclo clásico: fix → nuevo error → fix → error original

```
Iteración 1: Error — "Type 'string' is not assignable to type 'number'"
  → Fix: cambia el tipo a string
Iteración 2: Error — "Argument of type 'string' is not assignable to parameter of type 'number'"
  → Fix: cambia el parámetro a string
Iteración 3: Error — "Type 'string' is not assignable to type 'number'" (en otro archivo)
  → Fix: cambia ese tipo también
Iteración 4: Error — el tipo original que cambió en iteración 1 ahora falla
  → Vuelve al inicio. Bucle infinito.
```

El agente no tiene **memoria de su estrategia**. Cada iteración la trata como un problema nuevo. No sabe que ya intentó la misma solución hace 3 pasos.

### Las 5 situaciones que provocan bucles

| Situación | Por qué falla el agente |
|-----------|------------------------|
| **Errores de tipos en cadena** | Arreglar un tipo rompe otro archivo que depende de él |
| **Imports circulares** | A importa B, B importa A — el agente no detecta el ciclo |
| **Configuración de build** | tsconfig, webpack, vite.config — el agente cambia opciones al azar |
| **Tests que fallan por mocks** | Actualiza el código pero no los mocks, o viceversa infinitamente |
| **Errores de permisos/entorno** | El agente no puede arreglar lo que no es código (PATH, permisos de archivo) |

## Cómo parar un agente en bucle

### 1. Parada inmediata

| Herramienta | Cómo parar |
|-------------|-----------|
| **Cline / Claude Dev** | Botón "Cancel" en la barra del agente, o cierra la pestaña del chat |
| **Cursor** | `Escape` o botón "Stop" en la ventana del composer |
| **Aider** | `Ctrl+C` en la terminal |
| **GitHub Copilot Agent** | Botón "Stop" en el panel de chat |

### 2. Si no responde al cancel

```bash
# Mata la terminal integrada de VS Code
# Ctrl+Shift+P → "Terminal: Kill Active Terminal"

# O cierra VS Code directamente — no perderás archivos (auto-save)
```

### 3. Recuperar tu código tras un bucle

```bash
# Ver qué archivos cambió el agente
git diff --name-only

# Ver los cambios exactos
git diff

# Deshacer TODO lo que hizo el agente
git checkout .

# O, si hay cambios que sí querías, deshaz archivo por archivo
git checkout -- src/archivo-que-no-quieres.ts
```

> Si usas [Aider](/blog/aider-ia-terminal-barata-alternativa-cursor-2026/), cada iteración es un commit — puedes hacer `git revert` del último commit del agente sin perder nada.

## Cómo prevenir los bucles

### 1. Configura un límite de iteraciones

**Cline:** En la configuración de la extensión:
```json
{
  "cline.maxIterations": 5,
  "cline.autoApprove": false  // NUNCA actives auto-approve sin límite
}
```

**Cursor:** En Settings → AI → Max iterations: pon 5-10 como máximo.

**API directa:** Si tienes tu propio loop de agente:
```javascript
const MAX_ITERATIONS = 5;
let iterations = 0;

while (hasErrors && iterations < MAX_ITERATIONS) {
  const fix = await agent.suggestFix(currentError);
  await applyFix(fix);
  const result = await runBuild();
  hasErrors = !result.success;
  iterations++;

  if (iterations >= MAX_ITERATIONS) {
    console.log('⚠️ Límite de iteraciones alcanzado. Revisión manual necesaria.');
    break;
  }
}
```

### 2. No le des autonomía total

```
// ❌ Prompt peligroso
"Arregla todos los errores de TypeScript del proyecto hasta que compile"

// ✅ Prompt controlado  
"Arregla el error de tipo en src/auth/service.ts línea 45. 
Solo modifica ese archivo. Si el fix requiere cambiar otros archivos, 
dime cuáles ANTES de hacerlo."
```

### 3. Divide los errores en tareas pequeñas

En vez de "arregla los 15 errores de TypeScript", haz:

```
Tarea 1: "Arregla el error en src/auth/service.ts:45"
→ Verifica que compila
Tarea 2: "Arregla el error en src/users/controller.ts:12" 
→ Verifica que compila
...
```

Un error a la vez. Si un fix introduce un error nuevo, lo detectas inmediatamente.

### 4. Usa el modo "Dry Run" o "Plan"

Antes de que el agente ejecute cambios, pídele un plan:

```
ANTES de hacer cambios, analiza el error y dame:
1. Qué archivos necesitas modificar
2. Qué cambio harás en cada uno
3. Qué otros archivos podrían verse afectados

NO hagas cambios todavía. Solo el plan.
```

Revisa el plan. Si ves que va a tocar 10 archivos para un "error simple", probablemente el agente no entiende el problema y va a entrar en bucle.

### 5. Guarda un checkpoint antes de cada tarea del agente

```bash
# Antes de darle una tarea al agente
git add -A && git commit -m "checkpoint: antes de refactor auth"

# Si el agente la lía, vuelves al checkpoint
git reset --hard HEAD
```

## Coste real de un bucle: Caso con números

Un bucle típico de 20 iteraciones con Cline + Claude Sonnet 4:

| Concepto | Tokens | Coste |
|----------|--------|-------|
| Input por iteración (código + instrucciones) | ~8.000 | $0.024 |
| Output por iteración (fix + explicación) | ~2.000 | $0.030 |
| **20 iteraciones** | **200.000** | **$1.08** |

Con Claude Opus 4 el mismo bucle cuesta **~$6.40**. Con GPT-4.1 cuesta **~$1.60**.

Si el agente está en modo autónomo y nadie lo para, puede hacer 100+ iteraciones antes de que llegues al límite de la API. Eso son **$5-30 quemados** por un error que podrías haber arreglado manualmente en 5 minutos.

> Para controlar estos costes, revisa [cómo programar con IA sin arruinarte](/blog/programar-con-ia-sin-arruinarte-guia-2026/) y [caveman prompting para ahorrar tokens](/blog/caveman-prompting-ahorrar-tokens-ia-2026/).

## Checklist anti-bucle

- [ ] Límite de iteraciones configurado (5-10 max)
- [ ] Auto-approve desactivado
- [ ] Commit/checkpoint antes de cada tarea del agente
- [ ] Tareas pequeñas y específicas (un error a la vez)
- [ ] Plan antes de ejecución en tareas complejas
- [ ] `git diff` después de cada tarea para revisar los cambios
- [ ] Rate limit o presupuesto diario configurado en la API

## Artículos relacionados

- [Errores comunes al configurar Copilot, Cline y Cursor en VS Code](/blog/errores-comunes-ia-vscode-copilot-cline-2026/) — problemas de configuración
- [Aider: programar con IA desde la terminal](/blog/aider-ia-terminal-barata-alternativa-cursor-2026/) — el agente que hace commits automáticos (fácil de revertir)
- [Error 429 Too Many Requests](/blog/error-429-too-many-requests-api-ia-2026/) — qué hacer cuando el bucle agota tus rate limits]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cómo Resolver un Git Merge Conflict Masivo Provocado por Código de IA]]></title>
      <link>https://francobosg.netlify.app/blog/git-merge-conflict-codigo-ia-2026/</link>
      <description><![CDATA[Git merge conflicts gigantes por código generado con Copilot, Cursor o ChatGPT. Herramientas, estrategias y comandos para resolverlos sin perder tu trabajo.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/git-merge-conflict-codigo-ia-2026/</guid>
      <category>Git</category>
      <category>IA</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Tu compañero pushea cambios en `main`. Tú llevas 3 días en una rama donde Cursor refactorizó medio proyecto. Haces `git merge main` y:

```
Auto-merging src/components/Dashboard.tsx
CONFLICT (content): Merge conflict in src/components/Dashboard.tsx
Auto-merging src/utils/api.ts
CONFLICT (content): Merge conflict in src/utils/api.ts
Auto-merging src/hooks/useAuth.ts
CONFLICT (content): Merge conflict in src/hooks/useAuth.ts
...
Automatic merge failed; fix conflicts and then commit the result.
```

**37 archivos con conflictos**. No puedes hacer merge. No puedes hacer push. Estás atrapado.

Esto pasa todo el tiempo con código generado por IA. La IA toca muchos archivos, reformatea código existente y genera cambios "ruidosos" que Git no puede resolver automáticamente.

## Por qué la IA genera más conflictos

### 1. Refactoring masivo

Cuando le pides a Cursor "refactoriza el módulo de auth", toca 15 archivos. Si alguien editó cualquiera de esos 15 archivos en otra rama → conflicto.

### 2. Reformateo invisible

La IA a menudo cambia el formato del código sin cambiar la lógica:

```diff
// Tu compañero escribió:
- const result = await fetch(url).then(r => r.json())
  
// La IA "mejoró" el mismo archivo:
+ const response = await fetch(url);
+ const result = await response.json();
```

Semánticamente es lo mismo, pero Git ve líneas diferentes → conflicto.

### 3. Imports reorganizados

```diff
// Rama original:
- import { useState } from 'react';
- import { useRouter } from 'next/router';

// La IA reorganizó:
+ import { useRouter } from 'next/router';
+ import { useState } from 'react';
```

Solo cambió el orden. Git: **CONFLICT**.

## Paso 1: No entres en pánico — Evalúa el daño

```bash
# ¿Cuántos archivos tienen conflicto?
git diff --name-only --diff-filter=U

# ¿Cuántos conflictos por archivo?
grep -rn "<<<<<<< " --include="*.ts" --include="*.tsx" --include="*.js" | wc -l

# Ver un resumen visual
git mergetool --tool=vimdiff  # O usa VS Code
```

Clasifica los conflictos:

| Tipo | Cantidad típica | Estrategia |
|------|----------------|------------|
| Solo formato (misma lógica) | 60% | Acepta una versión completa |
| Cambio real en ambos lados | 25% | Merge manual |
| Archivos que solo tocó la IA | 10% | Acepta la versión de la IA (o la tuya) |
| Archivos nuevos/eliminados | 5% | Decide cuáles mantener |

## Paso 2: Resuelve los conflictos fáciles primero

### Acepta una versión completa para archivos de solo formato

```bash
# Acepta la versión de la rama actual (la tuya / la de la IA)
git checkout --ours src/utils/format.ts

# Acepta la versión de la otra rama (main)
git checkout --theirs src/utils/format.ts

# Marcar como resuelto
git add src/utils/format.ts
```

### Resolver varios archivos de golpe

```bash
# Aceptar TODOS los archivos de un directorio desde la otra rama
git checkout --theirs src/styles/*
git add src/styles/*

# Aceptar todos los archivos que solo cambió la IA
# (archivos que no existen en la otra rama = solo IA los tocó)
git diff --name-only --diff-filter=U | xargs git checkout --ours
```

> **Cuidado**: `--ours` y `--theirs` se invierten en `git rebase` vs `git merge`. En merge: `ours` = tu rama, `theirs` = la rama que estás mergeando. En rebase es al revés.

## Paso 3: VS Code Merge Editor para conflictos complejos

VS Code tiene un editor de merge visual:

1. Abre un archivo con conflicto
2. VS Code muestra botones: **Accept Current | Accept Incoming | Accept Both | Compare**
3. Usa "Compare Changes" para ver las diferencias lado a lado

Para archivos más complejos:

```
Ctrl+Shift+P → "Merge Editor: Open Merge Editor"
```

El editor de 3 vías muestra:
- **Izquierda**: Tu versión (current)
- **Derecha**: La otra versión (incoming)
- **Abajo**: El resultado combinado

Puedes seleccionar líneas de cada lado y construir el resultado.

## Paso 4: Pedir a la IA que resuelva el conflicto

Para conflictos específicos, puedes pasar el diff a un modelo:

```
Este archivo tiene un merge conflict. Aquí está el diff completo:

[Pega el contenido del archivo con los marcadores <<<<<<< ======= >>>>>>>]

Resuelve el conflicto combinando ambas versiones. Mantén toda la 
funcionalidad de ambas ramas. Dame el archivo completo resuelto, 
SIN marcadores de conflicto.
```

**Cuándo funciona bien**: Conflictos en imports, cambios de formato, renombrado de variables.

**Cuándo NO usarlo**: Lógica de negocio diferente en ambas ramas, cambios en la estructura de datos.

## Paso 5: Verificar y commitear

```bash
# Verificar que no quedan conflictos
grep -rn "<<<<<<< " --include="*.ts" --include="*.tsx" --include="*.js"
# (debe devolver 0 resultados)

# Verificar que compila
npm run build

# Verificar que los tests pasan
npm test

# Commit del merge
git add -A
git commit -m "merge: resolver conflictos con main tras refactor IA"
```

## Cómo prevenir conflictos masivos con IA

### 1. Commits pequeños y frecuentes

```bash
# ❌ Un commit gigante tras 3 días de refactoring con IA
git add -A && git commit -m "refactor everything"

# ✅ Un commit por cada tarea del agente
git add src/auth/ && git commit -m "refactor: auth module con Cursor"
git add src/api/ && git commit -m "refactor: api client con Cursor"
```

### 2. Merge main frecuentemente

```bash
# Cada mañana, antes de seguir trabajando con la IA:
git fetch origin
git merge origin/main
# Resolver conflictos pequeños es más fácil que uno masivo
```

### 3. Configura Prettier ANTES del refactoring de IA

Si todos usan el mismo Prettier config, la IA no puede reformatear "a su estilo":

```bash
# Antes del refactoring, formatea todo el proyecto
npx prettier --write "src/**/*.{ts,tsx,js,jsx}"
git add -A && git commit -m "style: formateo con prettier"

# AHORA deja que la IA trabaje
# Los cambios serán solo de lógica, no de formato
```

### 4. Pide a la IA que no reformatee

```
Modifica SOLO la lógica de autenticación en src/auth/service.ts.
NO cambies el formato, el orden de los imports ni renombres variables 
que no sean necesarias. Minimiza los cambios — menos líneas cambiadas = 
menos conflictos en git.
```

### 5. Usa ramas cortas

```bash
# ❌ Rama de 2 semanas con 200 commits de IA
git checkout -b feature/mega-refactor  # Conflictos garantizados

# ✅ Ramas de 1-2 días máximo
git checkout -b fix/auth-validation    # Merge rápido, pocos conflictos
```

## Herramientas útiles

| Herramienta | Para qué |
|-------------|----------|
| **VS Code Merge Editor** | Merge visual de 3 vías integrado |
| **git mergetool** | Abre la herramienta de merge configurada |
| **IntelliJ Merge** | El mejor merge visual (disponible en Community Edition gratis) |
| **meld** | Herramienta visual de merge para Linux |
| **delta** | Mejor visualización de diffs en terminal |
| **git rerere** | Recuerda cómo resolviste conflictos anteriores |

### Activar git rerere (muy recomendado)

```bash
git config --global rerere.enabled true
```

`rerere` = "reuse recorded resolution". Si resuelves un conflicto y luego haces otro merge con el mismo conflicto, Git lo resuelve automáticamente con la misma solución.

## Resumen

| Situación | Acción |
|-----------|--------|
| Conflicto de solo formato | `git checkout --ours/--theirs` |
| Conflicto de lógica simple | VS Code Merge Editor |
| Conflicto complejo | Pasar a ChatGPT + revisar manualmente |
| Prevención | Commits pequeños + merge diario + Prettier |
| Monorepo con IA | Ramas cortas + no reformatear + `rerere` |

## Artículos relacionados

- [Comandos Git esenciales](/blog/comandos-git-esenciales-2026/) — los comandos que necesitas para manejar conflictos
- [Cursor vs Copilot vs Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/) — qué editor genera cambios más limpios
- [Errores comunes al configurar IA en VS Code](/blog/errores-comunes-ia-vscode-copilot-cline-2026/) — problemas con extensiones de IA]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[La IA Me Da Código Incompleto: Cómo Solucionar el 'Lazy Coding' de ChatGPT y Claude]]></title>
      <link>https://francobosg.netlify.app/blog/ia-codigo-incompleto-lazy-coding-solucion-2026/</link>
      <description><![CDATA[¿ChatGPT o Claude te dicen 'aquí va el resto del código'? Por qué ocurre el lazy coding, cómo evitarlo con prompts específicos y qué modelo lo hace menos.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/ia-codigo-incompleto-lazy-coding-solucion-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Abres ChatGPT, le pides que refactorice tu componente de 200 líneas, y te devuelve esto:

```typescript
export function Dashboard() {
  // ... existing imports ...

  const [data, setData] = useState(null);

  // Aquí va tu lógica de fetch existente

  return (
    <div>
      {/* ... resto del JSX ... */}
      <NewFeature data={data} />
    </div>
  );
}
```

**60% del código es `// ...`**. No te ha dado nada útil. Has perdido 30 segundos esperando la respuesta y ahora tienes que juntar las piezas como un rompecabezas.

Este problema tiene nombre: **Lazy Coding**. Y todos los modelos de IA lo hacen — unos más que otros.

## Por qué la IA hace lazy coding

### 1. Límite de tokens de salida

Cada modelo tiene un máximo de tokens que puede generar en una respuesta:

| Modelo | Max output tokens | ¿Suficiente para código largo? |
|--------|------------------|-------------------------------|
| GPT-4.1 | 32.768 | Sí, pero se "cansa" antes |
| GPT-4.1 mini | 16.384 | Justo para archivos medianos |
| Claude Sonnet 4 | 16.384 | Buen balance |
| Claude Opus 4 | 32.768 | El más generoso |

Pero el problema no es solo el límite técnico — los modelos están **entrenados para ser concisos**. En el entrenamiento, las respuestas cortas y resumidas se premiaron más que las respuestas exhaustivas.

### 2. El modelo "cree" que ya sabes el resto

Cuando le pasas tu código existente y pides un cambio, el modelo interpreta que solo quieres ver **la diferencia**. Es lógico para una conversación humana, pero inútil cuando necesitas copiar-pegar el archivo completo.

### 3. Repeticiones penalizadas

Los LLMs penalizan la generación de texto repetitivo. Si tu archivo tiene 50 líneas de JSX similares, el modelo las resume con `// ... resto del JSX` porque generar cada una le "cuesta" probabilísticamente.

## Cómo evitarlo: Prompts que funcionan

### Prompt directo anti-lazy

```
Reescribe este archivo COMPLETO con los cambios aplicados. 
NO omitas código. NO uses "// ..." ni "// existing code". 
Incluye TODAS las líneas del archivo, incluidas las que no cambian.
Necesito poder copiar y pegar tu respuesta directamente.
```

### Prompt para cambios parciales (cuando es un archivo enorme)

```
Muéstrame SOLO las funciones que cambian, pero cada función COMPLETA 
(desde la declaración hasta el cierre). No omitas líneas dentro 
de las funciones.
```

### Prompt estilo diff (ahorra tokens y es preciso)

```
Dame los cambios en formato diff (unified diff). 
Solo las líneas que cambian con 3 líneas de contexto.
```

Respuesta del modelo:

```diff
@@ -15,7 +15,9 @@ export function Dashboard() {
   const [data, setData] = useState(null);
+  const [loading, setLoading] = useState(true);

   useEffect(() => {
-    fetchData().then(setData);
+    setLoading(true);
+    fetchData().then(setData).finally(() => setLoading(false));
   }, []);
```

Esto es mucho más útil que recibir medio archivo con `// ...`.

## Comparativa: ¿Qué herramienta hace menos lazy coding?

Probé el mismo prompt ("refactoriza este componente de 180 líneas para añadir cache") en 5 herramientas:

| Herramienta | Código completo | Lazy coding | Notas |
|------------|----------------|-------------|-------|
| ChatGPT (web) | ❌ 40% | 60% omitido | El peor — resume todo |
| Claude (web) | ⚠️ 70% | 30% omitido | Mejor, pero aún corta |
| GitHub Copilot Chat | ⚠️ 65% | 35% omitido | Depende del prompt |
| Cursor (Agent mode) | ✅ 95% | 5% omitido | Edita el archivo directamente |
| Aider + Claude | ✅ 98% | 2% omitido | El mejor — edita con diffs |

> Los editores que trabajan directamente con archivos (Cursor, Aider, Copilot Edits) hacen mucho menos lazy coding porque no necesitan "mostrar" el código en chat — lo aplican directamente.

## Técnica avanzada: System prompt para APIs

Si usas la API directamente (no la web), puedes añadir un system prompt persistente:

```javascript
const response = await openai.chat.completions.create({
  model: 'gpt-4.1',
  messages: [
    {
      role: 'system',
      content: `Eres un asistente de código. Reglas estrictas:
1. SIEMPRE escribe el código completo, nunca omitas líneas
2. NUNCA uses "// ..." o "// existing code" o "// resto del código"
3. Si el archivo es muy largo, divide la respuesta en partes numeradas
4. Cada función debe estar completa de principio a fin`
    },
    {
      role: 'user',
      content: `Refactoriza este archivo:\n\n${fileContent}`
    }
  ],
  max_tokens: 16384,  // Asegúrate de dar suficiente espacio
});
```

> Para APIs, revisa [cómo usar la API de ChatGPT y Claude sin arruinarte](/blog/programar-con-ia-sin-arruinarte-guia-2026/). El max_tokens alto sube el coste pero evita respuestas cortadas.

## Técnica nuclear: Divide el archivo antes de pedir

Si tu archivo tiene +300 líneas, **no se lo pases entero**. Divídelo:

```
Tengo un archivo con 3 secciones:
1. Imports y tipos (líneas 1-30)  
2. Hooks y lógica (líneas 31-120)
3. JSX/render (líneas 121-300)

Necesito cambiar la sección 2. Te la paso completa.
Dame la sección 2 COMPLETA con los cambios. Yo la pegaré en su sitio.

[Pega solo las líneas 31-120]
```

Así el modelo no tiene la tentación de resumir las otras secciones porque no las tiene.

## Configuración en editores para reducir lazy coding

### Cursor

```json
// .cursorrules (en la raíz del proyecto)
Always write complete code. Never use placeholder comments like 
"// existing code" or "// ...". When editing a file, rewrite the 
entire function or component, not just the changed lines.
```

### GitHub Copilot

En VS Code, usa el modo **Copilot Edits** en lugar del chat normal. Edits modifica el archivo directamente y no necesita "mostrar" el código.

### Cline / Claude Dev

```json
// En la configuración de Cline, system prompt personalizado:
"When writing code, always provide complete implementations. 
Never abbreviate with comments. Write every line."
```

## ¿Cuándo está bien que la IA resuma?

No siempre es malo. A veces **quieres** que resuma:

- **Revisión de código**: "¿Ves bugs en este archivo?" → No necesitas que reescriba todo
- **Explicaciones**: "¿Qué hace esta función?" → Solo necesitas la explicación
- **Diffs pequeños**: Si cambias una línea, no necesitas las otras 299

El lazy coding solo es un problema cuando necesitas **código ejecutable que puedas copiar y pegar** o que el editor aplique directamente.

## Resumen

| Situación | Solución |
|-----------|----------|
| Chat web (ChatGPT/Claude) | Prompt explícito: "código COMPLETO, sin omisiones" |
| API directa | System prompt + max_tokens alto |
| Archivo enorme (+300 líneas) | Divide en secciones, pide una a la vez |
| Editor (Cursor/Copilot) | Usa modo Agent/Edits en vez de chat |
| Terminal (Aider) | Ya genera código completo por defecto |

## Artículos relacionados

- [Mejores prompts para programar con IA](/blog/mejores-prompts-programar-ia-2026/) — técnicas avanzadas para sacar código de calidad
- [Cursor vs Copilot vs Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/) — qué editor genera código más completo
- [Errores comunes al configurar Copilot y Cline](/blog/errores-comunes-ia-vscode-copilot-cline-2026/) — soluciones rápidas para problemas del editor]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[La IA Me Sugirió un Paquete NPM que No Existe: Alucinaciones en Desarrollo Web]]></title>
      <link>https://francobosg.netlify.app/blog/ia-alucina-paquetes-npm-no-existen-2026/</link>
      <description><![CDATA[ChatGPT y Copilot inventan paquetes npm, APIs y funciones que no existen. Cómo detectar alucinaciones, verificar dependencias y evitar instalar malware camuflado.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/ia-alucina-paquetes-npm-no-existen-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Le pides a ChatGPT cómo implementar autenticación con OAuth en Express y te responde:

```bash
npm install express-oauth2-simple
```

Lo ejecutas. `npm ERR! 404 Not Found`. El paquete **no existe**. ChatGPT lo inventó.

No es un bug. Es una **alucinación** — el modelo generó un nombre de paquete que "suena" correcto pero nunca existió en npm.

Y lo peor: a veces el paquete **sí existe**... porque un atacante lo registró sabiendo que la IA lo iba a sugerir.

## Por qué la IA inventa paquetes

Los LLMs no tienen acceso en tiempo real a npm. Generan texto basándose en patrones estadísticos:

- "express" + "oauth" + "simple" = combinación probable → genera `express-oauth2-simple`
- "react" + "auth" + "helper" = combinación probable → genera `react-auth-helper`
- "python" + "flask" + "cors" = combinación probable → genera `flask-cors-helper`

Ninguno de estos existe (al momento de escribir esto). Pero el modelo **no sabe** que no existen — solo sabe que el nombre encaja con el patrón.

### Cuándo ocurre más

| Contexto | Probabilidad de alucinación |
|----------|-----------------------------|
| Paquetes populares (express, react, lodash) | **Baja** (~2%) |
| Paquetes de nicho | **Alta** (~15-20%) |
| Paquetes con nombre de 3+ palabras | **Muy alta** (~25%) |
| APIs internas de un paquete real | **Media** (~10%) |

El modelo no solo inventa paquetes — también inventa **funciones y métodos** de paquetes reales. Ejemplo:

```javascript
// ❌ La IA genera esto, pero .streamChat() no existe en el SDK de OpenAI
const stream = await openai.streamChat({ model: 'gpt-4.1', ... });

// ✅ La API real es:
const stream = await openai.chat.completions.create({ ..., stream: true });
```

## El peligro real: Slopsquatting

Un investigador de seguridad publicó en 2025 un estudio que demostró:

1. Recopiló los 1.000 nombres de paquetes que ChatGPT más alucinaba
2. Registró 10 de ellos en npm con código inofensivo (para la investigación)
3. En una semana, recibieron **+4.500 descargas** — la mayoría de desarrolladores que copiaron y pegaron código de la IA

El ataque se llama **slopsquatting** (una variación de typosquatting):

```
1. La IA alucina "react-oauth-simplified"
2. Un atacante registra "react-oauth-simplified" en npm con malware
3. Un desarrollador hace: npm install react-oauth-simplified
4. El paquete malicioso se ejecuta en postinstall
5. Roba variables de entorno (.env), tokens, SSH keys
```

No es teoría. Ya ha ocurrido con paquetes reales.

## Cómo verificar antes de instalar

### Paso 1: Busca en npmjs.com

Antes de `npm install lo-que-sea`, abre [npmjs.com](https://www.npmjs.com) y busca el paquete:

- **¿Existe?** Si no aparece, la IA lo inventó
- **¿Tiene descargas?** Un paquete legítimo tiene miles/semana. Menos de 100 → sospechoso
- **¿Cuándo se publicó?** Si se publicó hace días y la IA lo sugiere... es slopsquatting
- **¿Tiene repositorio de GitHub?** Un paquete sin repo es una red flag

### Paso 2: Usa npm info

```bash
# Antes de instalar, verifica
npm info react-oauth-simplified

# Si no existe, verás:
# npm ERR! 404 'react-oauth-simplified' is not in this registry

# Si existe, revisa los campos:
# - maintainers (¿quién lo mantiene?)
# - repository (¿tiene GitHub?)  
# - weekly downloads (¿es popular?)
# - created (¿es nuevo sospechoso?)
```

### Paso 3: Usa Socket.dev o Snyk

```bash
# Socket.dev analiza paquetes npm buscando malware
npx socket optimize  # Audita tu package.json

# Snyk
npx snyk test  # Busca vulnerabilidades conocidas
```

### Paso 4: Verifica la API del paquete real

Si la IA te sugiere un método de un paquete que sí existe, verifica en la documentación oficial:

```javascript
// La IA dice:
import { verifyJWT } from 'jsonwebtoken';

// Pero la API real es:
import jwt from 'jsonwebtoken';
jwt.verify(token, secret);
```

Abre la documentación o el README del paquete en GitHub. Nunca confíes en la API que la IA te muestra.

## Tabla de alucinaciones comunes

Paquetes que la IA sugiere frecuentemente y **no existen**:

| La IA sugiere | El paquete real es |
|--------------|--------------------|
| `express-oauth2-simple` | `passport` + `passport-google-oauth20` |
| `react-auth-helper` | `next-auth` o `@auth/core` |
| `node-env-config` | `dotenv` |
| `mongodb-easy-connect` | `mongoose` o el driver oficial `mongodb` |
| `typescript-path-resolver` | `tsconfig-paths` |
| `react-form-validation` | `react-hook-form` + `zod` |

> **Regla general**: Si el nombre del paquete describe exactamente lo que quieres hacer, probablemente la IA lo inventó. Los paquetes reales suelen tener nombres más creativos (express, lodash, zod, prisma).

## Cómo pedir dependencias a la IA sin riesgo

### Prompt seguro

```
Necesito implementar autenticación OAuth2 en Express.js.
Dame el nombre EXACTO del paquete npm más popular para esto.
Incluye el enlace a su página en npmjs.com o su repositorio de GitHub.
NO inventes paquetes — si no estás seguro del nombre exacto, dímelo.
```

### Prompt de verificación

```
¿El paquete "express-oauth2-simple" existe realmente en npm? 
Si no existe, ¿cuál es la alternativa real más usada?
```

La mayoría de modelos recientes (GPT-4.1, Claude Sonnet 4) son más honestos cuando les preguntas directamente si están seguros.

## Script para auditar dependencias alucinadas

Si ya tienes un proyecto con muchas dependencias y quieres verificar que todas son legítimas:

```bash
#!/bin/bash
# audit-deps.sh — Verifica que todas tus dependencias existen en npm

echo "🔍 Verificando dependencias..."

for pkg in $(node -e "
  const pkg = require('./package.json');
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
  console.log(Object.keys(deps).join('\n'));
"); do
  result=$(npm view "$pkg" name 2>&1)
  if echo "$result" | grep -q "404"; then
    echo "❌ $pkg — NO EXISTE en npm"
  else
    downloads=$(npm view "$pkg" --json 2>/dev/null | node -e "
      let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
        try{console.log(JSON.parse(d).name)}catch{console.log('?')}
      })")
    echo "✅ $pkg"
  fi
done
```

## Resumen

| Acción | Cuándo |
|--------|--------|
| Buscar en npmjs.com | **Siempre** antes de instalar un paquete sugerido por IA |
| `npm info paquete` | Cuando tienes dudas sobre un nombre |
| Verificar descargas semanales | Si el paquete existe pero no lo conoces |
| Revisar la API en la documentación | Cuando la IA usa métodos que no reconoces |
| Pedir enlace a GitHub/npm al prompt | Como hábito en cada prompt |

## Artículos relacionados

- [Testear código generado por IA](/blog/testear-codigo-generado-ia-copilot-cursor-2026/) — no confíes ciegamente en lo que genera
- [Mejores modelos de IA para programar](/blog/mejores-modelos-ia-para-programar-2026/) — qué modelos alucinan menos
- [Errores comunes al configurar Copilot y Cline](/blog/errores-comunes-ia-vscode-copilot-cline-2026/) — más problemas con IA en el editor]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Prettier No Formatea al Guardar en VS Code: Guía Definitiva Paso a Paso]]></title>
      <link>https://francobosg.netlify.app/blog/prettier-no-formatea-guardar-vscode-2026/</link>
      <description><![CDATA[¿Prettier no funciona al pulsar Ctrl+S? Solución paso a paso para VS Code: extensión, settings.json, conflictos con ESLint, .prettierrc y formatOnSave.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/prettier-no-formatea-guardar-vscode-2026/</guid>
      <category>VS Code</category>
      <category>Herramientas</category>
      <category>JavaScript</category>
      <content:encoded><![CDATA[Instalas Prettier. Configuras un `.prettierrc`. Pulsas `Ctrl+S` para guardar y... nada. El archivo se guarda pero **no se formatea**.

Abres otro proyecto, funciona perfecto. Vuelves al tuyo, nada. Llevas 20 minutos tocando configuraciones y sigue sin funcionar.

Es el problema más común de VS Code y tiene una solución sistemática.

## TL;DR — Checklist rápido

Si tienes prisa, revisa esto en orden:

1. ¿Extensión de Prettier instalada y habilitada? (`esbenp.prettier-vscode`)
2. ¿`formatOnSave` activado en settings.json?
3. ¿Prettier es el formateador por defecto?
4. ¿Tienes un `.prettierrc` en la raíz del proyecto?
5. ¿Hay conflicto con otra extensión (Beautify, ESLint)?
6. ¿El archivo está en `.prettierignore`?

Si checaste todo y sigue sin funcionar, sigue leyendo — hay causas más ocultas.

## Paso 1: Verifica la extensión

Abre VS Code → Extensions (`Ctrl+Shift+X`) → busca "Prettier":

- La extensión correcta es **"Prettier - Code formatter"** de **Prettier** (ID: `esbenp.prettier-vscode`)
- Verifica que dice **"Enabled"**, no "Disabled" ni "Enable (Workspace)"
- Si dice "Disabled", haz clic en "Enable"

> Hay extensiones falsas/antiguas como "Prettier+" o "JS Beautify Prettier". Desinstálalas si las tienes.

## Paso 2: Activa formatOnSave

Abre tu `settings.json` (`Ctrl+Shift+P` → "Preferences: Open User Settings (JSON)"):

```json
{
  "editor.formatOnSave": true
}
```

**Atención**: Hay DOS archivos settings.json:
- **User settings** (global): aplica a todos los proyectos
- **Workspace settings** (`.vscode/settings.json`): aplica solo a este proyecto

Si el workspace settings tiene `"editor.formatOnSave": false`, **anula** el user settings. Verifica ambos:

```bash
# User settings (global)
# Windows: %APPDATA%\Code\User\settings.json
# Mac: ~/Library/Application Support/Code/User/settings.json
# Linux: ~/.config/Code/User/settings.json

# Workspace settings (local)
# .vscode/settings.json en la raíz del proyecto
```

## Paso 3: Configura Prettier como formateador por defecto

Este es el paso que más gente olvida. VS Code puede tener varios formateadores instalados y no sabe cuál usar.

### Opción A: Desde la interfaz

1. Abre cualquier archivo `.js`, `.ts`, `.css` o `.html`
2. `Ctrl+Shift+P` → "Format Document With..."
3. Selecciona **"Configure Default Formatter..."**
4. Elige **"Prettier - Code formatter"**

### Opción B: En settings.json

```json
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode"
}
```

### Opción C: Por lenguaje (recomendado)

```json
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescriptreact]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[css]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[html]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  }
}
```

## Paso 4: Crea un .prettierrc

Sin un archivo de configuración, Prettier a veces se niega a formatear porque no sabe qué reglas aplicar.

Crea `.prettierrc` en la raíz del proyecto:

```json
{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100,
  "arrowParens": "always",
  "endOfLine": "lf"
}
```

> Si trabajas en un proyecto con más gente, acuerda las reglas primero. Un `.prettierrc` cambiado genera cientos de cambios en un solo commit.

## Paso 5: Resuelve conflictos con ESLint

El conflicto Prettier + ESLint es el problema más sutil. ESLint formatea el archivo, luego Prettier intenta reformatear, detecta un conflicto y no hace nada.

### Síntomas

- Prettier funciona en archivos `.css` o `.json` pero NO en `.js`/`.ts`
- Ves warnings de ESLint sobre formato (comillas, punto y coma)
- El archivo "parpadea" al guardar (se formatea y se vuelve a cambiar)

### Solución

```bash
npm install --save-dev eslint-config-prettier
```

En tu `eslint.config.js` o `.eslintrc`:

```javascript
// eslint.config.js (flat config)
import eslintConfigPrettier from 'eslint-config-prettier';

export default [
  // ... tus reglas de ESLint
  eslintConfigPrettier,  // SIEMPRE al final — desactiva reglas conflictivas
];
```

```json
// .eslintrc.json (formato legacy)
{
  "extends": [
    "eslint:recommended",
    "prettier"  // Siempre al final
  ]
}
```

Esto desactiva todas las reglas de ESLint que entran en conflicto con Prettier. Ahora ESLint detecta errores de lógica y Prettier se encarga del formato.

## Paso 6: Verifica que no está en .prettierignore

Si tienes un `.prettierignore` en la raíz del proyecto, verifica que tu archivo no está excluido:

```bash
# .prettierignore
dist/
node_modules/
*.min.js
# ¿Tienes algo como src/** o *.ts aquí por error?
```

## Paso 7: Output panel — El log que nadie mira

Si todo lo anterior está bien y sigue sin funcionar, VS Code tiene un log de Prettier:

1. `Ctrl+Shift+P` → "Output: Show Output Channel"
2. En el desplegable, selecciona **"Prettier"**
3. Guarda un archivo y mira qué dice

Errores comunes en el log:

| Error en Output | Causa | Solución |
|-----------------|-------|----------|
| `Cannot find module 'prettier'` | Prettier no está instalado en el proyecto | `npm install --save-dev prettier` |
| `Invalid configuration` | `.prettierrc` tiene syntax error | Valida el JSON |
| `File is ignored` | Archivo excluido por `.prettierignore` | Edita `.prettierignore` |
| `Multiple formatters` | Dos extensiones compiten | Desinstala la otra |

## Paso 8: Prettier como dependencia del proyecto

La extensión de VS Code puede usar su propio Prettier interno o el del proyecto. Para evitar inconsistencias:

```bash
npm install --save-dev prettier
```

Y en `.vscode/settings.json`:

```json
{
  "prettier.requireConfig": true
}
```

Esto obliga a la extensión a usar el Prettier instalado en `node_modules` y a requerir un `.prettierrc` — si falta, no formatea (y te avisa en el Output).

## Settings.json final (copia y pega)

```json
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "prettier.requireConfig": true,
  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescriptreact]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[javascriptreact]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[css]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[scss]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[html]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[markdown]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  }
}
```

## Resumen

| Problema | Solución |
|----------|----------|
| Extensión no instalada | Instalar `esbenp.prettier-vscode` |
| `formatOnSave` desactivado | Activar en settings.json |
| No es el formateador por defecto | `editor.defaultFormatter: esbenp.prettier-vscode` |
| Conflicto con ESLint | Instalar `eslint-config-prettier` |
| Sin `.prettierrc` | Crear en la raíz del proyecto |
| Archivo ignorado | Revisar `.prettierignore` |
| Error invisible | Revisar Output panel → Prettier |
| Prettier global vs local | `npm install --save-dev prettier` |

## Artículos relacionados

- [Errores comunes al configurar IA en VS Code](/blog/errores-comunes-ia-vscode-copilot-cline-2026/) — problemas similares con extensiones de IA
- [Comandos Git esenciales](/blog/comandos-git-esenciales-2026/) — si Prettier formatea un archivo entero y genera un diff enorme
- [Cursor vs Copilot vs Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/) — editores que incluyen formato integrado]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[TSServer Consume Mucha CPU o Se Bloquea en VS Code: Cómo Solucionarlo]]></title>
      <link>https://francobosg.netlify.app/blog/tsserver-consume-cpu-vscode-solucion-2026/</link>
      <description><![CDATA[¿El servidor de TypeScript (tsserver) consume 100% CPU en VS Code? Causas, soluciones paso a paso y configuraciones para proyectos grandes que hacen que VS Code vaya lento.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/tsserver-consume-cpu-vscode-solucion-2026/</guid>
      <category>VS Code</category>
      <category>TypeScript</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Abres VS Code con tu proyecto de TypeScript. El ventilador del portátil arranca. Miras el administrador de tareas: **tsserver.js consume 98% de CPU y 3GB de RAM**. El autocompletado tarda 5 segundos. Escribir una línea congela el editor.

Es el problema número uno de TypeScript en VS Code y afecta especialmente a monorepos y proyectos con +500 archivos.

## Qué es TSServer y por qué consume tanto

**TSServer** es el proceso que VS Code lanza para:

- Autocompletado (IntelliSense)
- Verificación de tipos en tiempo real
- Navegación (Go to Definition, Find References)
- Refactoring automático

Para hacer esto, tsserver **carga el proyecto entero en memoria**: todos los `.ts`, `.tsx`, los `.d.ts` de `node_modules/@types`, y todo lo referenciado por tu `tsconfig.json`.

En un proyecto con React + Material UI + Prisma + tRPC:

| Concepto | Archivos | Memoria aprox. |
|----------|----------|----------------|
| Tu código | ~200 archivos | ~50 MB |
| `@types/react` | ~150 archivos | ~80 MB |
| Material UI types | ~500 archivos | ~200 MB |
| Prisma Client generado | ~50 archivos | ~100 MB |
| Otros `@types/*` | ~300 archivos | ~150 MB |
| **Total** | **~1.200 archivos** | **~580 MB** |

Y eso es un proyecto mediano. Un monorepo con 5+ packages puede llegar a **3-4 GB** fácilmente.

## Solución rápida: Reiniciar TSServer

Antes de tocar configuraciones, prueba reiniciarlo:

```
Ctrl+Shift+P → "TypeScript: Restart TS Server"
```

Si tras reiniciar vuelve a consumir CPU en 30 segundos, el problema es estructural — sigue leyendo.

## Solución 1: Excluir archivos del análisis

### tsconfig.json — exclude

```json
{
  "compilerOptions": { ... },
  "exclude": [
    "node_modules",
    "dist",
    "build",
    ".next",
    "coverage",
    "**/*.test.ts",
    "**/*.spec.ts",
    "**/*.stories.tsx"
  ]
}
```

Los archivos en `exclude` no se analizan para tipos. Si tus tests o Storybook stories importan cosas raras que enlentecen tsserver, exclúyelos.

### tsconfig.json — include (más agresivo)

En vez de excluir, di explícitamente qué incluir:

```json
{
  "compilerOptions": { ... },
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx"
  ]
}
```

Esto hace que tsserver ignore todo lo que no esté en `src/`. Drástico pero efectivo.

## Solución 2: Configurar VS Code

### Limitar memoria de TSServer

```json
// settings.json
{
  // Memoria máxima para tsserver (en MB)
  "typescript.tsserver.maxTsServerMemory": 4096,

  // Desactivar análisis automático de proyectos adyacentes
  "typescript.disableAutomaticTypeAcquisition": true,

  // Retrasar el análisis al escribir (menos CPU)
  "typescript.suggest.autoImports": true
}
```

### Desactivar features que no uses

```json
{
  // Si no usas las sugerencias de imports automáticos
  "typescript.suggest.autoImports": false,

  // Si no usas el resaltado de referencias
  "editor.occurrencesHighlight": "off",

  // Si no necesitas el breadcrumb (barra de navegación arriba)
  "breadcrumbs.enabled": false,

  // Desactivar validación de JS si solo trabajas con TS
  "javascript.validate.enable": false
}
```

## Solución 3: Monorepos — Un TSServer por package

En monorepos, tsserver intenta cargar **todo**. El truco es abrir solo el package en el que trabajas:

```bash
# ❌ Abrir la raíz del monorepo (carga todo)
code /mi-monorepo

# ✅ Abrir solo el package específico
code /mi-monorepo/packages/frontend
```

O usa workspaces de VS Code:

```json
// mi-monorepo.code-workspace
{
  "folders": [
    { "path": "packages/frontend" },
    { "path": "packages/api" }
  ],
  "settings": {
    "typescript.tsserver.maxTsServerMemory": 3072
  }
}
```

Cada folder tendrá su propio tsserver, limitando la carga.

## Solución 4: skipLibCheck

Si los tipos de `node_modules` son el problema:

```json
// tsconfig.json
{
  "compilerOptions": {
    "skipLibCheck": true  // No verifica .d.ts de node_modules
  }
}
```

Esto reduce drásticamente el tiempo de análisis. La desventaja es que no detectarás errores en los tipos de las dependencias, pero en la práctica rara vez necesitas eso.

## Solución 5: El proyecto generado de Prisma

Prisma genera un cliente con miles de tipos. Si tienes Prisma y tsserver consume mucha CPU:

```json
// tsconfig.json
{
  "compilerOptions": {
    "skipLibCheck": true
  },
  "exclude": [
    "node_modules/.prisma"  // Excluir tipos generados de Prisma
  ]
}
```

Y en VS Code:

```json
// settings.json
{
  "files.watcherExclude": {
    "**/node_modules/.prisma/**": true
  }
}
```

## Solución 6: File watcher — Reducir lo que VS Code monitoriza

VS Code vigila cambios en archivos para reanalizar. En proyectos grandes, el watcher consume CPU:

```json
// settings.json
{
  "files.watcherExclude": {
    "**/node_modules/**": true,
    "**/dist/**": true,
    "**/.next/**": true,
    "**/build/**": true,
    "**/coverage/**": true,
    "**/.git/objects/**": true
  }
}
```

## Diagnóstico: Cómo saber qué consume CPU

### 1. Perfilado de TSServer

```
Ctrl+Shift+P → "TypeScript: Open TS Server Log"
```

Esto abre el log de tsserver. Busca líneas con tiempos altos:

```
[Info] Perf: 2845ms — getSemanticDiagnostics — src/components/Dashboard.tsx
[Info] Perf: 1203ms — getCompletionsAtPosition — src/utils/api.ts
```

Si un archivo tarda +1s, es el culpable. Probablemente tiene tipos complejos (generics anidados, conditional types, template literals).

### 2. Ver procesos de VS Code

```bash
# Windows — PowerShell
Get-Process -Name "node" | Sort-Object CPU -Descending | Select-Object -First 5

# Mac/Linux
ps aux | grep tsserver
```

### 3. VS Code Process Explorer

```
Ctrl+Shift+P → "Developer: Open Process Explorer"
```

Muestra todos los procesos de VS Code con CPU y memoria. El proceso `tsserver.js` aparecerá con su consumo real.

## Tabla resumen de soluciones

| Problema | Solución | Impacto en CPU |
|----------|----------|----------------|
| Muchos archivos | `exclude` / `include` en tsconfig | -40% |
| Tipos de node_modules | `skipLibCheck: true` | -30% |
| Monorepo completo | Abrir solo el package | -60% |
| File watcher | `files.watcherExclude` | -15% |
| Autocompletado lento | Desactivar `autoImports` | -20% |
| Prisma genera muchos tipos | Excluir `.prisma` | -25% |
| Todo junto | Aplicar todas | -70-80% |

## Artículos relacionados

- [Prettier no formatea al guardar](/blog/prettier-no-formatea-guardar-vscode-2026/) — otro problema común de VS Code
- [Errores comunes al configurar IA en VS Code](/blog/errores-comunes-ia-vscode-copilot-cline-2026/) — cuando Copilot también enlentece el editor
- [Cursor vs Copilot vs Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/) — editores alternativos que manejan mejor proyectos grandes]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[5 Animaciones con Framer Motion que Uso en Todos Mis Proyectos React]]></title>
      <link>https://francobosg.netlify.app/blog/animaciones-framer-motion-premium-2026/</link>
      <description><![CDATA[Las 5 animaciones de Framer Motion que copié-pegué en mis últimos 4 proyectos React/Next.js. Fade-in al scroll, page transitions, modales, skeleton loaders y stagger lists — con código completo.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/animaciones-framer-motion-premium-2026/</guid>
      <category>React</category>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Cada proyecto React que hago tiene las mismas 5 animaciones. Al principio las implementaba desde cero cada vez, hasta que me hice un mini-kit de componentes reutilizables con Framer Motion.

No son animaciones flashy. Son las típicas que hacen que una web pase de "funcional" a "se siente premium". La diferencia entre una web de alguien que acaba de aprender React y una web profesional suele ser esto: **las microinteracciones**.

## Instalación

```bash
npm install framer-motion
```

## 1. Fade-in al hacer scroll (el más usado)

Esta es la animación que más uso. Los elementos aparecen suavemente cuando entran en el viewport al hacer scroll.

```tsx
'use client';
import { motion } from 'framer-motion';

interface FadeInProps {
  children: React.ReactNode;
  delay?: number;
  direction?: 'up' | 'down' | 'left' | 'right';
  className?: string;
}

export function FadeIn({ children, delay = 0, direction = 'up', className }: FadeInProps) {
  const directionOffset = {
    up: { y: 40 },
    down: { y: -40 },
    left: { x: 40 },
    right: { x: -40 },
  };

  return (
    <motion.div
      initial={{ opacity: 0, ...directionOffset[direction] }}
      whileInView={{ opacity: 1, x: 0, y: 0 }}
      viewport={{ once: true, margin: '-100px' }}
      transition={{ duration: 0.5, delay, ease: 'easeOut' }}
      className={className}
    >
      {children}
    </motion.div>
  );
}
```

**Uso:**

```tsx
<FadeIn>
  <h2>Este título aparece suavemente</h2>
</FadeIn>

<FadeIn delay={0.2} direction="left">
  <Card />
</FadeIn>
```

**Por qué funciona**: `viewport: { once: true }` hace que la animación solo se ejecute una vez (no cada vez que scrolleas arriba y abajo). El `margin: '-100px'` hace que se active un poco antes de que el elemento sea visible, para que el usuario ya lo vea animándose, no apareciendo de golpe.

**Error que cometí**: Al principio usaba `duration: 1.5` pensando que más lento = más elegante. No. Las animaciones largas se sienten lentas y molestas. **0.3 - 0.6 segundos** es el sweet spot.

---

## 2. Stagger List (items que aparecen en cascada)

Cuando tienes una lista de cards, features, o cualquier grupo de elementos, hacer que aparezcan uno detrás de otro queda genial.

```tsx
'use client';
import { motion } from 'framer-motion';

const containerVariants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.1,
      delayChildren: 0.2,
    },
  },
};

const itemVariants = {
  hidden: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.4, ease: 'easeOut' },
  },
};

interface StaggerListProps {
  children: React.ReactNode;
  className?: string;
}

export function StaggerList({ children, className }: StaggerListProps) {
  return (
    <motion.div
      variants={containerVariants}
      initial="hidden"
      whileInView="visible"
      viewport={{ once: true, margin: '-50px' }}
      className={className}
    >
      {children}
    </motion.div>
  );
}

export function StaggerItem({ children, className }: StaggerListProps) {
  return (
    <motion.div variants={itemVariants} className={className}>
      {children}
    </motion.div>
  );
}
```

**Uso:**

```tsx
<StaggerList className="grid grid-cols-1 md:grid-cols-3 gap-6">
  <StaggerItem><FeatureCard title="Auth" /></StaggerItem>
  <StaggerItem><FeatureCard title="Pagos" /></StaggerItem>
  <StaggerItem><FeatureCard title="Dashboard" /></StaggerItem>
</StaggerList>
```

**El truco**: `staggerChildren: 0.1` significa que cada hijo espera 0.1s más que el anterior. Con 3 items el efecto es sutil pero elegante. Con 10+ items, sube a `0.05` para que no tarde siglos.

---

## 3. Modal con backdrop animado

Los modales sin animación se sienten rotos. Con Framer Motion + `AnimatePresence`, entran y salen suavemente.

```tsx
'use client';
import { motion, AnimatePresence } from 'framer-motion';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}

export function Modal({ isOpen, onClose, children }: ModalProps) {
  return (
    <AnimatePresence>
      {isOpen && (
        <>
          {/* Backdrop */}
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }}
            className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
            onClick={onClose}
          />
          {/* Modal */}
          <motion.div
            initial={{ opacity: 0, scale: 0.95, y: 10 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.95, y: 10 }}
            transition={{ duration: 0.2, ease: 'easeOut' }}
            className="fixed inset-0 z-50 flex items-center justify-center p-4"
          >
            <div
              className="w-full max-w-lg rounded-2xl bg-white dark:bg-gray-900 p-6 shadow-2xl"
              onClick={(e) => e.stopPropagation()}
            >
              {children}
            </div>
          </motion.div>
        </>
      )}
    </AnimatePresence>
  );
}
```

**Uso:**

```tsx
const [isOpen, setIsOpen] = useState(false);

<button onClick={() => setIsOpen(true)}>Abrir modal</button>

<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
  <h2>Confirmar acción</h2>
  <p>¿Estás seguro?</p>
  <button onClick={() => setIsOpen(false)}>Cerrar</button>
</Modal>
```

**Detalle clave**: `AnimatePresence` es lo que permite la animación de salida (`exit`). Sin él, React elimina el componente del DOM inmediatamente y nunca ves la animación de cierre. Me costó un bug de 2 horas descubrir esto la primera vez.

**El backdrop con `backdrop-blur-sm`** es ese efecto de desenfoque que usan las apps de Apple. Se ve premium y cuesta 0 esfuerzo extra.

---

## 4. Skeleton Loader animado

Los skeleton loaders (esos rectángulos grises pulsantes mientras carga el contenido) son estándar en 2026. Pero los de CSS puro se ven planos. Con Framer Motion quedan más suaves.

```tsx
'use client';
import { motion } from 'framer-motion';

export function Skeleton({ className }: { className?: string }) {
  return (
    <motion.div
      className={`rounded-lg bg-gray-200 dark:bg-gray-800 ${className}`}
      animate={{ opacity: [0.5, 1, 0.5] }}
      transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
    />
  );
}

// Ejemplo: Skeleton de una card
export function CardSkeleton() {
  return (
    <div className="rounded-2xl border border-gray-200 dark:border-gray-800 p-6 space-y-4">
      <Skeleton className="h-40 w-full" />
      <Skeleton className="h-5 w-3/4" />
      <Skeleton className="h-4 w-full" />
      <Skeleton className="h-4 w-5/6" />
      <div className="flex gap-2 pt-2">
        <Skeleton className="h-6 w-16 rounded-full" />
        <Skeleton className="h-6 w-20 rounded-full" />
      </div>
    </div>
  );
}
```

**Uso:**

```tsx
function PostList() {
  const { data: posts, isLoading } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });

  if (isLoading) {
    return (
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <CardSkeleton />
        <CardSkeleton />
        <CardSkeleton />
      </div>
    );
  }

  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      {posts.map(post => <PostCard key={post.id} post={post} />)}
    </div>
  );
}
```

**Tip**: El skeleton debe tener la **misma estructura visual** que el contenido real. Si tu card tiene imagen arriba + título + 2 líneas de texto + tags, tu skeleton debe tener las mismas formas. Si no, hay un "salto" feo cuando se carga el contenido.

---

## 5. Transiciones entre páginas (Next.js App Router)

Esta es la más compleja pero la que más impacto visual tiene. Cuando navegas entre páginas, en vez de un corte brusco, el contenido sale suavemente y el nuevo entra.

```tsx
// app/template.tsx (NO layout.tsx — template se re-renderiza en cada navegación)
'use client';
import { motion } from 'framer-motion';

export default function Template({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 8 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.3, ease: 'easeOut' }}
    >
      {children}
    </motion.div>
  );
}
```

**Importante**: Usa `template.tsx`, no `layout.tsx`. Los layouts en Next.js no se re-renderizan entre navegaciones — persisten. El `template.tsx` sí se re-renderiza, que es lo que necesitas para que la animación se dispare.

**Versión con exit animation** (más compleja, requiere `AnimatePresence` en el layout):

```tsx
// app/layout.tsx
'use client'; // Solo si necesitas AnimatePresence
import { AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();

  return (
    <html lang="es">
      <body>
        <Navbar />
        <AnimatePresence mode="wait">
          <motion.main
            key={pathname}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }}
          >
            {children}
          </motion.main>
        </AnimatePresence>
      </body>
    </html>
  );
}
```

**Ojo**: Esta versión convierte el layout en Client Component, lo que deshabilita Server Components para todo el tree. Para la mayoría de landing pages no importa. Para apps con mucho data fetching en el servidor, usa la versión `template.tsx` que es más simple y no tiene este trade-off.

---

## Bonus: Hover scale para cards/botones

Este no cuenta como "animación" pero lo pongo en todo:

```tsx
<motion.div
  whileHover={{ scale: 1.02 }}
  whileTap={{ scale: 0.98 }}
  transition={{ type: 'spring', stiffness: 300, damping: 20 }}
  className="cursor-pointer"
>
  <Card />
</motion.div>
```

`whileHover: { scale: 1.02 }` es sutil pero se siente responsive. El `whileTap: { scale: 0.98 }` da feedback táctil al hacer clic. La transición con `spring` se siente más natural que `easeOut`.

---

## Mi checklist antes de animar

1. **¿La animación tiene propósito?** Si es decorativa y no ayuda al usuario a entender qué pasa → la quito
2. **¿Dura menos de 0.5s?** Las animaciones de UI no deberían durar más (las de scroll pueden llegar a 0.6)
3. **¿Respeta `prefers-reduced-motion`?** Siempre:

```tsx
// En tu CSS global o Tailwind config
@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}
```

4. **¿La animación se ejecuta solo una vez?** `viewport: { once: true }` para scroll animations
5. **¿El contenido es usable sin la animación?** Si JavaScript falla, el contenido debe ser visible

## Artículos relacionados

- [10 Ejemplos de Landing Pages con Tailwind CSS](/blog/landing-pages-tailwind-ejemplos-codigo-2026/) — estos diseños con estas animaciones
- [Cursor vs Copilot vs Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/) — las herramientas que uso para codear más rápido
- [Cheat sheet de CSS Grid y Flexbox](/blog/cheat-sheet-css-grid-flexbox-2026/) — los layouts que animo con Framer Motion]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cómo Implementar Autenticación con Auth.js (NextAuth) Sin Volverte Loco]]></title>
      <link>https://francobosg.netlify.app/blog/auth-js-nextauth-implementacion-real-2026/</link>
      <description><![CDATA[Guía paso a paso para implementar auth real con Auth.js v5 en Next.js App Router. Google OAuth, credenciales, protección de rutas, sesiones y los errores que nadie te cuenta.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/auth-js-nextauth-implementacion-real-2026/</guid>
      <category>Tutorial</category>
      <category>Full-Stack</category>
      <category>Código</category>
      <content:encoded><![CDATA[La autenticación es esa feature que parece fácil hasta que empiezas a implementarla. "Son solo un login y un register", pensé la primera vez. 3 días después estaba debuggeando callbacks de OAuth, tokens expirados, y sesiones que desaparecían en producción.

Auth.js (antes NextAuth) es la mejor opción gratuita para auth en Next.js, pero la documentación asume que ya sabes lo que estás haciendo. Esta guía es la que me habría gustado tener.

## Setup inicial (5 minutos)

### 1. Instalar

```bash
npm install next-auth@beta
```

En 2026, la v5 sigue en `@beta` pero es estable para producción. No te asustes por el tag.

### 2. Generar el secret

```bash
npx auth secret
```

Esto genera un `AUTH_SECRET` en tu `.env.local`. Este secret firma los JWT y las cookies de sesión. **Sin él, nada funciona.**

### 3. Crear la configuración central

```typescript
// src/lib/auth.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(db),
  session: { strategy: 'jwt' }, // JWT en vez de sesiones en BD
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    Credentials({
      credentials: {
        email: { type: 'email' },
        password: { type: 'password' },
      },
      authorize: async (credentials) => {
        const user = await db.user.findUnique({
          where: { email: credentials.email as string },
        });

        if (!user || !user.hashedPassword) return null;

        const isValid = await bcrypt.compare(
          credentials.password as string,
          user.hashedPassword
        );

        if (!isValid) return null;

        return { id: user.id, email: user.email, name: user.name };
      },
    }),
  ],
  callbacks: {
    jwt({ token, user }) {
      if (user) {
        token.id = user.id;
      }
      return token;
    },
    session({ session, token }) {
      session.user.id = token.id as string;
      return session;
    },
  },
  pages: {
    signIn: '/login', // Tu página de login custom
  },
});
```

### 4. Crear la API route

```typescript
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;
```

### 5. El middleware para proteger rutas

```typescript
// src/middleware.ts
import { auth } from '@/lib/auth';

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard');
  const isOnAuth = req.nextUrl.pathname.startsWith('/login') ||
                   req.nextUrl.pathname.startsWith('/register');

  if (isOnDashboard && !isLoggedIn) {
    return Response.redirect(new URL('/login', req.nextUrl));
  }

  if (isOnAuth && isLoggedIn) {
    return Response.redirect(new URL('/dashboard', req.nextUrl));
  }
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
```

## Los errores que vas a tener (y cómo solucionarlos)

### Error 1: "OAUTH_CALLBACK_ERROR" — el más común

```
[auth][error] OAuthCallbackError: ...
```

**Causa**: El redirect URI en la consola de Google no coincide con tu URL real.

**Solución**: Ve a Google Cloud Console → Credentials → OAuth Client → Authorized redirect URIs y añade:

```
http://localhost:3000/api/auth/callback/google     # Para dev
https://tudominio.com/api/auth/callback/google     # Para producción
```

El formato siempre es: `{tu-url}/api/auth/callback/{provider}`. Me pasé 2 horas la primera vez porque puse `/auth/callback/google` sin el `/api`.

### Error 2: La sesión desaparece al refrescar

**Causa**: Si usas `strategy: 'database'` (el default con un adapter), Auth.js guarda las sesiones en la BD. Pero si el adapter no está bien configurado, la sesión no se persiste.

**Solución**: Usa `strategy: 'jwt'` (como en mi config arriba). Los JWT van en una cookie httpOnly y no necesitan BD para las sesiones. Es más simple y funciona siempre.

```typescript
session: { strategy: 'jwt' },
```

### Error 3: El user.id no está en la sesión

```typescript
const session = await auth();
console.log(session.user.id); // undefined 😱
```

**Causa**: Auth.js no incluye el `id` del usuario en la sesión por defecto (por seguridad).

**Solución**: Añadir los callbacks `jwt` y `session` como en mi config arriba. Y extender los tipos:

```typescript
// src/types/next-auth.d.ts
import { DefaultSession } from 'next-auth';

declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
    } & DefaultSession['user'];
  }
}
```

### Error 4: Credentials provider + Adapter = sesiones rotas

**Causa**: Auth.js no persiste sesiones en BD para el Credentials provider (es una decisión de diseño — los passwords no se consideran "seguros" para sesiones de larga duración).

**Solución**: Con Credentials, **siempre** usa `strategy: 'jwt'`. Si usas solo OAuth (Google, GitHub), puedes usar `strategy: 'database'`.

## Login con Google — Componente completo

```tsx
// features/auth/components/LoginForm.tsx
'use client';
import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleCredentialsLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    const result = await signIn('credentials', {
      email,
      password,
      redirect: false,
    });

    if (result?.error) {
      setError('Email o contraseña incorrectos');
      setLoading(false);
      return;
    }

    router.push('/dashboard');
  };

  return (
    <div className="mx-auto max-w-sm space-y-6">
      {/* Google OAuth */}
      <button
        onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
        className="flex w-full items-center justify-center gap-3 rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-50"
      >
        <svg className="h-5 w-5" viewBox="0 0 24 24">
          {/* Google icon SVG path */}
        </svg>
        Continuar con Google
      </button>

      <div className="relative">
        <div className="absolute inset-0 flex items-center">
          <div className="w-full border-t border-gray-200" />
        </div>
        <div className="relative flex justify-center text-sm">
          <span className="bg-white px-4 text-gray-500">o</span>
        </div>
      </div>

      {/* Credentials */}
      <form onSubmit={handleCredentialsLogin} className="space-y-4">
        {error && (
          <p className="rounded-lg bg-red-50 p-3 text-sm text-red-600">
            {error}
          </p>
        )}
        <input
          type="email"
          placeholder="tu@email.com"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm"
        />
        <input
          type="password"
          placeholder="Contraseña"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
          className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm"
        />
        <button
          type="submit"
          disabled={loading}
          className="w-full rounded-lg bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50"
        >
          {loading ? 'Entrando...' : 'Iniciar sesión'}
        </button>
      </form>
    </div>
  );
}
```

**Detalle importante**: `redirect: false` en el `signIn` de credentials permite manejar el error en el cliente en vez de redirigir a una página de error genérica. Para OAuth (Google), usamos `callbackUrl` porque el flujo OAuth necesita la redirección.

## Obtener la sesión en Server Components

```tsx
// En cualquier Server Component
import { auth } from '@/lib/auth';

export default async function DashboardPage() {
  const session = await auth();

  if (!session) {
    redirect('/login'); // Por si acaso (el middleware ya lo cubre)
  }

  return (
    <div>
      <h1>Hola, {session.user.name}</h1>
      <p>Tu ID: {session.user.id}</p>
    </div>
  );
}
```

## Obtener la sesión en Client Components

```tsx
'use client';
import { useSession } from 'next-auth/react';

export function UserMenu() {
  const { data: session, status } = useSession();

  if (status === 'loading') return <Skeleton className="h-8 w-8 rounded-full" />;
  if (!session) return null;

  return (
    <div>
      <img src={session.user.image} alt="" className="h-8 w-8 rounded-full" />
      <span>{session.user.name}</span>
    </div>
  );
}
```

**No olvides** el `SessionProvider` en tu layout:

```tsx
// app/layout.tsx
import { SessionProvider } from 'next-auth/react';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <SessionProvider>{children}</SessionProvider>
      </body>
    </html>
  );
}
```

## Proteger API Routes

```typescript
// app/api/tasks/route.ts
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';

export async function GET() {
  const session = await auth();

  if (!session) {
    return NextResponse.json({ error: 'No autorizado' }, { status: 401 });
  }

  const tasks = await db.task.findMany({
    where: { userId: session.user.id },
  });

  return NextResponse.json(tasks);
}
```

## El esquema de Prisma que uso

```prisma
// prisma/schema.prisma
model User {
  id             String    @id @default(cuid())
  name           String?
  email          String    @unique
  emailVerified  DateTime?
  image          String?
  hashedPassword String?   // null si se registró con OAuth

  accounts Account[]
  sessions Session[]
  tasks    Task[]

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?
  access_token      String?
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}
```

## Checklist de seguridad

Antes de ir a producción, verifico:

- [ ] `AUTH_SECRET` generado con `npx auth secret` (no un string inventado)
- [ ] Passwords hasheadas con `bcrypt` (nunca en texto plano)
- [ ] `redirect: false` en credentials para no filtrar info en la URL
- [ ] Cookies con `httpOnly` y `secure` (Auth.js lo hace por defecto en producción)
- [ ] CSRF protection activo (Auth.js lo incluye por defecto)
- [ ] Variables de entorno en `.env.local`, no hardcodeadas
- [ ] Redirect URIs en la consola de OAuth actualizadas para el dominio de producción
- [ ] Rate limiting en el endpoint de login (Auth.js NO lo incluye — implementar con `upstash/ratelimit`)

## Artículos relacionados

- [Estructura de carpetas en React/Next.js](/blog/estructura-carpetas-react-nextjs-2026/) — cómo organizo la feature de auth
- [Proteger tu API Node.js con JWT](/blog/proteger-api-nodejs-jwt-auth-guia-2026/) — auth desde cero sin Auth.js
- [Supabase vs Firebase en producción](/blog/supabase-vs-firebase-experiencia-produccion-2026/) — alternativas de auth as a service]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cómo Implementar el Banner de Cookies (RGPD) Correctamente en Tu Web — Paso a Paso]]></title>
      <link>https://francobosg.netlify.app/blog/banner-cookies-rgpd-implementacion-correcta-2026/</link>
      <description><![CDATA[Guía práctica para implementar un banner de consentimiento de cookies que cumpla con RGPD/LOPD. Sin plugins de pago, con código HTML/JS puro, y con checklist legal real.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/banner-cookies-rgpd-implementacion-correcta-2026/</guid>
      <category>Tutorial</category>
      <category>Código</category>
      <content:encoded><![CDATA[El 90% de los banners de cookies que veo en webs de desarrolladores están mal implementados. Y no hablo de diseño — hablo de cumplimiento legal. El error más común: cargar Google Analytics **antes** de que el usuario acepte las cookies.

Yo también lo hacía. Ponía el script de GA en el `<head>`, mostraba un banner bonito, y cuando el usuario hacía clic en "Aceptar" guardaba un flag en localStorage. El problema: GA ya estaba trackeando desde el primer milisegundo.

Esto es lo que hago ahora, y es lo que exige la RGPD.

## El principio fundamental

```
1. Cargar la web SIN cookies no esenciales
2. Mostrar el banner
3. El usuario elige: Aceptar / Rechazar / Configurar
4. SOLO si acepta → cargar Analytics, AdSense, etc.
5. Guardar la preferencia para no preguntar otra vez
```

**No al revés.** Primero el consentimiento, después las cookies.

## Implementación completa (HTML + JS puro)

### El banner HTML

```html
<!-- Poner al final del <body>, antes de </body> -->
<div id="cookie-banner" class="fixed bottom-0 left-0 right-0 z-50 hidden">
  <div class="mx-auto max-w-5xl px-4 py-4">
    <div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-gray-700 dark:bg-gray-900">
      <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
        <div class="flex-1">
          <p class="text-sm text-gray-700 dark:text-gray-300">
            Usamos cookies propias y de terceros para analíticas y personalización.
            Puedes aceptar, rechazar o
            <a href="/cookies" class="underline hover:text-blue-600">configurar tus preferencias</a>.
          </p>
        </div>
        <div class="flex flex-shrink-0 gap-3">
          <button
            id="cookie-reject"
            class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
          >
            Rechazar
          </button>
          <button
            id="cookie-accept"
            class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500"
          >
            Aceptar
          </button>
        </div>
      </div>
    </div>
  </div>
</div>
```

**Detalles de diseño que importan legalmente:**

- Los botones de "Aceptar" y "Rechazar" tienen el **mismo tamaño visual**. Si haces el de aceptar gigante y el de rechazar pequeño/gris, es un *dark pattern* y la AEPD puede multarte.
- El texto es claro y directo. Nada de "utilizamos cookies para mejorar tu experiencia" (demasiado vago).
- Hay enlace a la política de cookies.

### El JavaScript

```javascript
// cookie-consent.js
(function() {
  'use strict';

  const CONSENT_KEY = 'cookie-consent';
  const banner = document.getElementById('cookie-banner');
  const acceptBtn = document.getElementById('cookie-accept');
  const rejectBtn = document.getElementById('cookie-reject');

  // Si ya hay una decisión guardada, no mostrar el banner
  const savedConsent = localStorage.getItem(CONSENT_KEY);

  if (savedConsent === 'accepted') {
    loadAnalytics();
    return;
  }

  if (savedConsent === 'rejected') {
    return; // No cargar nada, no mostrar banner
  }

  // Primera visita: mostrar el banner
  if (banner) {
    banner.classList.remove('hidden');
  }

  if (acceptBtn) {
    acceptBtn.addEventListener('click', function() {
      localStorage.setItem(CONSENT_KEY, 'accepted');
      if (banner) banner.classList.add('hidden');
      loadAnalytics();
    });
  }

  if (rejectBtn) {
    rejectBtn.addEventListener('click', function() {
      localStorage.setItem(CONSENT_KEY, 'rejected');
      if (banner) banner.classList.add('hidden');
    });
  }

  function loadAnalytics() {
    // Google Analytics 4
    if (!document.querySelector('script[src*="googletagmanager"]')) {
      var gaScript = document.createElement('script');
      gaScript.async = true;
      gaScript.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX';
      document.head.appendChild(gaScript);

      gaScript.onload = function() {
        window.dataLayer = window.dataLayer || [];
        function gtag() { window.dataLayer.push(arguments); }
        window.gtag = gtag;
        gtag('js', new Date());
        gtag('config', 'G-XXXXXXXXXX', {
          anonymize_ip: true  // Anonimizar IP — recomendado por RGPD
        });
      };
    }

    // Aquí puedes cargar otros scripts: AdSense, Facebook Pixel, Hotjar, etc.
  }
})();
```

**Lo importante:**
- `loadAnalytics()` solo se ejecuta si el usuario acepta o si ya aceptó antes
- `anonymize_ip: true` anonimiza la IP del usuario (recomendado por RGPD aunque no es obligatorio desde GA4)
- Si el usuario rechaza, no se carga nada. Ni ahora ni en visitas futuras
- El script se ejecuta en un IIFE para no contaminar el scope global

### Cómo cargarlo en tu web

```html
<!-- En el <head>, ANTES de cualquier script de tracking -->
<script>
  // NO pongas Google Analytics aquí. Solo el cookie-consent.
</script>

<!-- Al final del <body> -->
<script src="/cookie-consent.js"></script>
```

En mi caso con Astro, lo cargo así:

```astro
---
// BlogLayout.astro
---
<html>
  <head>
    <!-- NO hay GA aquí. Solo se carga si hay consentimiento. -->
  </head>
  <body>
    <slot />

    <!-- Banner de cookies -->
    <div id="cookie-banner" class="...">
      <!-- ... -->
    </div>

    <script is:inline>
      // El script de consentimiento inline
      // (misma lógica que arriba)
    </script>
  </body>
</html>
```

## La política de cookies (página obligatoria)

Necesitas una página `/cookies` (o `/cookies.html`) con:

```markdown
## Política de Cookies

### ¿Qué son las cookies?
Las cookies son pequeños archivos de texto que se almacenan en tu navegador...

### Cookies que utilizamos

| Cookie | Tipo | Proveedor | Propósito | Duración |
|--------|------|-----------|-----------|----------|
| `cookie-consent` | Técnica (propia) | Este sitio | Guardar tu preferencia de cookies | Permanente |
| `_ga` | Analítica (tercero) | Google Analytics | Distinguir usuarios únicos | 2 años |
| `_ga_XXXXXXX` | Analítica (tercero) | Google Analytics | Persistir estado de sesión | 2 años |
| `color-theme` | Técnica (propia) | Este sitio | Guardar preferencia de tema oscuro/claro | Permanente |

### Cómo gestionar las cookies
Puedes cambiar tu preferencia en cualquier momento haciendo clic en [Gestionar cookies].
También puedes configurar tu navegador para bloquear cookies...

### Base legal
El tratamiento se basa en tu consentimiento (art. 6.1.a RGPD).
Las cookies técnicas se basan en interés legítimo (art. 6.1.f RGPD).

### Contacto
Si tienes dudas: [tu email]
```

## Checklist de cumplimiento

### Obligatorio

- [ ] El banner aparece en la primera visita
- [ ] NO se cargan cookies de tracking antes del consentimiento
- [ ] Hay opción de **rechazar** con la misma visibilidad que aceptar
- [ ] La preferencia se guarda y no se pregunta en cada página
- [ ] Hay un enlace a la política de cookies desde el banner
- [ ] Existe una página de política de cookies accesible
- [ ] La política lista todas las cookies con su proveedor, propósito y duración

### Recomendado

- [ ] Opción de "configurar" cookies por categoría (analíticas, marketing, etc.)
- [ ] Botón para cambiar la preferencia después (en el footer, por ejemplo)
- [ ] `anonymize_ip: true` en Google Analytics
- [ ] Las cookies se eliminan si el usuario cambia de "aceptar" a "rechazar"

### Prohibido (dark patterns que multa la AEPD)

- [ ] Botón de "Aceptar" mucho más grande/visible que "Rechazar"
- [ ] Solo opción de "Aceptar" y "Más información" (sin rechazar)
- [ ] Cookie wall: "Acepta las cookies o no puedes ver la web"
- [ ] Pre-marcar casillas de cookies no esenciales
- [ ] Requerir más clics para rechazar que para aceptar

## Versión avanzada: con categorías

Si quieres ir más allá y permitir que el usuario elija por categorías:

```javascript
const CATEGORIES = {
  essential: true,   // Siempre activas, no se pueden desactivar
  analytics: false,  // Google Analytics, Hotjar
  marketing: false,  // AdSense, Facebook Pixel
};

function getConsent() {
  const saved = localStorage.getItem('cookie-categories');
  return saved ? JSON.parse(saved) : null;
}

function setConsent(categories) {
  localStorage.setItem('cookie-consent', 'configured');
  localStorage.setItem('cookie-categories', JSON.stringify(categories));

  if (categories.analytics) loadAnalytics();
  if (categories.marketing) loadMarketing();
}

// Aceptar todo
function acceptAll() {
  setConsent({ essential: true, analytics: true, marketing: true });
}

// Rechazar todo (solo esenciales)
function rejectAll() {
  setConsent({ essential: true, analytics: false, marketing: false });
}
```

## Mi implementación real

En mi portfolio uso una versión simple (aceptar/rechazar) porque solo tengo Google Analytics. No necesito categorías si solo tengo un tipo de cookie no esencial.

El código está en mi [portfolio con Vite y Tailwind](/blog/como-hice-mi-portfolio-vite-tailwind/), y el consentimiento se comparte entre el portfolio y el blog porque ambos están en el mismo dominio (`francobosg.netlify.app`). El `localStorage` es compartido, así que si aceptas en el portfolio, el blog ya lo sabe.

## Herramientas que NO recomiendo

- **CookieBot, OneTrust, Iubenda**: Son soluciones SaaS de pago (50-200€/mes). Para una web personal o un blog, es matar moscas a cañonazos. El script que te puse arriba hace exactamente lo mismo.
- **Plugins de WordPress**: Si no estás en WordPress, no te sirven. Y si estás en WordPress, muchos cargan 100KB+ de JavaScript para algo que se hace con 30 líneas.
- **Google Consent Mode**: Es un layer adicional que Google recomienda para "medir sin cookies". En la práctica, sigue necesitando consentimiento para tracking completo. Úsalo solo si tienes ads.

## Artículos relacionados

- [Configuración definitiva de CORS en Node.js](/blog/cors-nodejs-express-configuracion-produccion-2026/) — otra pieza de seguridad web
- [Cómo hice mi portfolio con Vite y Tailwind](/blog/como-hice-mi-portfolio-vite-tailwind/) — donde implementé este banner
- [Deploy gratis con GitHub Actions + Netlify](/blog/deploy-gratis-github-actions-netlify-vercel-2026/) — donde corre esta web]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cheat Sheet Visual de CSS Grid y Flexbox — La Guía que Siempre Busco]]></title>
      <link>https://francobosg.netlify.app/blog/cheat-sheet-css-grid-flexbox-2026/</link>
      <description><![CDATA[Referencia visual rápida de CSS Grid y Flexbox con ejemplos de código copiables. Todas las propiedades que necesitas para layouts modernos, en un solo sitio.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/cheat-sheet-css-grid-flexbox-2026/</guid>
      <category>Tutorial</category>
      <category>Código</category>
      <content:encoded><![CDATA[Cada vez que necesito hacer un layout con Grid o Flexbox, acabo buscando en Google lo mismo: "css grid center item", "flexbox space between", "grid auto-fill vs auto-fit"...

Así que me hice esta referencia visual para dejar de buscar las mismas cosas una y otra vez. La comparto porque seguro que tú también lo buscas.

## Flexbox — Referencia completa

### El contenedor (parent)

```css
.flex-container {
  display: flex;

  /* Dirección del eje principal */
  flex-direction: row;            /* → (default) */
  flex-direction: row-reverse;    /* ← */
  flex-direction: column;         /* ↓ */
  flex-direction: column-reverse; /* ↑ */

  /* ¿Se ajustan a varias líneas? */
  flex-wrap: nowrap;   /* Todo en una línea (default) */
  flex-wrap: wrap;     /* Saltan de línea si no caben */

  /* Alineación en el eje principal (horizontal si row) */
  justify-content: flex-start;    /* |■ ■ ■         | */
  justify-content: flex-end;      /* |         ■ ■ ■| */
  justify-content: center;        /* |    ■ ■ ■     | */
  justify-content: space-between; /* |■    ■    ■   | */
  justify-content: space-around;  /* | ■   ■   ■  | */
  justify-content: space-evenly;  /* |  ■  ■  ■  | */

  /* Alineación en el eje cruzado (vertical si row) */
  align-items: stretch;    /* Los items se estiran (default) */
  align-items: flex-start; /* Arriba */
  align-items: flex-end;   /* Abajo */
  align-items: center;     /* Centro vertical */
  align-items: baseline;   /* Alinea por la línea base del texto */

  /* Espacio entre elementos (moderno, funciona en Flex y Grid) */
  gap: 1rem;
  row-gap: 1rem;
  column-gap: 2rem;
}
```

### Los items (children)

```css
.flex-item {
  /* Cuánto crece el item si hay espacio extra */
  flex-grow: 0;   /* No crece (default) */
  flex-grow: 1;   /* Ocupa todo el espacio disponible */

  /* Cuánto se encoge si no hay espacio */
  flex-shrink: 1; /* Se encoge proporcionalmente (default) */
  flex-shrink: 0; /* NO se encoge — mantiene su tamaño */

  /* Tamaño base antes de crecer/encoger */
  flex-basis: auto;  /* Usa el width del elemento (default) */
  flex-basis: 200px; /* Empieza en 200px */
  flex-basis: 0;     /* Ignora el contenido, distribuye solo por grow */

  /* Shorthand (el que siempre uso) */
  flex: 1;           /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
  flex: 0 0 200px;   /* No crece, no encoge, 200px fijo */

  /* Alineación individual (override del align-items del padre) */
  align-self: center;
  align-self: flex-start;
  align-self: flex-end;
  align-self: stretch;

  /* Orden visual */
  order: 0;  /* Default */
  order: -1; /* Se mueve al principio */
  order: 1;  /* Se mueve al final */
}
```

### Los 5 patrones Flexbox que más uso

#### 1. Centrar algo (el clásico)

```css
.center-everything {
  display: flex;
  justify-content: center;
  align-items: center;
}
/* Tailwind: flex items-center justify-center */
```

Me pasé 3 años usando `margin: 0 auto` + `line-height` hacks. Ojalá alguien me hubiera dicho esto antes.

#### 2. Navbar con logo a la izquierda y links a la derecha

```css
.navbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
/* Tailwind: flex items-center justify-between */
```

#### 3. Cards en fila que saltan de línea

```css
.card-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}
.card {
  flex: 1 1 300px; /* Mínimo 300px, crece para rellenar */
}
/* Tailwind: flex flex-wrap gap-4; child: flex-1 basis-[300px] */
```

#### 4. Sidebar fija + contenido flexible

```css
.layout {
  display: flex;
}
.sidebar {
  flex: 0 0 280px; /* Fija a 280px */
}
.content {
  flex: 1; /* Ocupa el resto */
}
/* Tailwind: flex; sidebar: w-[280px] shrink-0; content: flex-1 */
```

#### 5. Footer que siempre está abajo (sticky footer)

```css
body {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}
main {
  flex: 1; /* Empuja el footer abajo */
}
/* Tailwind: flex flex-col min-h-screen; main: flex-1 */
```

---

## CSS Grid — Referencia completa

### El contenedor (parent)

```css
.grid-container {
  display: grid;

  /* Definir columnas */
  grid-template-columns: 200px 1fr 200px;      /* Sidebar | Content | Sidebar */
  grid-template-columns: repeat(3, 1fr);        /* 3 columnas iguales */
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); /* Responsive sin media queries */
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));  /* Idem, pero estira si sobra espacio */

  /* Definir filas */
  grid-template-rows: auto 1fr auto;            /* Header | Content | Footer */
  grid-auto-rows: minmax(100px, auto);          /* Filas implícitas: mínimo 100px */

  /* Espacio entre celdas */
  gap: 1rem;
  row-gap: 1.5rem;
  column-gap: 1rem;

  /* Alinear TODO el contenido del grid */
  justify-items: stretch;  /* Los items se estiran horizontalmente (default) */
  justify-items: center;   /* Centrado horizontal */
  align-items: stretch;    /* Los items se estiran verticalmente (default) */
  align-items: center;     /* Centrado vertical */

  /* Alinear el grid dentro de su contenedor */
  justify-content: center;        /* Grid centrado horizontalmente */
  align-content: center;          /* Grid centrado verticalmente */
  place-content: center;          /* Shorthand: ambos a la vez */
}
```

### Los items (children)

```css
.grid-item {
  /* Posicionamiento por línea (empieza en 1, no en 0) */
  grid-column: 1 / 3;      /* Ocupa de la línea 1 a la 3 (2 columnas) */
  grid-column: 1 / -1;     /* De la primera a la última línea (full width) */
  grid-column: span 2;     /* Ocupa 2 columnas desde donde esté */
  grid-row: 1 / 3;         /* Ocupa 2 filas */

  /* Alineación individual */
  justify-self: center;    /* Centrado horizontal en su celda */
  align-self: center;      /* Centrado vertical en su celda */
  place-self: center;      /* Shorthand: ambos */
}
```

### Los 5 patrones Grid que más uso

#### 1. Grid responsive sin media queries

```css
.responsive-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 1.5rem;
}
/* Tailwind: grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-6 */
/* O más simple: grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 */
```

Este es el patrón que uso para el 90% de grids. `auto-fit` + `minmax()` = las columnas se ajustan solas. Si caben 3, hay 3. Si solo caben 2, hay 2. Sin una sola media query.

**auto-fill vs auto-fit**: `auto-fill` crea columnas vacías si sobra espacio. `auto-fit` estira las existentes. Para cards, siempre `auto-fit`.

#### 2. Layout de página completa (Holy Grail)

```css
.page-layout {
  display: grid;
  grid-template-columns: 250px 1fr;
  grid-template-rows: auto 1fr auto;
  grid-template-areas:
    "header header"
    "sidebar main"
    "footer footer";
  min-height: 100vh;
}
.header  { grid-area: header; }
.sidebar { grid-area: sidebar; }
.main    { grid-area: main; }
.footer  { grid-area: footer; }
```

`grid-template-areas` es pura magia. Puedes leer el layout como si fuera un dibujo ASCII.

#### 3. Galería tipo Pinterest (Masonry)

```css
.masonry {
  columns: 3;
  column-gap: 1rem;
}
.masonry-item {
  break-inside: avoid;
  margin-bottom: 1rem;
}
/* Nota: esto es CSS Columns, no Grid. Pero lo incluyo porque
   siempre lo busco como "css grid masonry". Grid masonry real
   con grid-template-rows: masonry ya funciona en Firefox. */
```

#### 4. Item que ocupa todo el ancho

```css
.full-width-item {
  grid-column: 1 / -1; /* De la primera a la última línea */
}
/* Tailwind: col-span-full */
```

#### 5. Centrar un item en el grid

```css
.centered-grid {
  display: grid;
  place-items: center;
  min-height: 100vh;
}
/* Tailwind: grid place-items-center min-h-screen */
```

Sí, `place-items: center` en Grid es otra forma de centrar. Más limpio que Flex para centrar un solo elemento.

---

## Cuándo uso cada uno — Mi regla personal

| Situación | Elijo | Por qué |
|-----------|-------|---------|
| Navbar | Flexbox | Una sola fila, `space-between` |
| Grid de cards | Grid | Necesito columnas responsive |
| Formulario | Grid | 2 columnas de inputs alineados |
| Centrar un div | Grid | `place-items: center` es una línea |
| Sidebar + content | Grid | 2 columnas con anchos distintos |
| Botones en fila | Flexbox | Una fila con gap |
| Dashboard completo | Grid | Filas Y columnas con areas |
| Lista vertical | Flexbox | `flex-direction: column` con gap |

La regla que me sirve: **si solo necesito una dirección → Flexbox. Si necesito filas Y columnas → Grid.** Cuando dudo, empiezo con Grid porque es más potente y después simplifico a Flexbox si resulta que no necesitaba tanto.

---

## Propiedades nuevas de CSS que uso (2025-2026)

### Container Queries

```css
.card-container {
  container-type: inline-size;
}

@container (min-width: 400px) {
  .card {
    flex-direction: row; /* Horizontal si el contenedor es ancho */
  }
}
/* Tailwind: @container, @md:flex-row */
```

Los container queries responden al tamaño del contenedor padre, no del viewport. Perfectos para componentes reutilizables.

### Subgrid

```css
.parent {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
}
.child {
  grid-column: span 3;
  display: grid;
  grid-template-columns: subgrid; /* Hereda las columnas del padre */
}
```

`subgrid` permite que un hijo herede las líneas del grid del padre. Ideal para alinear contenido dentro de cards en un grid.

---

## Tip final

Cuando me pierdo con un layout, abro las DevTools del navegador (F12), selecciono el elemento, y activo el **Grid/Flex overlay** (en Chrome: Layout panel → Grid overlays). Te dibuja las líneas, las areas, y los gaps en pantalla. Es la mejor forma de debuggear layouts.

## Artículos relacionados

- [10 Landing Pages con Tailwind CSS (código completo)](/blog/landing-pages-tailwind-ejemplos-codigo-2026/) — estos layouts aplicados a diseños reales
- [Cómo hice mi portfolio con Vite y Tailwind](/blog/como-hice-mi-portfolio-vite-tailwind/) — layout real con Flexbox y Grid]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Configuración Definitiva de CORS en Node.js/Express para Producción]]></title>
      <link>https://francobosg.netlify.app/blog/cors-nodejs-express-configuracion-produccion-2026/</link>
      <description><![CDATA[Cómo configurar CORS correctamente en Express y Node.js para producción. Los errores más comunes, por qué tu wildcard no funciona con cookies, y la config que uso en mis APIs reales.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/cors-nodejs-express-configuracion-produccion-2026/</guid>
      <category>Tutorial</category>
      <category>Full-Stack</category>
      <category>Código</category>
      <content:encoded><![CDATA[Si estás leyendo esto, probablemente estás viendo este error en la consola:

```
Access to fetch at 'http://localhost:4000/api/users' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
```

Lo he visto cientos de veces. Y la "solución" que encuentra todo el mundo en Stack Overflow es:

```javascript
// ❌ La "solución" de Stack Overflow que NO debes usar en producción
app.use(cors());
```

Esto permite que **cualquier web del mundo** haga peticiones a tu API. Para desarrollo local, vale. Para producción con autenticación, es un agujero de seguridad.

## Cómo funciona CORS (la versión corta)

1. Tu frontend en `https://miapp.com` hace `fetch('https://api.miapp.com/users')`
2. El navegador ve que son dominios diferentes → activa CORS
3. El navegador envía una petición **preflight** (OPTIONS) preguntando: "¿puedo hacer esta petición?"
4. El servidor responde con headers diciendo: "sí, `miapp.com` puede hacer GET y POST"
5. Solo entonces el navegador permite la petición real

Si el servidor no responde con los headers correctos → el navegador bloquea la respuesta. Tu API procesó la petición, pero el navegador no te deja ver el resultado.

## La configuración que uso en producción

```javascript
// src/middleware/cors.js
import cors from 'cors';

const allowedOrigins = [
  'https://miapp.com',
  'https://www.miapp.com',
  'https://admin.miapp.com',
];

// En desarrollo, añadir localhost
if (process.env.NODE_ENV !== 'production') {
  allowedOrigins.push(
    'http://localhost:3000',
    'http://localhost:5173', // Vite
    'http://localhost:4321', // Astro
  );
}

export const corsMiddleware = cors({
  origin: function (origin, callback) {
    // Permitir peticiones sin origin (Postman, curl, server-to-server)
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    }
  },
  credentials: true,                    // Permite enviar cookies
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['X-Total-Count'],     // Headers custom que el frontend puede leer
  maxAge: 86400,                         // Cache preflight 24h (reduce peticiones OPTIONS)
});
```

```javascript
// src/app.js
import express from 'express';
import { corsMiddleware } from './middleware/cors.js';

const app = express();

// CORS debe ir ANTES de todas las rutas
app.use(corsMiddleware);

app.use(express.json());

// Tus rutas aquí
app.use('/api/users', usersRouter);
app.use('/api/tasks', tasksRouter);
```

## Los 7 errores de CORS más comunes (y cómo solucionarlos)

### Error 1: `cors()` sin configurar (wildcard implícito)

```javascript
// ❌ Permite TODO. No usar en producción con auth.
app.use(cors());
```

Esto equivale a `Access-Control-Allow-Origin: *`. Si tu API usa cookies o tokens de sesión, el navegador rechazará las peticiones con `credentials: 'include'` cuando el origin es wildcard.

```javascript
// ✅ Especificar dominios
app.use(cors({ origin: 'https://miapp.com', credentials: true }));
```

### Error 2: Wildcard + credentials = error

```javascript
// ❌ Esto NO funciona
app.use(cors({ origin: '*', credentials: true }));
```

```
Access to fetch at '...' has been blocked by CORS policy:
The value of the 'Access-Control-Allow-Origin' header must not be '*'
when the request's credentials mode is 'include'.
```

El navegador prohíbe wildcard con credentials. Es una protección de seguridad: si permitieras cualquier origen con cookies, una web maliciosa podría hacer peticiones autenticadas a tu API.

```javascript
// ✅ Origin específico con credentials
app.use(cors({ 
  origin: 'https://miapp.com', 
  credentials: true 
}));
```

### Error 3: El frontend no envía `credentials: 'include'`

Tu servidor está bien configurado, pero el frontend no envía las cookies:

```javascript
// ❌ Fetch sin credentials — no envía cookies
fetch('https://api.miapp.com/users');

// ✅ Fetch con credentials — envía cookies del dominio
fetch('https://api.miapp.com/users', {
  credentials: 'include',
});

// ✅ Con axios
axios.get('https://api.miapp.com/users', {
  withCredentials: true,
});
```

### Error 4: Preflight bloqueado porque OPTIONS no responde

```javascript
// ❌ Si tienes rutas que bloquean OPTIONS
app.use('/api', authMiddleware, apiRoutes);
// El middleware de auth bloquea la petición OPTIONS (no tiene token)
```

```javascript
// ✅ CORS middleware ANTES del auth middleware
app.use(corsMiddleware);     // Responde a OPTIONS sin auth
app.use('/api', authMiddleware, apiRoutes);
```

El preflight (OPTIONS) no lleva cookies ni tokens. Si tu middleware de autenticación está antes que CORS, bloqueará el preflight y ninguna petición pasará.

### Error 5: No incluir `Content-Type` en `allowedHeaders`

```javascript
// ❌ El browser envía Content-Type: application/json
// pero el servidor no lo permite
app.use(cors({ allowedHeaders: ['Authorization'] }));
```

```javascript
// ✅ Incluir Content-Type
app.use(cors({ allowedHeaders: ['Content-Type', 'Authorization'] }));
```

Los "simple headers" (`Accept`, `Content-Language`, `Content-Type` con ciertos valores) no necesitan declararse. Pero si envías `Content-Type: application/json`, el navegador lo trata como un header "no simple" y requiere que esté en `allowedHeaders`.

### Error 6: Headers personalizados no visibles en el frontend

```javascript
// En el backend
res.setHeader('X-Total-Count', '142');

// En el frontend
const count = response.headers.get('X-Total-Count'); // null
```

Los navegadores solo exponen los "CORS-safelisted headers" al frontend. Para headers custom, necesitas `exposedHeaders`:

```javascript
// ✅ Exponer headers custom
app.use(cors({ exposedHeaders: ['X-Total-Count', 'X-Request-Id'] }));
```

### Error 7: CORS en producción con reverse proxy (Nginx)

Si tu API está detrás de Nginx, CORS puede configurarse en Nginx O en Express, pero **no en ambos**. Si ambos añaden `Access-Control-Allow-Origin`, el navegador recibe el header duplicado y lo rechaza:

```
The 'Access-Control-Allow-Origin' header contains multiple values
'https://miapp.com, https://miapp.com', but only one is allowed.
```

```nginx
# ✅ Opción 1: CORS en Nginx (y desactivar en Express)
location /api/ {
    proxy_pass http://localhost:4000;

    add_header Access-Control-Allow-Origin "https://miapp.com" always;
    add_header Access-Control-Allow-Credentials true always;
    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;

    if ($request_method = OPTIONS) {
        return 204;
    }
}
```

```javascript
// ✅ Opción 2: CORS en Express (recomendado — más control)
// No tocar Nginx, dejar que Express maneje CORS
```

Yo prefiero manejar CORS en Express porque es más fácil de debuggear y mantener que editando configs de Nginx.

## Configuración para múltiples dominios dinámicos

Si tienes un SaaS multi-tenant donde cada cliente tiene su subdominio:

```javascript
const corsMiddleware = cors({
  origin: function (origin, callback) {
    if (!origin) return callback(null, true);

    // Permitir cualquier subdominio de miapp.com
    const allowedPattern = /^https:\/\/[\w-]+\.miapp\.com$/;

    if (allowedPattern.test(origin) || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
});
```

**Cuidado**: El regex debe ser estricto. Un regex laxo como `/miapp\.com/` permitiría `evil-miapp.com` (spoofing).

## Config para APIs públicas (sin auth)

Si tu API es pública y no usa cookies:

```javascript
// ✅ Para APIs públicas — wildcard está bien
app.use(cors());
// Equivale a: Access-Control-Allow-Origin: *
```

Ejemplos donde wildcard es correcto:
- API de precios de criptomonedas
- API de datos abiertos del gobierno
- CDN de recursos estáticos
- API de herramientas públicas (conversor de moneda, clima, etc.)

## Mi checklist de CORS para producción

- [ ] `origin` especifica los dominios exactos (no wildcard con auth)
- [ ] `credentials: true` si usas cookies de sesión
- [ ] `allowedHeaders` incluye `Content-Type` y `Authorization`
- [ ] CORS middleware va ANTES del auth middleware
- [ ] Preflight (OPTIONS) responde con 204 sin pasar por auth
- [ ] `maxAge: 86400` para cachear preflight y reducir latencia
- [ ] No duplicar headers CORS entre Nginx y Express
- [ ] Los origenes de desarrollo (localhost) solo se añaden en `NODE_ENV !== 'production'`
- [ ] `exposedHeaders` declarados si el frontend lee headers custom
- [ ] Logs de errores CORS para detectar intentos sospechosos

## Artículos relacionados

- [Error: CORS Access-Control-Allow-Origin](/blog/errores/cors-access-control-allow-origin/) — la guía de error rápida
- [Proteger tu API Node.js con JWT](/blog/proteger-api-nodejs-jwt-auth-guia-2026/) — auth que necesita CORS bien configurado
- [Banner de Cookies RGPD paso a paso](/blog/banner-cookies-rgpd-implementacion-correcta-2026/) — otra pieza de seguridad web]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[¿Vale la Pena Pagar Cursor Pro? Comparativa Real con GitHub Copilot en 2026]]></title>
      <link>https://francobosg.netlify.app/blog/cursor-pro-vs-copilot-vale-la-pena-2026/</link>
      <description><![CDATA[Pagué 3 meses de Cursor Pro ($20/mes) y llevo 2 años con Copilot. Comparativa con números reales: velocidad, calidad de código, consumo de API, y cuál merece tu dinero.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/cursor-pro-vs-copilot-vale-la-pena-2026/</guid>
      <category>IA</category>
      <category>Herramientas</category>
      <category>Código</category>
      <content:encoded><![CDATA[Llevo 2 años pagando GitHub Copilot ($10/mes). Hace 3 meses empecé a pagar también Cursor Pro ($20/mes) para comparar. Aquí va mi veredicto honesto, con números reales.

**Spoiler**: No hay un ganador claro. Depende de cómo trabajas. Pero sí hay un ganador en relación calidad/precio, y no es el que esperaba.

## Mi setup para la comparativa

- **Proyectos**: SaaS con NestJS + React + PostgreSQL, y este mismo portfolio/blog con Astro
- **Lenguajes**: TypeScript (90%), Python (10%)
- **Periodo**: Enero-Marzo 2026
- **Método**: Misma tarea en ambos editores, midiendo tiempo y calidad

## Autocompletado en línea: Copilot gana

El autocompletado mientras escribes (la sugerencia gris en línea) es lo que más usas en el día a día. Aquí Copilot sigue siendo el rey.

| Aspecto | Copilot | Cursor |
|---------|---------|--------|
| Velocidad de sugerencia | ~200ms | ~400ms |
| Relevancia contextual | 9/10 | 8/10 |
| Multi-línea | Bueno | Bueno |
| Sugerencias fantasma | Pocas | Más frecuentes |

"Sugerencias fantasma" son las que aparecen y desaparecen antes de que puedas aceptarlas. Cursor las tiene más a menudo, probablemente porque su modelo hace más procesamiento antes de mostrar la sugerencia.

**Ejemplo real**: Escribo `const user = await` en un archivo donde tengo Prisma:

- **Copilot** sugiere `prisma.user.findUnique({ where: { id } })` en 180ms → correcto
- **Cursor** sugiere lo mismo pero en 350ms, y a veces primero muestra `fetch('/api/user')` por medio segundo antes de corregir → molesto

Para autocompletado puro, Copilot es más fluido. Lo noto especialmente en archivos grandes donde Cursor a veces se traba.

## Modo Agent/Chat: Cursor gana por goleada

Aquí la diferencia es brutal. El modo Agent de Cursor es otra liga:

### Tarea de prueba: "Añadir sistema de notificaciones por email"

**Con Copilot Chat** (en VS Code):
1. Le explico lo que necesito en el chat
2. Me da código en el chat que tengo que copiar y pegar
3. No sabe qué archivos ya tengo ni cómo se conectan
4. Tardo **45 minutos** entre pegar código, ajustar imports y arreglar errores

**Con Cursor Agent**:
1. Le explico lo que necesito
2. Lee mi estructura de carpetas solo
3. Edita 4 archivos directamente (service, controller, module, template)
4. Ejecuta `npm run build` para verificar
5. Funciona en **12 minutos** con un error menor que arreglo a mano

Cursor Agent edita archivos directamente, entiende la estructura del proyecto, y puede ejecutar comandos. Copilot Chat te da texto que tú tienes que aplicar manualmente.

> Desde que Copilot lanzó el modo "Edits" y el Agent mode en VS Code, la diferencia se ha reducido. Pero Cursor sigue siendo más rápido porque fue diseñado para esto desde el inicio.

### Refactoring multi-archivo

| Tarea | Copilot | Cursor Agent |
|-------|---------|-------------|
| Renombrar entidad en 8 archivos | 15 min (manual con Find & Replace) | 3 min (lo hace solo) |
| Añadir endpoint + tests | 25 min | 8 min |
| Migrar de API antigua a nueva | 40 min | 15 min |
| Crear CRUD completo | 30 min | 10 min |

Cursor Agent me ahorra ~60% del tiempo en tareas multi-archivo. Pero hay una trampa...

## La trampa de Cursor: Las 500 solicitudes

El plan Pro incluye 500 solicitudes "fast" al mes. Suena a mucho. No lo es.

Mi consumo real en un mes:

| Semana | Solicitudes | Acumulado |
|--------|------------|-----------|
| 1 | 180 | 180 |
| 2 | 155 | 335 |
| 3 | 140 | 475 |
| 4 | 25 (modo slow) | 500 |

**A la tercera semana ya estaba en modo slow**. El modo slow usa modelos menos potentes y tarda 10-30 segundos por respuesta. Es usable pero frustrante.

¿Cuántas solicitudes gasta cada acción?

- Autocompletado Tab: 0 (no cuenta)
- Chat simple: 1 solicitud
- Agent (tarea pequeña, 2-3 archivos): 3-5 solicitudes
- Agent (tarea grande, 5+ archivos): 8-15 solicitudes

Una sesión intensa con el Agent puede gastar 30-50 solicitudes en una hora. Si usas el Agent para todo, las 500 no llegan al mes.

### ¿Solución? Tu propia API key

Puedes poner tu API key de OpenAI o Anthropic en Cursor. Pero entonces pagas por token:

```
Tarea mediana con Claude Sonnet (input ~8K tokens, output ~3K tokens):
= $0.024 (input) + $0.045 (output) = ~$0.07 por solicitud

50 solicitudes/día × 22 días = 1.100 solicitudes
1.100 × $0.07 = ~$77/mes
```

Sí, **$77/mes** si usas tu key intensamente. Más caro que los $20 del plan Pro.

## Copilot: Precio imbatible

| Plan | Precio | Incluye |
|------|--------|---------|
| **Copilot Free** | $0 | 2.000 autocompletados + 50 chats/mes |
| **Copilot Individual** | $10/mes | Ilimitado + chat + Agent mode |
| **Copilot Business** | $19/mes | + políticas empresa + IP indemnity |
| **Cursor Free** | $0 | 50 solicitudes + autocompletado limitado |
| **Cursor Pro** | $20/mes | 500 fast + ilimitado slow + Agent |
| **Cursor Business** | $40/mes | + admin + roles |

Copilot Individual a $10/mes con uso ilimitado es difícil de superar. Cursor Pro a $20 con 500 solicitudes limitadas es el doble de caro y con menos "gasolina".

## Mi flujo de trabajo real (después de 3 meses)

Terminé usando **ambos**, pero para cosas diferentes:

| Tarea | Herramienta | Por qué |
|-------|-------------|---------|
| Escribir código nuevo línea a línea | Copilot | Autocompletado más rápido |
| Refactoring multi-archivo | Cursor Agent | 60% más rápido |
| Generar tests | Cursor Agent | Entiende el contexto del proyecto |
| Debugging rápido | Copilot Chat | Más rápido para preguntas simples |
| Crear features completas | Cursor Agent | Edita archivos directamente |
| Documentar código | Copilot | Suficiente para JSDoc/comentarios |

Pero pagar $30/mes por dos herramientas de IA es excesivo. Tengo que elegir una.

## Mi veredicto: ¿Cuál merece tu dinero?

### Si eres junior o estás aprendiendo → Copilot ($10/mes)

- El autocompletado te enseña patrones mientras escribes
- El chat es suficiente para preguntas
- $10/mes no duele
- Se integra en VS Code que ya conoces

### Si eres mid/senior y facturas tu tiempo → Cursor Pro ($20/mes)

- El Agent te ahorra 1-2 horas al día en refactoring
- Si cobras +$30/hora, los $20/mes se pagan solos en un día
- Necesitas saber "guiar" al agente (es una skill)
- Vigila las 500 solicitudes

### Si eres freelance con presupuesto ajustado → Copilot Free ($0)

- 2.000 autocompletados al mes es suficiente para proyectos pequeños
- 50 chats dan para resolver dudas puntuales
- Cuando necesites más, paga el mes concreto

### Lo que yo hago ahora

Cancelé Cursor Pro y me quedé con Copilot Individual ($10/mes). Para las 2-3 veces al mes que necesito un refactoring masivo, uso [Aider](/blog/aider-ia-terminal-barata-alternativa-cursor-2026/) con mi API key de Claude — me cuesta ~$3-5 por sesión y no pago suscripción.

**Total**: $10/mes (Copilot) + ~$5/mes (Aider con API key) = **$15/mes** por lo mejor de ambos mundos.

## Lo que mejoraría de cada uno

### Cursor
- Las 500 solicitudes son pocas para el precio. Deberían ser 1.000 o ilimitadas slow más rápido.
- El autocompletado debería ser tan fluido como Copilot.
- Necesita mejor integración con Git (Copilot muestra diffs inline mejor).

### Copilot
- El modo Agent necesita mejorar la edición multi-archivo.
- El chat debería poder ejecutar comandos de terminal (como Cursor).
- Debería soportar modelos de Anthropic (Claude es mejor para código).

## Artículos relacionados

- [Cursor vs Copilot vs Windsurf: Comparativa técnica](/blog/cursor-vs-copilot-vs-windsurf-2026/) — la comparativa más detallada con benchmarks
- [Aider: programar con IA desde la terminal por céntimos](/blog/aider-ia-terminal-barata-alternativa-cursor-2026/) — la alternativa barata
- [Programar con IA sin arruinarte](/blog/programar-con-ia-sin-arruinarte-guia-2026/) — cómo controlar el gasto en herramientas de IA
- [Errores comunes con Copilot y Cline en VS Code](/blog/errores-comunes-ia-vscode-copilot-cline-2026/) — soluciones rápidas]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cómo Estructuro las Carpetas en un Proyecto Grande de React/Next.js (Vida Real)]]></title>
      <link>https://francobosg.netlify.app/blog/estructura-carpetas-react-nextjs-2026/</link>
      <description><![CDATA[La estructura de carpetas que uso en proyectos React y Next.js en producción con +50 componentes. Feature-based, sin over-engineering, con ejemplos reales del árbol completo.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/estructura-carpetas-react-nextjs-2026/</guid>
      <category>React</category>
      <category>Arquitectura</category>
      <category>Full-Stack</category>
      <content:encoded><![CDATA[Pregúntale a ChatGPT "cómo estructurar un proyecto React" y te dará esto:

```
src/
├── components/
├── hooks/
├── utils/
├── services/
├── types/
└── styles/
```

Esta estructura funciona... para un proyecto con 10 archivos. Cuando llegas a 50+ componentes, 20 hooks y 30 utils, abrir la carpeta `components/` es como abrir el cajón de los cubiertos de tu abuela: hay de todo, no encuentras nada, y tienes miedo de tocar algo.

## El problema de organizar por tipo de archivo

En uno de mis primeros proyectos grandes (un SaaS de gestión de tareas), tenía esta estructura "por tipo":

```
src/
├── components/
│   ├── Button.tsx
│   ├── Modal.tsx
│   ├── TaskCard.tsx
│   ├── TaskForm.tsx
│   ├── TaskList.tsx
│   ├── TaskFilters.tsx
│   ├── ProjectSidebar.tsx
│   ├── ProjectHeader.tsx
│   ├── ProjectSettings.tsx
│   ├── UserAvatar.tsx
│   ├── UserProfile.tsx
│   ├── UserSettings.tsx
│   ├── InvoiceTable.tsx
│   ├── InvoiceForm.tsx
│   ├── ... (47 archivos más)
├── hooks/
│   ├── useTasks.ts
│   ├── useProjects.ts
│   ├── useAuth.ts
│   ├── useInvoices.ts
│   ├── ... (18 archivos más)
├── utils/
│   ├── formatDate.ts
│   ├── formatCurrency.ts
│   ├── taskHelpers.ts
│   ├── projectHelpers.ts
│   ├── ... (12 archivos más)
└── types/
    ├── task.ts
    ├── project.ts
    ├── user.ts
    └── invoice.ts
```

Para implementar una feature nueva de tareas, tenía que editar archivos en **5 carpetas diferentes**: components/, hooks/, utils/, types/ y store/. Cada vez que abría VS Code, la barra lateral parecía una sopa de letras.

## La estructura que uso ahora: Feature-Based

```
src/
├── app/                          # Rutas de Next.js (solo routing)
│   ├── (auth)/
│   │   ├── login/page.tsx
│   │   └── register/page.tsx
│   ├── (dashboard)/
│   │   ├── layout.tsx
│   │   ├── page.tsx              # Dashboard home
│   │   ├── tasks/
│   │   │   ├── page.tsx          # Lista de tareas
│   │   │   └── [id]/page.tsx     # Detalle de tarea
│   │   ├── projects/
│   │   │   ├── page.tsx
│   │   │   └── [id]/page.tsx
│   │   └── settings/
│   │       └── page.tsx
│   ├── api/                      # API routes
│   │   ├── tasks/route.ts
│   │   ├── projects/route.ts
│   │   └── webhooks/stripe/route.ts
│   ├── layout.tsx
│   └── page.tsx                  # Landing page
│
├── features/                     # 🔑 La clave: lógica agrupada por feature
│   ├── tasks/
│   │   ├── components/
│   │   │   ├── TaskCard.tsx
│   │   │   ├── TaskForm.tsx
│   │   │   ├── TaskList.tsx
│   │   │   └── TaskFilters.tsx
│   │   ├── hooks/
│   │   │   ├── useTasks.ts
│   │   │   └── useTaskMutations.ts
│   │   ├── lib/
│   │   │   ├── task-helpers.ts
│   │   │   └── task-validation.ts
│   │   ├── types.ts
│   │   └── index.ts              # Re-exports públicos
│   │
│   ├── projects/
│   │   ├── components/
│   │   │   ├── ProjectSidebar.tsx
│   │   │   ├── ProjectHeader.tsx
│   │   │   └── ProjectSettings.tsx
│   │   ├── hooks/
│   │   │   └── useProjects.ts
│   │   ├── types.ts
│   │   └── index.ts
│   │
│   ├── auth/
│   │   ├── components/
│   │   │   ├── LoginForm.tsx
│   │   │   └── RegisterForm.tsx
│   │   ├── hooks/
│   │   │   └── useAuth.ts
│   │   ├── lib/
│   │   │   └── auth-config.ts
│   │   └── index.ts
│   │
│   └── billing/
│       ├── components/
│       │   ├── PricingTable.tsx
│       │   ├── InvoiceList.tsx
│       │   └── SubscriptionBadge.tsx
│       ├── hooks/
│       │   └── useSubscription.ts
│       ├── lib/
│       │   └── stripe.ts
│       └── index.ts
│
├── components/                   # Componentes compartidos (UI primitivos)
│   ├── ui/
│   │   ├── Button.tsx
│   │   ├── Input.tsx
│   │   ├── Modal.tsx
│   │   ├── Skeleton.tsx
│   │   ├── Badge.tsx
│   │   └── Card.tsx
│   └── layout/
│       ├── Navbar.tsx
│       ├── Sidebar.tsx
│       └── Footer.tsx
│
├── lib/                          # Utilidades globales
│   ├── db.ts                     # Cliente de Prisma
│   ├── api.ts                    # Fetch wrapper
│   ├── utils.ts                  # cn(), formatDate(), etc.
│   └── constants.ts
│
├── hooks/                        # Hooks globales (no específicos de una feature)
│   ├── useMediaQuery.ts
│   └── useDebounce.ts
│
└── types/                        # Tipos globales
    └── globals.d.ts
```

## Las reglas que sigo

### 1. Una feature = una carpeta con todo lo suyo

Cada carpeta en `features/` tiene TODO lo que necesita esa funcionalidad: componentes, hooks, utils, tipos. Si mañana quiero eliminar la feature de billing, borro `features/billing/` y listo.

```
features/tasks/
├── components/    # Solo componentes de tareas
├── hooks/         # Solo hooks de tareas
├── lib/           # Solo utils de tareas
├── types.ts       # Solo tipos de tareas
└── index.ts       # Lo que otras features pueden importar
```

### 2. El index.ts controla la API pública de la feature

```typescript
// features/tasks/index.ts
export { TaskList } from './components/TaskList';
export { TaskCard } from './components/TaskCard';
export { useTasks } from './hooks/useTasks';
export type { Task, CreateTaskInput } from './types';

// NO exporto componentes internos como TaskFilters
// — es un detalle de implementación de TaskList
```

Esto significa que desde fuera, importas así:

```typescript
// ✅ Correcto — importa desde el barrel
import { TaskList, useTasks } from '@/features/tasks';

// ❌ Evitar — acoplamiento a la estructura interna
import { TaskList } from '@/features/tasks/components/TaskList';
```

### 3. `components/` global = solo UI primitivos sin lógica de negocio

Si un componente tiene lógica de negocio (fetch datos, validaciones específicas, etc.), va en `features/`. Si es un componente genérico reutilizable (Button, Modal, Input, Card), va en `components/ui/`.

```typescript
// components/ui/Button.tsx — genérico, sin lógica de negocio
export function Button({ children, variant, ...props }) { ... }

// features/tasks/components/TaskCard.tsx — específico de tareas
export function TaskCard({ task }: { task: Task }) {
  // Conoce el tipo Task, formatea fechas específicas, tiene acciones de tareas
}
```

### 4. `app/` es solo routing — sin lógica

Las páginas en `app/` son thin wrappers que importan features:

```tsx
// app/(dashboard)/tasks/page.tsx
import { TaskList } from '@/features/tasks';
import { getCurrentUser } from '@/features/auth';

export default async function TasksPage() {
  const user = await getCurrentUser();
  return <TaskList userId={user.id} />;
}
```

3 líneas. Sin lógica. La página solo decide qué componente de feature renderizar y le pasa los datos necesarios.

### 5. Imports con alias `@/` siempre

```json
// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
```

```typescript
// ✅ Claro, corto, no se rompe al mover archivos
import { Button } from '@/components/ui/Button';
import { TaskList } from '@/features/tasks';

// ❌ Infierno de puntos relativos
import { Button } from '../../../components/ui/Button';
import { TaskList } from '../../features/tasks/components/TaskList';
```

## Cuándo empiezo con esta estructura

**No desde el minuto 1.** Para un proyecto nuevo o un MVP, empiezo plano:

```
src/
├── app/
├── components/        # Todo junto al principio
├── lib/
└── types/
```

Cuando llego a **~20 componentes** o cuando noto que toco 4+ carpetas para una feature, es el momento de refactorizar a feature-based. Antes de eso es over-engineering.

La migración es gradual:
1. Creo `features/tasks/`
2. Muevo los archivos relacionados con tareas
3. Actualizo los imports
4. Repito para la siguiente feature

VS Code con "Move to file" y el rename de TypeScript actualiza los imports automáticamente.

## Errores que he cometido

### Error 1: Carpetas dentro de carpetas dentro de carpetas

```
// ❌ Demasiada anidación
features/tasks/components/list/items/card/TaskCard.tsx
```

Si necesitas más de 3 niveles de profundidad, algo va mal. La carpeta `features/tasks/components/` con archivos planos funciona perfectamente hasta 15-20 componentes. Si necesitas más, probablemente la feature es demasiado grande y habría que dividirla.

### Error 2: Features que se importan entre sí de forma circular

```typescript
// ❌ features/tasks importa de features/projects
// Y features/projects importa de features/tasks
// → Dependencia circular
```

Si dos features se necesitan mutuamente, hay algo que debería estar en `lib/` o en una feature compartida. Los tipos compartidos van a `types/`, los utils compartidos a `lib/`.

### Error 3: Mover TODO a features/ desde el principio

No todo necesita ser una feature. Si solo tienes un componente de auth (LoginForm) y un hook (useAuth), no necesitas una carpeta `features/auth/` con subcarpetas. Ponlo en `components/` y cuando crezca, muévelo.

## La estructura para un proyecto de portfolio o blog

Para proyectos pequeños (portfolios, blogs, landing pages), esta estructura es overkill. Para esos uso:

```
src/
├── app/
│   ├── page.tsx
│   ├── blog/
│   │   ├── page.tsx
│   │   └── [slug]/page.tsx
│   └── layout.tsx
├── components/
│   ├── Hero.tsx
│   ├── ProjectCard.tsx
│   ├── BlogPost.tsx
│   └── ContactForm.tsx
├── lib/
│   ├── posts.ts        # Fetch de posts del blog
│   └── utils.ts
└── content/
    └── posts/           # Markdown files
```

Plano, simple, suficiente. La estructura feature-based solo tiene sentido cuando hay **múltiples dominios de negocio** (users, tasks, billing, projects...).

## Artículos relacionados

- [Por qué dejé de usar Redux (y qué uso ahora)](/blog/por-que-deje-redux-alternativas-2026/) — cómo organizo el estado sin Redux
- [Caso real: SaaS con NestJS y React](/blog/caso-real-saas-atrapaclientes-nestjs-react/) — esta estructura en un proyecto real
- [Auth.js (NextAuth) sin volverte loco](/blog/auth-js-nextauth-implementacion-real-2026/) — cómo estructuro la feature de auth]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[10 Ejemplos de Landing Pages Modernas con Tailwind CSS (Código Completo y Listo)]]></title>
      <link>https://francobosg.netlify.app/blog/landing-pages-tailwind-ejemplos-codigo-2026/</link>
      <description><![CDATA[10 diseños de landing page con Tailwind CSS que puedes copiar y pegar. Hero sections, pricing tables, features grid, testimonios y CTA — código completo sin dependencias.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/landing-pages-tailwind-ejemplos-codigo-2026/</guid>
      <category>Tutorial</category>
      <category>Código</category>
      <category>Full-Stack</category>
      <content:encoded><![CDATA[Pedirle a ChatGPT "hazme una landing page bonita" genera HTML genérico con colores random y layouts que parecen de 2018. La IA no tiene gusto estético — no puede decirte si algo "se ve bien".

Estos 10 bloques están **curados a mano** y probados visualmente. Cada uno es un componente independiente que puedes copiar, pegar y adaptar.

## Cómo usar estos ejemplos

1. Copia el código HTML/Tailwind
2. Pega en tu proyecto (cualquier framework o HTML puro)
3. Cambia textos, colores e imágenes
4. Los colores usan variables CSS fáciles de personalizar

Todos los ejemplos asumen que tienes Tailwind CSS instalado. Si no, sigue la [guía oficial](https://tailwindcss.com/docs/installation).

---

## 1. Hero Section — Gradiente con CTA

El hero es lo primero que ve el usuario. Este diseño usa un gradiente oscuro con texto grande y dos botones de acción.

```html
<section class="relative overflow-hidden bg-gradient-to-br from-gray-900 via-blue-900 to-gray-900">
  <div class="absolute inset-0 bg-[url('/grid-pattern.svg')] opacity-10"></div>
  <div class="relative mx-auto max-w-5xl px-6 py-24 sm:py-32 lg:py-40 text-center">
    <span class="inline-block rounded-full bg-blue-500/10 px-4 py-1.5 text-sm font-medium text-blue-400 ring-1 ring-blue-500/20 mb-8">
      Nuevo: Integración con IA
    </span>
    <h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white tracking-tight leading-tight">
      Construye tu SaaS<br>
      <span class="bg-gradient-to-r from-blue-400 to-cyan-400 bg-clip-text text-transparent">
        10x más rápido
      </span>
    </h1>
    <p class="mt-6 text-lg sm:text-xl text-gray-300 max-w-2xl mx-auto leading-relaxed">
      Framework completo con autenticación, pagos, dashboard y API. 
      Deja de reinventar la rueda y lanza en días, no en meses.
    </p>
    <div class="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
      <a href="#" class="rounded-lg bg-blue-600 px-8 py-3.5 text-base font-semibold text-white shadow-lg shadow-blue-600/25 hover:bg-blue-500 transition-colors">
        Empezar gratis →
      </a>
      <a href="#" class="rounded-lg border border-gray-600 px-8 py-3.5 text-base font-semibold text-gray-300 hover:bg-gray-800 transition-colors">
        Ver demo
      </a>
    </div>
    <p class="mt-6 text-sm text-gray-500">
      Sin tarjeta de crédito · Setup en 5 minutos · Cancela cuando quieras
    </p>
  </div>
</section>
```

**Por qué funciona**: Gradiente sobrio (no rainbow), badge de novedad arriba, headline con contraste de color, subtítulo que habla de beneficios, dos CTAs con jerarquía visual (primario azul, secundario outline), y texto de confianza debajo.

---

## 2. Feature Grid — 3 columnas con iconos

```html
<section class="bg-white dark:bg-gray-900 py-20 px-6">
  <div class="mx-auto max-w-5xl text-center mb-16">
    <h2 class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white">
      Todo lo que necesitas para lanzar
    </h2>
    <p class="mt-4 text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
      Cada feature está probada en producción con miles de usuarios.
    </p>
  </div>
  <div class="mx-auto max-w-5xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
    <!-- Feature 1 -->
    <div class="relative rounded-2xl border border-gray-200 dark:border-gray-800 p-8 hover:border-blue-500/50 transition-colors">
      <div class="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-600 text-white text-xl mb-5">
        🔐
      </div>
      <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Autenticación</h3>
      <p class="mt-2 text-gray-600 dark:text-gray-400 leading-relaxed">
        Login con Google, GitHub y email. Sesiones seguras con JWT y refresh tokens. Listo en 5 minutos.
      </p>
    </div>
    <!-- Feature 2 -->
    <div class="relative rounded-2xl border border-gray-200 dark:border-gray-800 p-8 hover:border-blue-500/50 transition-colors">
      <div class="flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-600 text-white text-xl mb-5">
        💳
      </div>
      <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Pagos con Stripe</h3>
      <p class="mt-2 text-gray-600 dark:text-gray-400 leading-relaxed">
        Suscripciones, one-time payments y webhooks configurados. Portal de cliente incluido.
      </p>
    </div>
    <!-- Feature 3 -->
    <div class="relative rounded-2xl border border-gray-200 dark:border-gray-800 p-8 hover:border-blue-500/50 transition-colors">
      <div class="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-600 text-white text-xl mb-5">
        📊
      </div>
      <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Dashboard</h3>
      <p class="mt-2 text-gray-600 dark:text-gray-400 leading-relaxed">
        Panel de admin con gráficas, tablas con filtros, y exportación a CSV. Dark mode incluido.
      </p>
    </div>
    <!-- Feature 4 -->
    <div class="relative rounded-2xl border border-gray-200 dark:border-gray-800 p-8 hover:border-blue-500/50 transition-colors">
      <div class="flex h-12 w-12 items-center justify-center rounded-lg bg-amber-600 text-white text-xl mb-5">
        📧
      </div>
      <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Emails transaccionales</h3>
      <p class="mt-2 text-gray-600 dark:text-gray-400 leading-relaxed">
        Templates de email con React Email. Welcome, reset password, invoices — todo listo.
      </p>
    </div>
    <!-- Feature 5 -->
    <div class="relative rounded-2xl border border-gray-200 dark:border-gray-800 p-8 hover:border-blue-500/50 transition-colors">
      <div class="flex h-12 w-12 items-center justify-center rounded-lg bg-rose-600 text-white text-xl mb-5">
        🚀
      </div>
      <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deploy en 1 clic</h3>
      <p class="mt-2 text-gray-600 dark:text-gray-400 leading-relaxed">
        Vercel, Railway o Docker. CI/CD con GitHub Actions preconfigurado. De git push a producción.
      </p>
    </div>
    <!-- Feature 6 -->
    <div class="relative rounded-2xl border border-gray-200 dark:border-gray-800 p-8 hover:border-blue-500/50 transition-colors">
      <div class="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-600 text-white text-xl mb-5">
        🤖
      </div>
      <h3 class="text-lg font-semibold text-gray-900 dark:text-white">IA integrada</h3>
      <p class="mt-2 text-gray-600 dark:text-gray-400 leading-relaxed">
        Streaming con OpenAI/Claude listo para usar. RAG con embeddings y vector search incluido.
      </p>
    </div>
  </div>
</section>
```

**Tips de diseño**: Las tarjetas con `hover:border-blue-500/50` dan feedback visual sin ser agresivas. Los iconos con fondo de color roto por sección (azul, verde, morado...) crean variedad. El grid pasa a 1 columna en móvil automáticamente.

---

## 3. Pricing Table — 3 planes con plan destacado

```html
<section class="bg-gray-50 dark:bg-gray-950 py-20 px-6">
  <div class="mx-auto max-w-5xl text-center mb-16">
    <h2 class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white">
      Precios simples, sin sorpresas
    </h2>
    <p class="mt-4 text-lg text-gray-600 dark:text-gray-400">
      Empieza gratis. Escala cuando lo necesites.
    </p>
  </div>
  <div class="mx-auto max-w-5xl grid grid-cols-1 md:grid-cols-3 gap-8 items-start">
    <!-- Free -->
    <div class="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-8">
      <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Free</h3>
      <p class="mt-2 text-sm text-gray-500">Para proyectos personales</p>
      <p class="mt-6"><span class="text-4xl font-bold text-gray-900 dark:text-white">0€</span><span class="text-gray-500">/mes</span></p>
      <ul class="mt-8 space-y-3 text-sm text-gray-600 dark:text-gray-400">
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> 1 proyecto</li>
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> 1.000 usuarios</li>
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> Auth básica</li>
        <li class="flex items-center gap-3"><span class="text-gray-300 dark:text-gray-600">✗</span> Custom domain</li>
        <li class="flex items-center gap-3"><span class="text-gray-300 dark:text-gray-600">✗</span> Soporte prioritario</li>
      </ul>
      <a href="#" class="mt-8 block w-full rounded-lg border border-gray-300 dark:border-gray-700 py-2.5 text-center text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
        Empezar gratis
      </a>
    </div>
    <!-- Pro (destacado) -->
    <div class="rounded-2xl border-2 border-blue-600 bg-white dark:bg-gray-900 p-8 shadow-xl shadow-blue-600/10 relative">
      <span class="absolute -top-3.5 left-1/2 -translate-x-1/2 rounded-full bg-blue-600 px-4 py-1 text-xs font-semibold text-white">
        Más popular
      </span>
      <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Pro</h3>
      <p class="mt-2 text-sm text-gray-500">Para SaaS en producción</p>
      <p class="mt-6"><span class="text-4xl font-bold text-gray-900 dark:text-white">29€</span><span class="text-gray-500">/mes</span></p>
      <ul class="mt-8 space-y-3 text-sm text-gray-600 dark:text-gray-400">
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> Proyectos ilimitados</li>
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> 50.000 usuarios</li>
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> Auth + OAuth providers</li>
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> Custom domain</li>
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> Soporte por email</li>
      </ul>
      <a href="#" class="mt-8 block w-full rounded-lg bg-blue-600 py-2.5 text-center text-sm font-semibold text-white shadow-lg shadow-blue-600/25 hover:bg-blue-500 transition-colors">
        Empezar con Pro
      </a>
    </div>
    <!-- Enterprise -->
    <div class="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-8">
      <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Enterprise</h3>
      <p class="mt-2 text-sm text-gray-500">Para equipos grandes</p>
      <p class="mt-6"><span class="text-4xl font-bold text-gray-900 dark:text-white">99€</span><span class="text-gray-500">/mes</span></p>
      <ul class="mt-8 space-y-3 text-sm text-gray-600 dark:text-gray-400">
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> Todo lo de Pro</li>
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> Usuarios ilimitados</li>
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> SSO / SAML</li>
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> SLA 99.9%</li>
        <li class="flex items-center gap-3"><span class="text-green-500">✓</span> Soporte prioritario 24/7</li>
      </ul>
      <a href="#" class="mt-8 block w-full rounded-lg border border-gray-300 dark:border-gray-700 py-2.5 text-center text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
        Contactar ventas
      </a>
    </div>
  </div>
</section>
```

**Por qué funciona**: El plan central tiene `border-2 border-blue-600` + `shadow-xl` + badge "Más popular" para llamar la atención. Los checkmarks en verde y las X en gris crean un contraste visual inmediato. El CTA del plan Pro es el único con fondo lleno.

---

## 4. Testimonios — Tarjetas con avatar

```html
<section class="bg-white dark:bg-gray-900 py-20 px-6">
  <div class="mx-auto max-w-5xl text-center mb-16">
    <h2 class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white">
      Lo que dicen nuestros usuarios
    </h2>
  </div>
  <div class="mx-auto max-w-5xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
    <div class="rounded-2xl border border-gray-200 dark:border-gray-800 p-6">
      <div class="flex items-center gap-1 text-amber-400 text-sm mb-4">★★★★★</div>
      <p class="text-gray-600 dark:text-gray-300 leading-relaxed">
        "Lancé mi SaaS en 2 semanas usando este boilerplate. Lo que más me gustó es que los pagos con Stripe ya estaban configurados — eso solo me habría llevado 3 días."
      </p>
      <div class="mt-6 flex items-center gap-3">
        <div class="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white text-sm font-bold">ML</div>
        <div>
          <p class="text-sm font-semibold text-gray-900 dark:text-white">María López</p>
          <p class="text-xs text-gray-500">Fundadora de TaskFlow</p>
        </div>
      </div>
    </div>
    <div class="rounded-2xl border border-gray-200 dark:border-gray-800 p-6">
      <div class="flex items-center gap-1 text-amber-400 text-sm mb-4">★★★★★</div>
      <p class="text-gray-600 dark:text-gray-300 leading-relaxed">
        "El código está limpio y bien documentado. No es un boilerplate de esos que necesitas 2 días para entender la estructura. Lo abrí y ya sabía dónde estaba todo."
      </p>
      <div class="mt-6 flex items-center gap-3">
        <div class="h-10 w-10 rounded-full bg-gradient-to-br from-emerald-500 to-teal-500 flex items-center justify-center text-white text-sm font-bold">DR</div>
        <div>
          <p class="text-sm font-semibold text-gray-900 dark:text-white">David Ruiz</p>
          <p class="text-xs text-gray-500">Senior Dev en Acme Corp</p>
        </div>
      </div>
    </div>
    <div class="rounded-2xl border border-gray-200 dark:border-gray-800 p-6">
      <div class="flex items-center gap-1 text-amber-400 text-sm mb-4">★★★★★</div>
      <p class="text-gray-600 dark:text-gray-300 leading-relaxed">
        "El soporte es de 10. Tuve un problema con los webhooks de Stripe y me respondieron en 2 horas con una solución que funcionó a la primera."
      </p>
      <div class="mt-6 flex items-center gap-3">
        <div class="h-10 w-10 rounded-full bg-gradient-to-br from-rose-500 to-pink-500 flex items-center justify-center text-white text-sm font-bold">AC</div>
        <div>
          <p class="text-sm font-semibold text-gray-900 dark:text-white">Ana Castillo</p>
          <p class="text-xs text-gray-500">Freelance fullstack</p>
        </div>
      </div>
    </div>
  </div>
</section>
```

**Tip**: Los avatares con gradientes (en vez de imágenes) se ven modernos y no necesitas fotos reales. Si tienes fotos, usa `<img class="h-10 w-10 rounded-full object-cover">`.

---

## 5. CTA Final — Fondo con gradiente

```html
<section class="relative overflow-hidden">
  <div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-violet-600"></div>
  <div class="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-white/10 via-transparent to-transparent"></div>
  <div class="relative mx-auto max-w-3xl px-6 py-20 sm:py-28 text-center">
    <h2 class="text-3xl sm:text-4xl font-bold text-white">
      ¿Listo para lanzar tu próximo proyecto?
    </h2>
    <p class="mt-4 text-lg text-blue-100 max-w-xl mx-auto">
      Únete a +2.000 desarrolladores que ya están construyendo más rápido.
    </p>
    <div class="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
      <a href="#" class="rounded-lg bg-white px-8 py-3.5 text-base font-semibold text-blue-600 shadow-lg hover:bg-gray-100 transition-colors">
        Empezar gratis →
      </a>
      <a href="#" class="rounded-lg border border-white/30 px-8 py-3.5 text-base font-semibold text-white hover:bg-white/10 transition-colors">
        Ver documentación
      </a>
    </div>
  </div>
</section>
```

---

## 6. Stats Counter — Números que impresionan

```html
<section class="bg-gray-900 py-16 px-6">
  <div class="mx-auto max-w-5xl grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
    <div>
      <p class="text-3xl sm:text-4xl font-bold text-white">2.5K+</p>
      <p class="mt-2 text-sm text-gray-400">Desarrolladores</p>
    </div>
    <div>
      <p class="text-3xl sm:text-4xl font-bold text-white">450+</p>
      <p class="mt-2 text-sm text-gray-400">Apps en producción</p>
    </div>
    <div>
      <p class="text-3xl sm:text-4xl font-bold text-white">99.9%</p>
      <p class="mt-2 text-sm text-gray-400">Uptime</p>
    </div>
    <div>
      <p class="text-3xl sm:text-4xl font-bold text-white">4.9/5</p>
      <p class="mt-2 text-sm text-gray-400">Valoración</p>
    </div>
  </div>
</section>
```

---

## 7. FAQ Accordion (solo CSS, sin JavaScript)

```html
<section class="bg-white dark:bg-gray-900 py-20 px-6">
  <div class="mx-auto max-w-2xl">
    <h2 class="text-3xl font-bold text-gray-900 dark:text-white text-center mb-12">
      Preguntas frecuentes
    </h2>
    <div class="divide-y divide-gray-200 dark:divide-gray-800">
      <details class="group py-4" open>
        <summary class="flex cursor-pointer items-center justify-between text-left text-base font-semibold text-gray-900 dark:text-white">
          ¿Necesito saber TypeScript?
          <span class="ml-4 text-gray-400 group-open:rotate-45 transition-transform text-xl">+</span>
        </summary>
        <p class="mt-3 text-gray-600 dark:text-gray-400 leading-relaxed">
          Recomendable pero no obligatorio. Todo el código tiene tipos, lo que te ayuda con el autocompletado. Si solo sabes JavaScript, podrás seguir igualmente.
        </p>
      </details>
      <details class="group py-4">
        <summary class="flex cursor-pointer items-center justify-between text-left text-base font-semibold text-gray-900 dark:text-white">
          ¿Incluye actualizaciones?
          <span class="ml-4 text-gray-400 group-open:rotate-45 transition-transform text-xl">+</span>
        </summary>
        <p class="mt-3 text-gray-600 dark:text-gray-400 leading-relaxed">
          Sí. Todas las actualizaciones menores y de seguridad están incluidas de por vida. Las major versions nuevas tienen un 50% de descuento para clientes existentes.
        </p>
      </details>
      <details class="group py-4">
        <summary class="flex cursor-pointer items-center justify-between text-left text-base font-semibold text-gray-900 dark:text-white">
          ¿Puedo usarlo en múltiples proyectos?
          <span class="ml-4 text-gray-400 group-open:rotate-45 transition-transform text-xl">+</span>
        </summary>
        <p class="mt-3 text-gray-600 dark:text-gray-400 leading-relaxed">
          Sí. La licencia es por desarrollador, no por proyecto. Puedes usarlo en todos los proyectos personales y comerciales que quieras.
        </p>
      </details>
    </div>
  </div>
</section>
```

**Tip**: El `<details>` + `<summary>` HTML nativo funciona como accordion sin JavaScript. El `group-open:rotate-45` gira el `+` en `×` cuando se abre.

---

## 8. Navbar — Sticky con blur

```html
<nav class="sticky top-0 z-50 border-b border-gray-200/50 dark:border-gray-800/50 bg-white/80 dark:bg-gray-950/80 backdrop-blur-lg">
  <div class="mx-auto max-w-5xl flex items-center justify-between px-6 py-4">
    <a href="/" class="text-xl font-bold text-gray-900 dark:text-white">
      MiSaaS<span class="text-blue-600">.</span>
    </a>
    <div class="hidden md:flex items-center gap-8 text-sm text-gray-600 dark:text-gray-400">
      <a href="#features" class="hover:text-gray-900 dark:hover:text-white transition-colors">Features</a>
      <a href="#pricing" class="hover:text-gray-900 dark:hover:text-white transition-colors">Precios</a>
      <a href="#faq" class="hover:text-gray-900 dark:hover:text-white transition-colors">FAQ</a>
      <a href="/blog" class="hover:text-gray-900 dark:hover:text-white transition-colors">Blog</a>
    </div>
    <div class="flex items-center gap-4">
      <a href="/login" class="hidden sm:block text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
        Iniciar sesión
      </a>
      <a href="/signup" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors">
        Empezar gratis
      </a>
    </div>
  </div>
</nav>
```

**El truco del blur**: `bg-white/80 backdrop-blur-lg` crea ese efecto glassmorphism donde la navbar es semi-transparente y el contenido de debajo se difumina al hacer scroll. Muy popular en 2025-2026.

---

## 9. Footer — Limpio con columnas

```html
<footer class="bg-gray-950 border-t border-gray-800 px-6 py-16">
  <div class="mx-auto max-w-5xl grid grid-cols-2 md:grid-cols-4 gap-8">
    <div class="col-span-2 md:col-span-1">
      <p class="text-lg font-bold text-white">MiSaaS<span class="text-blue-500">.</span></p>
      <p class="mt-3 text-sm text-gray-400 leading-relaxed">
        Construye más rápido. Lanza antes. Itera con datos.
      </p>
    </div>
    <div>
      <h4 class="text-sm font-semibold text-white mb-4">Producto</h4>
      <ul class="space-y-2.5 text-sm text-gray-400">
        <li><a href="#" class="hover:text-white transition-colors">Features</a></li>
        <li><a href="#" class="hover:text-white transition-colors">Precios</a></li>
        <li><a href="#" class="hover:text-white transition-colors">Changelog</a></li>
        <li><a href="#" class="hover:text-white transition-colors">Roadmap</a></li>
      </ul>
    </div>
    <div>
      <h4 class="text-sm font-semibold text-white mb-4">Recursos</h4>
      <ul class="space-y-2.5 text-sm text-gray-400">
        <li><a href="#" class="hover:text-white transition-colors">Documentación</a></li>
        <li><a href="#" class="hover:text-white transition-colors">Blog</a></li>
        <li><a href="#" class="hover:text-white transition-colors">Guías</a></li>
        <li><a href="#" class="hover:text-white transition-colors">Soporte</a></li>
      </ul>
    </div>
    <div>
      <h4 class="text-sm font-semibold text-white mb-4">Legal</h4>
      <ul class="space-y-2.5 text-sm text-gray-400">
        <li><a href="#" class="hover:text-white transition-colors">Privacidad</a></li>
        <li><a href="#" class="hover:text-white transition-colors">Términos</a></li>
        <li><a href="#" class="hover:text-white transition-colors">Cookies</a></li>
      </ul>
    </div>
  </div>
  <div class="mx-auto max-w-5xl mt-12 pt-8 border-t border-gray-800 flex flex-col sm:flex-row justify-between items-center gap-4">
    <p class="text-xs text-gray-500">© 2026 MiSaaS. Todos los derechos reservados.</p>
    <div class="flex gap-4 text-gray-500">
      <a href="#" class="hover:text-white transition-colors" aria-label="GitHub">GH</a>
      <a href="#" class="hover:text-white transition-colors" aria-label="Twitter">TW</a>
      <a href="#" class="hover:text-white transition-colors" aria-label="Discord">DC</a>
    </div>
  </div>
</footer>
```

---

## 10. Banner de oferta — Sticky top

```html
<div class="bg-gradient-to-r from-blue-600 to-violet-600 px-4 py-2.5 text-center text-sm text-white">
  <p>
    🎉 <strong>Black Friday</strong>: 50% de descuento con el código
    <code class="rounded bg-white/20 px-1.5 py-0.5 font-mono text-xs">BF2026</code>
    — <a href="#" class="underline underline-offset-2 hover:text-blue-200">Ver oferta →</a>
  </p>
</div>
```

---

## Cómo combinar estos bloques

Para montar una landing completa, apílalos en este orden:

```
1. Banner de oferta (si aplica)
2. Navbar sticky
3. Hero section
4. Stats counter
5. Feature grid
6. Testimonios
7. Pricing table
8. FAQ accordion
9. CTA final
10. Footer
```

Copia cada bloque, pégalos uno debajo del otro, y cambia los textos. En 30 minutos tienes una landing profesional.

## Artículos relacionados

- [Cómo hice mi portfolio con Vite y Tailwind](/blog/como-hice-mi-portfolio-vite-tailwind/) — este mismo portfolio en acción
- [Cursor vs Copilot vs Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/) — herramientas de IA que te ayudan a maquetar más rápido]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Pasé Mi Web de React a Astro: Estos Son los Resultados de Rendimiento (Lighthouse)]]></title>
      <link>https://francobosg.netlify.app/blog/migracion-react-astro-rendimiento-lighthouse-2026/</link>
      <description><![CDATA[Migré una web de React/Next.js a Astro y medí antes y después con Lighthouse. Los números hablan: de 62 a 98 en Performance. Cómo lo hice, qué problemas tuve y si merece la pena.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/migracion-react-astro-rendimiento-lighthouse-2026/</guid>
      <category>Astro</category>
      <category>React</category>
      <category>Código</category>
      <content:encoded><![CDATA[Tenía una web personal hecha con Next.js 14 (App Router, React Server Components, el stack más moderno del momento). Funcionaba bien, se veía bien. Pero cada vez que abría Lighthouse, los números me daban vergüenza:

```
Performance:      62
Accessibility:    89
Best Practices:   95
SEO:              91
```

62 en Performance para un blog con 15 páginas. Un blog. Sin base de datos en tiempo real, sin interactividad compleja, sin nada que justifique esa puntuación.

El problema era claro: **estaba enviando 187KB de JavaScript al navegador para una web que era básicamente texto e imágenes.**

## Los números: antes (React) vs después (Astro)

| Métrica | React/Next.js | Astro | Mejora |
|---------|--------------|-------|--------|
| **Lighthouse Performance** | 62 | 98 | +58% |
| **JavaScript enviado** | 187 KB | 12 KB | -93.6% |
| **First Contentful Paint** | 2.1s | 0.6s | -71% |
| **Largest Contentful Paint** | 3.8s | 1.1s | -71% |
| **Time to Interactive** | 4.2s | 0.8s | -81% |
| **Cumulative Layout Shift** | 0.12 | 0.01 | -92% |
| **Total Blocking Time** | 890ms | 40ms | -95% |
| **Tamaño HTML (home)** | 42 KB | 18 KB | -57% |

No es que Astro sea mágico. Es que React estaba enviando el framework completo, el runtime de React, el runtime de Next.js, los chunks de páginas pre-cargadas, y el JavaScript de hidratación... para una web que no necesitaba nada de eso.

## ¿Por qué mi web React era lenta?

Abrí el Network tab y analicé el JavaScript:

```
react-dom.production.min.js:    42 KB
next/dist/chunks/framework:     38 KB
next/dist/chunks/main:          28 KB
next/dist/chunks/pages/_app:    15 KB
next/dist/chunks/webpack:        8 KB
Página actual + componentes:    56 KB
─────────────────────────────────────
Total:                          187 KB
```

De esos 187 KB, **130 KB eran el framework** (React + Next.js runtime). Solo 56 KB eran mi código real. Y la mayor parte de mi código eran componentes que renderizaban HTML estático — no tenían estado, no tenían efectos, no tenían interactividad.

React necesita ese runtime para la hidratación: recorre todo el DOM, adjunta los event listeners, y sincroniza el HTML del servidor con el virtual DOM del cliente. Para un blog, eso es como usar un camión de 18 ruedas para ir a comprar el pan.

## Cómo hice la migración (paso a paso)

### Paso 1: Crear el proyecto Astro

```bash
npm create astro@latest blog-astro
# Elegí: Empty, TypeScript Strict, Install dependencies
```

### Paso 2: Instalar las integraciones necesarias

```bash
npx astro add tailwind
npx astro add mdx       # Si usas MDX, si es .md puro no hace falta
npx astro add sitemap
```

### Paso 3: Convertir páginas de JSX a .astro

El cambio más grande fue la sintaxis. Astro usa `.astro` files que mezclan frontmatter (lógica del servidor) con HTML:

```tsx
// ❌ Antes (Next.js) — page.tsx
import { getAllPosts } from '@/lib/posts';
import { PostCard } from '@/components/PostCard';

export default async function BlogPage() {
  const posts = await getAllPosts();

  return (
    <main className="max-w-3xl mx-auto px-4 py-12">
      <h1 className="text-4xl font-bold">Blog</h1>
      <div className="mt-8 space-y-6">
        {posts.map(post => (
          <PostCard key={post.slug} post={post} />
        ))}
      </div>
    </main>
  );
}
```

```astro
---
// ✅ Después (Astro) — index.astro
import { getCollection } from 'astro:content';
import BlogLayout from '../layouts/BlogLayout.astro';
import PostCard from '../components/PostCard.astro';

const posts = await getCollection('posts');
const sortedPosts = posts.sort((a, b) =>
  new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
);
---

<BlogLayout title="Blog">
  <h1 class="text-4xl font-bold">Blog</h1>
  <div class="mt-8 space-y-6">
    {sortedPosts.map(post => (
      <PostCard post={post} />
    ))}
  </div>
</BlogLayout>
```

Los cambios principales:
- `className` → `class`
- La lógica va en el frontmatter (`---`)
- No hay `export default function` — el HTML va directamente
- `key` no es necesario en `.astro` (no hay virtual DOM)
- Content Collections reemplazan el fetch de markdown manual

### Paso 4: Convertir componentes

Componentes sin interactividad → `.astro`:

```astro
---
// components/PostCard.astro
const { post } = Astro.props;
const url = `/blog/${post.id.replace(/\.md$/, '')}/`;
---

<article class="border-b border-gray-200 dark:border-gray-800 pb-6">
  <a href={url} class="group">
    <h2 class="text-xl font-semibold group-hover:text-blue-600 transition-colors">
      {post.data.title}
    </h2>
    <p class="mt-2 text-gray-600 dark:text-gray-400">
      {post.data.description}
    </p>
    <time class="mt-2 text-sm text-gray-500">
      {new Date(post.data.date).toLocaleDateString('es-ES')}
    </time>
  </a>
</article>
```

**0 JavaScript enviado al navegador.** Este componente se renderiza en build time como HTML puro.

Componentes CON interactividad → mantuve React con `client:` directive:

```astro
---
// En una página .astro
import SearchBar from '../components/SearchBar.tsx';
---

<!-- Solo este componente envía JavaScript -->
<SearchBar client:load />
```

### Paso 5: Content Collections para los posts

```typescript
// src/content.config.ts
import { defineCollection, z } from 'astro:content';

const posts = defineCollection({
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    image: z.string().optional(),
  }),
});

export const collections = { posts };
```

Los markdowns van en `src/content/posts/` y Astro los valida contra el schema automáticamente.

## Los problemas que tuve

### Problema 1: No existe `useRouter()` en Astro

En React hacía:

```tsx
const router = useRouter();
router.push('/blog');
```

En Astro, los links son `<a href>` normales. Sin client-side routing. Cada clic es una navegación completa del navegador.

"¿Eso no es más lento?" — pensé. Pero no. Con HTML pre-renderizado, la navegación del navegador es **más rápida** que el client-side routing de Next.js porque no hay JavaScript que parsear y ejecutar. El HTML llega y se pinta. Punto.

Para las pocas transiciones que quería animar, usé la View Transitions API de Astro:

```astro
---
// En el layout
import { ViewTransitions } from 'astro:transitions';
---

<head>
  <ViewTransitions />
</head>
```

Una línea y tienes transiciones animadas entre páginas. Sin Framer Motion, sin AnimatePresence, sin 32KB de JavaScript.

### Problema 2: El search necesitaba JavaScript

Mi barra de búsqueda era un componente React con estado. En Astro lo mantuve como isla de React:

```astro
<SearchBar client:idle />
```

`client:idle` carga el JavaScript cuando el navegador está idle. El usuario ve la página inmediatamente, y cuando el navegador no tiene nada que hacer, carga el search. Sin bloquear el render.

### Problema 3: Dark mode con FOUC

Astro genera HTML estático, así que si pones la clase `dark` en `<html>` con JavaScript del cliente, hay un flash de tema claro antes de que el script se ejecute.

Solución: script inline en el `<head>` que se ejecuta antes del render:

```astro
<head>
  <script is:inline>
    if (localStorage.getItem('color-theme') === 'dark' ||
        (!localStorage.getItem('color-theme') &&
         window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  </script>
</head>
```

`is:inline` en Astro evita que el script se bundlee y lo ejecuta inmediatamente.

## ¿Merece la pena migrar?

**Sí, si tu web es principalmente contenido** (blog, docs, portfolio, landing page). Los números hablan solos:

- 98 en Lighthouse vs 62
- 12 KB de JS vs 187 KB
- 0.6s FCP vs 2.1s

**No, si tu web es una app interactiva** (dashboard, SaaS con mucho estado, editor visual). Astro no está diseñado para apps con mucha interactividad — para eso sigue con Next.js o Remix.

**La zona gris**: Webs que son 80% contenido + 20% interactividad (un blog con un buscador, un portfolio con un formulario de contacto). Ahí Astro con islas de React es perfecto — HTML estático para todo, React solo para los 2-3 componentes que lo necesitan.

Mi portfolio actual usa exactamente eso: Astro para todo el contenido estático, y las pocas piezas interactivas son vanilla JS con `is:inline`. Zero framework runtime en el navegador.

## Artículos relacionados

- [Cómo hice mi portfolio con Vite y Tailwind](/blog/como-hice-mi-portfolio-vite-tailwind/) — el portfolio que migré
- [Deploy gratis con GitHub Actions + Netlify](/blog/deploy-gratis-github-actions-netlify-vercel-2026/) — cómo despliego Astro
- [Cheat sheet de CSS Grid y Flexbox](/blog/cheat-sheet-css-grid-flexbox-2026/) — los layouts que uso en Astro sin JavaScript]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Por Qué Dejé de Usar Redux en 2026 (Y Qué Uso Ahora)]]></title>
      <link>https://francobosg.netlify.app/blog/por-que-deje-redux-alternativas-2026/</link>
      <description><![CDATA[Después de 4 años usando Redux en todos mis proyectos React, lo eliminé. Por qué Redux ya no tiene sentido para la mayoría de apps, y las alternativas que uso ahora con ejemplos reales.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/por-que-deje-redux-alternativas-2026/</guid>
      <category>React</category>
      <category>Arquitectura</category>
      <category>Código</category>
      <content:encoded><![CDATA[Mi primer proyecto React serio tenía Redux. Mi segundo también. Y el tercero. Redux era la respuesta a todo: ¿necesitas estado global? Redux. ¿Fetch de datos? Redux + Thunk. ¿Formularios? Redux Form. ¿El estado del modal? Redux.

Tenía un `store/` con 47 archivos entre slices, thunks, selectors, types y tests. **Para una app de gestión de tareas.**

Un día me senté a contar las líneas de código:

```
Lógica de negocio real:          2.400 líneas
Boilerplate de Redux:            3.100 líneas
Ratio señal/ruido:               43% señal, 57% ruido
```

Más de la mitad del código era fontanería de Redux. Ese día empecé la migración.

## Qué me hizo abandonar Redux

### 1. El boilerplate no ha mejorado (aunque Redux Toolkit ayuda)

Redux Toolkit redujo mucho el boilerplate comparado con Redux vanilla. Pero sigue siendo demasiado:

```typescript
// Para hacer un CRUD de tareas necesitas:

// 1. El slice (taskSlice.ts)
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const fetchTasks = createAsyncThunk(
  'tasks/fetchAll',
  async (_, { rejectWithValue }) => {
    try {
      const response = await fetch('/api/tasks');
      return response.json();
    } catch (err) {
      return rejectWithValue(err.message);
    }
  }
);

const taskSlice = createSlice({
  name: 'tasks',
  initialState: { items: [], loading: false, error: null },
  reducers: {
    clearError: (state) => { state.error = null; },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchTasks.pending, (state) => { state.loading = true; })
      .addCase(fetchTasks.fulfilled, (state, action) => {
        state.loading = false;
        state.items = action.payload;
      })
      .addCase(fetchTasks.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload;
      });
  },
});

// 2. El store (store.ts)
// 3. Los selectors (taskSelectors.ts)
// 4. El Provider en el layout
// 5. Los types de RootState y AppDispatch
// 6. Los hooks tipados useAppSelector/useAppDispatch
```

~80 líneas de código para... hacer un fetch y guardar el resultado.

### 2. El 90% del "estado global" no necesita ser global

Hice una auditoría de mi store de Redux:

| Dato en el store | ¿Realmente necesita ser global? |
|-----------------|-------------------------------|
| Lista de tareas | No — solo la usa una página |
| Usuario logueado | Sí — se usa en muchos sitios |
| Estado de un modal | No — es local del componente |
| Datos de un formulario | No — es local del formulario |
| Filtros de búsqueda | No — pertenece a la URL |
| Tema dark/light | Sí — se usa globalmente |
| Notificaciones | Quizás — un Context bastaría |

De 12 slices en mi store, solo 2 necesitaban ser realmente globales. El resto era estado local o estado del servidor que debería venir de una caché de queries.

### 3. React Server Components cambiaron las reglas

Con Next.js App Router y React Server Components, muchos datos ya no pasan por el cliente:

```tsx
// Antes (con Redux): fetch en el cliente → store → componente
// Ahora (con RSC): fetch en el servidor → componente directamente

// Este componente NO necesita Redux. Los datos llegan del servidor.
export default async function TaskList() {
  const tasks = await db.task.findMany({ 
    where: { userId: session.user.id } 
  });

  return (
    <ul>
      {tasks.map(task => <TaskItem key={task.id} task={task} />)}
    </ul>
  );
}
```

No hay loading state. No hay error state. No hay cache invalidation. El servidor hizo el fetch, renderizó el HTML, y el cliente lo recibe listo.

## Qué uso ahora (y por qué)

### Para datos del servidor: TanStack Query

```typescript
// Antes: 80 líneas de Redux para un fetch
// Ahora: 5 líneas con TanStack Query

function TaskList() {
  const { data: tasks, isLoading, error } = useQuery({
    queryKey: ['tasks'],
    queryFn: () => fetch('/api/tasks').then(r => r.json()),
  });

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <ul>{tasks.map(t => <TaskItem key={t.id} task={t} />)}</ul>;
}
```

TanStack Query te da gratis: caché, deduplicación, refetch automático, optimistic updates, retry, y stale-while-revalidate. Todo lo que en Redux tenías que implementar a mano.

### Para estado global simple: Zustand

```typescript
// Antes: slice + store + provider + selectors + hooks tipados
// Ahora: un archivo de 10 líneas

import { create } from 'zustand';

interface AppStore {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
  user: User | null;
  setUser: (user: User | null) => void;
}

export const useAppStore = create<AppStore>((set) => ({
  theme: 'dark',
  toggleTheme: () => set((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' })),
  user: null,
  setUser: (user) => set({ user }),
}));

// Uso: const theme = useAppStore(s => s.theme);
```

Sin Provider, sin boilerplate, con selectores automáticos, tipado perfecto. Para el 95% de necesidades de estado global, Zustand es todo lo que necesitas.

### Para formularios: React Hook Form

```typescript
// Antes: Redux Form (2.000 líneas de código para 5 formularios)
// Ahora: React Hook Form

const { register, handleSubmit, formState: { errors } } = useForm<TaskForm>();

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <input {...register('title', { required: 'El título es obligatorio' })} />
    {errors.title && <span>{errors.title.message}</span>}
    <button type="submit">Crear</button>
  </form>
);
```

### Para estado en la URL: nuqs (o useSearchParams)

Los filtros de búsqueda, la paginación, los tabs activos... todo eso pertenece a la URL, no al store:

```typescript
import { useQueryState } from 'nuqs';

function TaskFilters() {
  const [status, setStatus] = useQueryState('status', { defaultValue: 'all' });
  const [page, setPage] = useQueryState('page', { defaultValue: '1' });

  // URL: /tasks?status=pending&page=2
  // Compartible, bookmarkeable, persistente al refrescar
}
```

## La migración: Cómo eliminé Redux paso a paso

No lo hice de golpe. Fui slice por slice en 2 semanas:

### Semana 1: Datos del servidor → TanStack Query

```bash
npm install @tanstack/react-query
```

1. Identifiqué qué slices eran "estado del servidor" (datos que vienen de una API)
2. Creé hooks con `useQuery` y `useMutation` para cada uno
3. Eliminé los slices, thunks y selectors correspondientes
4. **Resultado**: -1.800 líneas de código, misma funcionalidad

### Semana 2: Estado local + global → Zustand + estado local

1. Los modals, dropdowns y UI state → `useState` local en cada componente
2. El estado genuinamente global (tema, user, notificaciones) → un store de Zustand de 25 líneas
3. Los filtros de URL → `useSearchParams`
4. **Resultado**: -1.300 líneas más

### Total

```
Antes:  3.100 líneas de Redux
Después: 180 líneas (Zustand store + custom hooks de TanStack Query)
Reducción: 94% menos código de gestión de estado
```

Y la app funciona igual. Mejor, de hecho, porque TanStack Query gestiona la caché mucho mejor que mi implementación manual con Redux.

## ¿Cuándo SÍ tiene sentido Redux?

No voy a decir "Redux nunca". Hay casos donde sigue siendo la mejor opción:

- **Editores visuales** (tipo Figma, Photoshop web): Undo/redo nativo con Redux + Immer
- **Apps offline-first**: Redux Persist + middleware de sincronización
- **Estado compartido entre muchos componentes con lógica compleja**: Middleware, sagas, epics
- **Equipos grandes** que necesitan una arquitectura predecible y estandarizada

Si estás en alguno de estos casos, Redux Toolkit sigue siendo sólido. Para todo lo demás, hay alternativas más ligeras.

## Resumen: Mi stack de estado en 2026

| Necesidad | Solución | Líneas de setup |
|-----------|----------|----------------|
| Datos de API | TanStack Query | 5 por query |
| Estado global | Zustand | 20-30 (un store) |
| Formularios | React Hook Form | 0 (por form) |
| Estado de URL | nuqs | 3 por param |
| Estado local | useState/useReducer | 1-5 |
| **Total** | | **~60 líneas** |

Vs las **3.100 líneas** que tenía con Redux. No hay comparación.

## Artículos relacionados

- [Cómo estructuro mis carpetas en React/Next.js](/blog/estructura-carpetas-react-nextjs-2026/) — organización sin Redux
- [Caso real: SaaS con NestJS y React](/blog/caso-real-saas-atrapaclientes-nestjs-react/) — un SaaS sin Redux
- [Testear código generado por IA](/blog/testear-codigo-generado-ia-copilot-cursor-2026/) — testing de hooks de estado]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Supabase vs Firebase en 2026: Mi Experiencia Real en Producción Tras 1 Año]]></title>
      <link>https://francobosg.netlify.app/blog/supabase-vs-firebase-experiencia-produccion-2026/</link>
      <description><![CDATA[Comparativa honesta entre Supabase y Firebase después de usar ambos en producción. Costes reales, caídas, límites ocultos, vendor lock-in y cuál elegiría hoy para un SaaS.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/supabase-vs-firebase-experiencia-produccion-2026/</guid>
      <category>Arquitectura</category>
      <category>Backend</category>
      <category>Caso Real</category>
      <content:encoded><![CDATA[Voy a ser directo: **usé Firebase durante 2 años para un SaaS y lo migré a Supabase**. No porque Firebase sea malo — es una herramienta brutal — sino porque me encontré con problemas que la documentación oficial no menciona y que ninguna IA te va a contar porque no ha pasado por la experiencia.

Este artículo no es la típica comparativa de "ambos son buenos dependiendo de tu caso de uso". Es lo que me pasó a mí, con números reales.

## El contexto: Qué construí con cada uno

**Con Firebase (2023-2025)**: Un SaaS de gestión de reuniones con IA. ~2.000 usuarios activos, ~500 reuniones/día, almacenamiento de transcripciones.

**Con Supabase (2025-2026)**: Migré ese mismo SaaS + lancé [AtrapaClientes](/blog/caso-real-saas-atrapaclientes-nestjs-react/), un CRM con dashboard, webhooks y API pública.

## Lo que nadie te cuenta de Firebase

### La factura de Firestore es impredecible

Firebase cobra por **número de lecturas de documentos**, no por volumen de datos. Esto suena razonable hasta que haces una query que lee 500 documentos para mostrar una tabla con paginación.

Mi primer mes en producción real:

| Concepto | Estimado | Real |
|----------|----------|------|
| Lecturas Firestore | 1M | **4.2M** |
| Escrituras Firestore | 200K | 180K |
| Storage | 2 GB | 1.8 GB |
| **Factura** | **~$15** | **~$47** |

¿Por qué 4x más lecturas de las esperadas? Porque cada vez que un listener de Firestore se reconecta (cambio de pestaña, pérdida de conexión), **re-lee todos los documentos** del snapshot. Con 500 usuarios activos, eso genera miles de lecturas fantasma que no ves en tu código.

### Security Rules son un lenguaje de programación oculto

Las Firebase Security Rules parecen simples:

```
match /meetings/{meetingId} {
  allow read: if request.auth.uid in resource.data.members;
}
```

Hasta que necesitas validar datos complejos, hacer joins entre colecciones o verificar roles. Entonces te encuentras escribiendo un lenguaje de programación sin tipado, sin debugging y sin tests unitarios fáciles.

Después de romper producción 2 veces por una Security Rule mal escrita, dejé de confiar en ellas para lógica de negocio.

### Vendor lock-in real

Migrar de Firestore no es "exportar un JSON". El modelo de datos de Firestore (documentos anidados, subcollections, references) **no tiene equivalente directo en SQL ni en MongoDB**. Tuve que repensar toda la estructura de datos para migrar a PostgreSQL.

```
// Firestore: datos anidados en subcollections
/users/{userId}/meetings/{meetingId}/transcriptions/{transId}

// PostgreSQL: relaciones con foreign keys
users → meetings (user_id FK) → transcriptions (meeting_id FK)
```

La migración de datos me llevó **3 semanas**. Si lo hubiera empezado con PostgreSQL, habría sido 0.

## Lo que nadie te cuenta de Supabase

### PostgreSQL es increíble... pero necesitas saber SQL

Con Firebase, haces `collection('users').where('role', '==', 'admin').get()` y ya. Con Supabase tienes todo el poder de SQL, pero eso significa que necesitas saber SQL.

```sql
-- Esto que en Firestore es trivial...
-- collection('meetings').where('date', '>=', today).orderBy('date')

-- En Supabase requiere entender queries:
SELECT m.*, u.name as organizer_name
FROM meetings m
JOIN users u ON m.organizer_id = u.id
WHERE m.date >= CURRENT_DATE
ORDER BY m.date ASC
LIMIT 20 OFFSET 0;
```

La ventaja: una vez que sabes SQL, puedes hacer **cualquier cosa**. Joins, aggregations, window functions, CTEs. Firestore no puede hacer un JOIN ni pagando.

### Row Level Security (RLS) es mejor... pero más complejo

Las políticas RLS de Supabase son SQL estándar:

```sql
CREATE POLICY "Users can see their own meetings"
ON meetings
FOR SELECT
USING (auth.uid() = organizer_id OR auth.uid() = ANY(member_ids));
```

Ventajas sobre Firebase Security Rules:
- SQL estándar (no un lenguaje inventado)
- Se pueden testear con queries normales
- Soportan joins y subqueries

Desventaja: si te olvidas de activar RLS en una tabla, **todos los datos son públicos por defecto**. Me pasó en desarrollo (gracias a Dios no en producción) con la tabla de `api_keys`. Supabase te avisa con un warning, pero si lo ignoras...

### El tier gratuito se te queda corto rápido

| Recurso | Free tier | Mi uso real (mes 3) |
|---------|-----------|---------------------|
| Base de datos | 500 MB | 380 MB (bien) |
| Storage | 1 GB | 2.1 GB (superado) |
| Edge Functions | 500K invocaciones | 1.2M (superado) |
| Bandwidth | 2 GB | 4.5 GB (superado) |
| **Coste Pro** | $0 | **$25 + $12 extras** |

Con el plan Pro ($25/mes) tienes 8 GB de DB, 100 GB de storage y 2M edge functions. Para un SaaS con <5.000 usuarios es suficiente.

### Realtime tiene límites

Supabase Realtime (equivalente a los listeners de Firestore) funciona bien para ~100 conexiones simultáneas. A partir de ahí necesitas el add-on de Realtime ($0.0015/mensaje). Firebase Realtime Database maneja mejor las conexiones masivas out-of-the-box.

## Comparativa directa

| Aspecto | Firebase | Supabase | Ganador |
|---------|----------|----------|---------|
| **Setup inicial** | 5 min (consola web) | 5 min (consola web) | Empate |
| **Curva de aprendizaje** | Baja (NoSQL, SDK sencillo) | Media (SQL, RLS) | Firebase |
| **Base de datos** | Firestore (documento) | PostgreSQL (relacional) | **Supabase** |
| **Auth** | Excelente (muchos proveedores) | Muy bueno (GoTrue) | Firebase (por poco) |
| **Storage** | GCS integrado | S3-compatible | Empate |
| **Realtime** | Excelente | Bueno (límites) | Firebase |
| **Edge Functions** | Cloud Functions (Node) | Deno (edge) | Depende |
| **Vendor lock-in** | **Alto** | **Bajo** (SQL estándar) | **Supabase** |
| **Precio predecible** | No (lecturas variables) | Sí (plan fijo) | **Supabase** |
| **SQL/Joins** | No | Sí | **Supabase** |
| **Self-hosting** | No | Sí (Docker) | **Supabase** |
| **Comunidad/Docs** | Enorme (10+ años) | Grande y creciendo | Firebase |

## Costes reales: Mes a mes para una app con ~2.000 usuarios

### Firebase

| Mes | Lecturas | Storage | Functions | Total |
|-----|----------|---------|-----------|-------|
| 1 | $12 | $1 | $5 | **$18** |
| 3 | $28 | $3 | $12 | **$43** |
| 6 | $47 | $5 | $18 | **$70** |
| 12 | $65 | $8 | $25 | **$98** |

El problema: las lecturas crecen exponencialmente con los usuarios porque cada interacción genera más lecturas.

### Supabase

| Mes | Plan | Extras | Total |
|-----|------|--------|-------|
| 1 | Free | $0 | **$0** |
| 3 | Pro $25 | $5 (storage) | **$30** |
| 6 | Pro $25 | $10 | **$35** |
| 12 | Pro $25 | $15 | **$40** |

Supabase escala mejor en coste porque PostgreSQL no cobra por lecturas — cobra por almacenamiento y compute.

## ¿Cuál elegiría hoy?

**Para un MVP que quiero lanzar en 1 semana**: Firebase. El SDK es más rápido de integrar y la curva de aprendizaje es menor.

**Para cualquier proyecto que vaya a durar más de 3 meses**: Supabase. PostgreSQL es una base de datos seria que no te va a limitar cuando necesites hacer queries complejas, y el vendor lock-in es mínimo.

**Para un SaaS con suscripciones y facturación**: Supabase sin duda. Necesitas JOINs para reportes, transacciones ACID para pagos, y la capacidad de migrar si Supabase cierra o sube precios.

> Si quieres ver cómo estructuré un SaaS con PostgreSQL, lee mi [caso real de AtrapaClientes con NestJS y React](/blog/caso-real-saas-atrapaclientes-nestjs-react/).

## Cómo migré de Firebase a Supabase

### 1. Auth (1 día)

```bash
# Exportar usuarios de Firebase
firebase auth:export users.json --format=json

# Script para importar a Supabase (simplificado)
node migrate-auth.js
```

Firebase permite exportar los hashes de contraseñas, así que los usuarios no tienen que hacer "olvidé mi contraseña" después de la migración.

### 2. Datos (2 semanas)

```javascript
// Script de migración Firestore → PostgreSQL
const meetings = await db.collection('meetings').get();

for (const doc of meetings.docs) {
  const data = doc.data();
  
  await supabase.from('meetings').insert({
    id: doc.id,
    title: data.title,
    organizer_id: data.organizerId,
    date: data.date.toDate(),
    // Flatten subcollections into related tables
    member_ids: data.members || [],
  });
  
  // Migrar subcollection de transcripciones
  const transcriptions = await doc.ref.collection('transcriptions').get();
  for (const trans of transcriptions.docs) {
    await supabase.from('transcriptions').insert({
      meeting_id: doc.id,
      content: trans.data().text,
      created_at: trans.data().createdAt.toDate(),
    });
  }
}
```

### 3. Storage (3 días)

```bash
# Descargar de Firebase Storage
gsutil -m cp -r gs://my-project.appspot.com/ ./backup/

# Subir a Supabase Storage
# (usé un script con el SDK de Supabase)
```

### 4. Reescribir queries (1 semana)

De `collection().where().get()` a queries SQL con el SDK de Supabase. Esta fue la parte más lenta pero también la más satisfactoria — las queries SQL son más claras y potentes.

## Conclusión honesta

Firebase es un producto excelente para prototipar rápido. Pero cuando tu app crece, la factura se descontrola, las Security Rules se vuelven inmanejables, y el vendor lock-in te atrapa.

Supabase requiere más conocimiento inicial (SQL, RLS), pero a cambio te da un stack estándar que puedes migrar a cualquier PostgreSQL del mundo si lo necesitas.

**Mi regla**: si el proyecto va a durar más de un sprint, empieza con Supabase. Tu yo del futuro te lo agradecerá.

## Artículos relacionados

- [Caso real: SaaS con NestJS y React](/blog/caso-real-saas-atrapaclientes-nestjs-react/) — cómo construí AtrapaClientes sobre PostgreSQL
- [Caso real: IA para reuniones con Gemini y Supabase](/blog/caso-real-ia-reuniones-gemini-supabase/) — el proyecto que empecé en Firebase
- [Deploy gratis con GitHub Actions](/blog/deploy-gratis-github-actions-netlify-vercel-2026/) — cómo desplegar sin depender de una sola plataforma
- [Proteger tu API con JWT](/blog/proteger-api-nodejs-jwt-auth-guia-2026/) — auth hecha a mano vs BaaS]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Vercel vs VPS: Cuánto Cuesta Realmente Mantener una App Next.js en 2026]]></title>
      <link>https://francobosg.netlify.app/blog/vercel-vs-vps-coste-real-nextjs-2026/</link>
      <description><![CDATA[Comparo la factura real de Vercel Pro vs un VPS en Hetzner para una app Next.js con tráfico real. Incluye costes ocultos, escenarios de factura sorpresa y cuándo merece la pena cada opción.]]></description>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/vercel-vs-vps-coste-real-nextjs-2026/</guid>
      <category>Arquitectura</category>
      <category>Full-Stack</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Desplegué mi primera app Next.js en Vercel en 2024. `git push`, URL en 30 segundos, SSL automático. Mágico.

Tres meses después llegó la primera factura que no esperaba: **$87**. Mi app tenía 12.000 visitas al mes. ¿Cómo es posible?

Desde entonces he mantenido la misma app en Vercel Y en un VPS de Hetzner en paralelo durante 6 meses para comparar. Aquí van los números reales.

## La app: Qué estoy desplegando

- **Framework**: Next.js 15 (App Router, Server Components)
- **Base de datos**: PostgreSQL en Supabase (externo)
- **Auth**: Auth.js con Google + GitHub
- **API Routes**: 15 endpoints serverless
- **ISR**: Páginas estáticas regeneradas cada 60s
- **Tráfico**: ~15.000 visitas/mes, ~45.000 page views
- **Imágenes**: Servidas desde Cloudflare R2

## Vercel Pro: La factura desglosada

### Plan Pro: $20/mes base

Lo que incluye:

| Recurso | Incluido | Mi uso real |
|---------|----------|-------------|
| Bandwidth | 1 TB | ~8 GB |
| Serverless invocaciones | 1M | ~180K |
| Edge Middleware invocaciones | 1M | ~45K |
| ISR regeneraciones | 100K | ~22K |
| Image Optimization | 5.000 | ~3.200 |
| Build minutes | 6.000 min | ~120 min |

A primera vista, todo cabe. Pero hay extras:

### Los costes ocultos

**Image Optimization**: Las 5.000 imágenes optimizadas del plan Pro se refieren a **imágenes únicas procesadas**, no a veces servidas. Con un blog que tiene 30 posts con 3 imágenes cada uno = 90 imágenes únicas × varios tamaños (srcset) = ~450 imágenes. Bien.

Pero si usas `next/image` con imágenes de usuario (avatares, uploads), cada imagen nueva cuenta. Con 2.000 usuarios subiendo fotos de perfil... llegas rápido a 5.000.

**Serverless Function Duration**: El plan Pro incluye 1.000 GB-horas. Cada invocación × su duración × su memoria. Si tienes una API route que tarda 3 segundos (porque llama a una IA), eso come rápido:

```
180.000 invocaciones × 0.5s promedio × 1GB memoria = 25 GB-horas
(bien, dentro del límite)

Pero si una función tarda 3s (llamada a OpenAI):
50.000 invocaciones × 3s × 1GB = 41.6 GB-horas
(todavía bien, pero se nota)
```

**Mi factura real de Vercel en 6 meses**:

| Mes | Base | Extras | Total |
|-----|------|--------|-------|
| 1 | $20 | $0 | $20 |
| 2 | $20 | $0 | $20 |
| 3 | $20 | $12 (imágenes) | $32 |
| 4 | $20 | $8 | $28 |
| 5 | $20 | $15 | $35 |
| 6 | $20 | $22 (pico tráfico) | $42 |
| **Total** | | | **$177** |
| **Media** | | | **$29.50/mes** |

### El escenario de pesadilla: La factura sorpresa

Un post de mi blog se compartió en Hacker News. En 24 horas:
- 45.000 visitas (vs las 500/día normales)
- 890.000 serverless invocations
- 95 GB de bandwidth

Con el plan Pro, la factura de ese mes: **$87**. No fue dramático porque el post no siguió viral, pero si hubiera sido una semana entera, habría sido $200+.

> Vercel tiene un "Spend Limit" que puedes configurar para que tu app deje de funcionar si llegas a un límite. Pero tu app **deja de funcionar**. Si es un SaaS de pago, eso no es aceptable.

## VPS (Hetzner): La factura desglosada

### Setup

| Recurso | Especificaciones | Precio |
|---------|-----------------|--------|
| VPS CX22 | 2 vCPU, 4 GB RAM, 40 GB SSD | **4,50€/mes** |
| IPv4 dedicada | (incluida) | 0€ |
| Backups automáticos | 20% del precio del VPS | 0,90€/mes |
| **Total** | | **5,40€/mes (~$5.80)** |

Sí. **$5.80 al mes**. Independientemente del tráfico.

### Lo que tuve que configurar

```bash
# 1. Ubuntu 24.04 + Docker
apt update && apt install docker.io docker-compose-v2 nginx certbot

# 2. Dockerfile para Next.js
# (Next.js tiene un Dockerfile oficial en su repo)

# 3. Nginx como reverse proxy
server {
    listen 443 ssl http2;
    server_name miapp.com;
    
    ssl_certificate /etc/letsencrypt/live/miapp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/miapp.com/privkey.pem;
    
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

# 4. Certbot para SSL automático
certbot --nginx -d miapp.com

# 5. GitHub Actions para deploy automático
# (git push → build → docker push → restart en el VPS)
```

**Tiempo de setup inicial**: ~3 horas la primera vez. Después tengo un script que lo replica en 15 minutos.

### Mi factura real del VPS en 6 meses

| Mes | VPS | Extras | Total |
|-----|-----|--------|-------|
| 1-6 | 5,40€ | 0€ | 5,40€ |
| **Total** | | | **32,40€ (~$35)** |
| **Media** | | | **$5.80/mes** |

Cuando el post se viralizó en HN, ¿qué pasó con el VPS? Nada. La CPU subió al 60%, Nginx sirvió páginas cacheadas, y la factura fue... $5.80. Como siempre.

## Comparativa directa

| Aspecto | Vercel Pro | VPS Hetzner |
|---------|-----------|-------------|
| **Precio base** | $20/mes | ~$5.80/mes |
| **Precio real (mi caso)** | ~$29.50/mes | ~$5.80/mes |
| **Setup** | 0 minutos | 3 horas |
| **Deploy** | git push | git push (con CI/CD) |
| **SSL** | Automático | Certbot (automático tras setup) |
| **CDN** | Incluido (Edge Network) | Necesitas Cloudflare (gratis) |
| **Escalado** | Automático | Manual (resize VPS) |
| **Serverless Functions** | Nativas | No (usas Node.js normal) |
| **Image Optimization** | Incluida | Necesitas sharp + cache |
| **Factura sorpresa** | Posible | Imposible |
| **Mantenimiento** | 0 | ~1h/mes (updates) |
| **ISR/SSR** | Nativo | Funciona con `next start` |
| **Monitoring** | Dashboard incluido | Necesitas Uptime Kuma/similar |

## Cuándo elegir Vercel

- **Equipo sin DevOps**: Si nadie en tu equipo sabe configurar un servidor, Vercel vale la pena
- **Preview Deployments**: Cada PR genera una URL de preview → genial para equipos
- **Edge Functions**: Si necesitas lógica en el edge (geolocalización, A/B testing)
- **Presupuesto > $30/mes es aceptable**: Para empresas que facturan, $30/mes es nada
- **MVP que necesitas YA**: Deploy en 30 segundos vs 3 horas de setup

## Cuándo elegir un VPS

- **Presupuesto ajustado**: $5.80 vs $30/mes es una diferencia del 80%
- **Tráfico impredecible**: Un VPS no te penaliza por ser viral
- **Control total**: Logs, SSH, cron jobs, lo que necesites
- **Multi-app**: En un VPS de $5.80 puedo correr 3-4 apps a la vez
- **Aprendizaje**: Montar un servidor te enseña cosas que Vercel te oculta

## Mi setup actual (lo mejor de ambos mundos)

```
Blog/Portfolio (este sitio) → Netlify (gratis, estático)
SaaS frontend (Next.js) → VPS Hetzner ($5.80/mes)
SaaS API (NestJS) → Mismo VPS
Base de datos → Supabase ($25/mes)
CDN/Caché → Cloudflare (gratis)
CI/CD → GitHub Actions (gratis)
Monitoring → Uptime Kuma (self-hosted en el mismo VPS)
```

**Total: ~$31/mes** para un stack completo de producción. Con Vercel sería ~$55/mes para lo mismo.

> Si quieres configurar el deploy automático con GitHub Actions, lee mi [guía de deploy gratis](/blog/deploy-gratis-github-actions-netlify-vercel-2026/).

## El elefante en la habitación: Next.js sin Vercel

Next.js es propiedad de Vercel. Las features más nuevas (Partial Prerendering, Server Actions optimizados) funcionan mejor en Vercel. En un VPS con `next start`, todo funciona, pero sin las optimizaciones de edge.

Alternativas si esto te preocupa:

- **Astro**: Lo uso para este blog. Genera HTML estático, funciona en cualquier hosting
- **Remix**: Funciona en cualquier servidor Node.js sin preferencia por un hosting
- **SvelteKit**: Idem, adapter para cualquier plataforma

Si estás empezando un proyecto nuevo y no quieres depender de Vercel, considera estas opciones. Si ya tienes una app Next.js, funciona perfectamente en un VPS — solo te pierdes las optimizaciones de edge.

## Artículos relacionados

- [Docker para developers: Guía práctica](/blog/docker-para-developers-guia-practica-2026/) — lo que necesitas para el VPS
- [Deploy gratis con GitHub Actions](/blog/deploy-gratis-github-actions-netlify-vercel-2026/) — CI/CD para tu VPS
- [Supabase vs Firebase en producción](/blog/supabase-vs-firebase-experiencia-produccion-2026/) — la base de datos que va con ambos
- [Cómo hice mi portfolio con Vite y Tailwind](/blog/como-hice-mi-portfolio-vite-tailwind/) — un ejemplo de hosting estático gratis]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Aider: Programa con IA desde la Terminal (Alternativa Barata a Cursor)]]></title>
      <link>https://francobosg.netlify.app/blog/aider-ia-terminal-barata-alternativa-cursor-2026/</link>
      <description><![CDATA[Tutorial de Aider en español. Instala, configura y usa este asistente de código IA en terminal. Más barato que Cursor, compatible con cualquier modelo y con Git integrado.]]></description>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/aider-ia-terminal-barata-alternativa-cursor-2026/</guid>
      <category>IA</category>
      <category>Herramientas</category>
      <category>Tutorial</category>
      <category>Código</category>
      <content:encoded><![CDATA[Cursor cuesta $20/mes. GitHub Copilot otros $10. Si los dos te parecen caros o se te agotan las peticiones premium, hay una alternativa open source que usa tu propia API key: **Aider**.

Funciona desde la terminal, edita archivos directamente, hace auto-commits en Git y soporta cualquier modelo de IA. Y pagas solo por los tokens que consumes.

## ¿Por qué Aider en vez de un IDE con IA?

| Característica | Aider | Cursor | Copilot |
|---------------|-------|--------|---------|
| **Precio** | Tu API key (pago por uso) | $20/mes (Pro) | $10/mes |
| **Modelo** | Cualquiera (OpenAI, Claude, Gemini, Ollama, DeepSeek) | Limitado al plan | GPT-4o + Claude |
| **Funciona por SSH** | ✅ Perfecto | ❌ No | ⚠️ Parcial |
| **Git integrado** | ✅ Auto-commit cada cambio | ❌ Manual | ❌ Manual |
| **Consumo de tokens** | Optimizado (repo map) | Variable | Variable |
| **Límite de peticiones** | Sin límite (tu API key) | Cuota mensual | Cuota mensual |
| **Open source** | ✅ 100% | ❌ | ❌ |

**El caso de uso ideal de Aider**: eres un dev que programa mucho con IA y se te acaba la cuota de Cursor a mitad de mes. O trabajas por SSH en servidores y no puedes usar un IDE con IA.

Para una comparativa más detallada de editores, lee [Cursor vs Copilot vs Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/).

## Instalación

### Requisitos

- Python 3.10+
- Git
- Una API key (OpenAI, Anthropic o modelo local con Ollama)

### Instalar Aider

```bash
# Instalar via pip
pip install aider-chat

# O con pipx (recomendado — no contamina tu entorno)
pipx install aider-chat

# Verificar
aider --version
```

### Configurar tu API key

```bash
# Para OpenAI
export OPENAI_API_KEY=sk-...

# Para Anthropic (Claude)
export ANTHROPIC_API_KEY=sk-ant-...

# Windows PowerShell
$env:OPENAI_API_KEY = "sk-..."
```

## Tu primera sesión con Aider

```bash
# Entra al directorio de tu proyecto
cd mi-proyecto

# Iniciar Aider con GPT-4.1 (buen balance precio/calidad)
aider --model gpt-4.1

# Se abre una sesión interactiva
# ──────────────────────────
# Aider v0.82.0
# Model: gpt-4.1 with diff edit format
# Git repo: .git with 47 files
# Repo-map: using 1024 tokens
# ──────────────────────────
```

### Comandos esenciales

```bash
# Añadir archivos al contexto
/add src/auth/login.ts src/auth/types.ts

# Pedir un cambio (Aider edita los archivos directamente)
> Añade validación de email en el login antes de llamar a la API

# Aider edita login.ts, muestra el diff y hace auto-commit

# Deshacer el último cambio
/undo

# Ver qué archivos están en contexto
/ls

# Cambiar de modelo en caliente
/model claude-sonnet-4

# Salir
/exit
```

## Configuración optimizada para ahorrar tokens

### 1. Limitar el repo map

Aider genera un "mapa" de tu repositorio (funciones, clases, exports) para que la IA entienda la estructura sin enviar todo el código. Limita los tokens del mapa:

```bash
# Por defecto usa ~1024 tokens de mapa. Para proyectos grandes:
aider --map-tokens 512

# Para proyectos pequeños, puedes desactivarlo
aider --map-tokens 0
```

### 2. Añadir solo los archivos necesarios

```bash
# ❌ No hagas esto (envía todo al contexto)
aider --model gpt-4.1 .

# ✅ Añade solo lo que vas a editar
aider --model gpt-4.1
/add src/auth/login.ts
/add src/auth/middleware.ts
```

### 3. Usar modelos diferentes por tarea

```bash
# Tarea simple → modelo barato
aider --model gpt-4.1-nano
> genera tests unitarios para esta función

# Tarea compleja → modelo potente
aider --model claude-sonnet-4
> refactora este módulo para soportar multi-tenencia
```

### 4. Combinar con caveman prompting

Los prompts que escribes en Aider también consumen tokens. Usa [caveman prompting](/blog/caveman-prompting-ahorrar-tokens-ia-2026/):

```bash
# ❌ Prompt largo
> Por favor, podrías crear una función que reciba un array de usuarios 
> y devuelva solo los que tienen el campo "activo" en true, ordenados 
> por fecha de registro de más reciente a más antiguo?

# ✅ Caveman
> fn: filtrar usuarios activos, ordenar por fecha desc
```

## Usar Aider con modelos locales (gratis)

Si tienes [Ollama instalado](/blog/ollama-ia-local-gratis-sin-api-2026/), puedes usar Aider completamente gratis:

```bash
# Instalar Ollama y descargar modelo
ollama pull deepseek-coder-v3:16b

# Usar Aider con modelo local
aider --model ollama/deepseek-coder-v3:16b

# O con Qwen (más ligero, 8GB RAM)
aider --model ollama/qwen2.5-coder:7b
```

### ¿Cuándo usar local vs API?

| Tarea | Modelo local | API |
|-------|-------------|-----|
| Añadir docstrings | ✅ Perfecto | Innecesario |
| Generar tests simples | ✅ Suficiente | Innecesario |
| Renombrar variables | ✅ Perfecto | Innecesario |
| Refactoring complejo | ❌ Limitado | ✅ Claude/GPT-4.1 |
| Nuevo módulo desde cero | ❌ Poca calidad | ✅ Necesario |
| Debugging de errores raros | ❌ No tiene contexto | ✅ Mejor con API |

## Flujo de trabajo real: mi día con Aider

```bash
# Mañana: empezar feature con modelo potente
aider --model gpt-4.1
/add src/payments/stripe.ts src/payments/types.ts
> implementa checkout con Stripe. soporta: tarjeta, SEPA, Apple Pay

# Aider genera el código, lo reviso, y hace commit automático
# git log: "feat: implement Stripe checkout with card, SEPA, Apple Pay"

# Tarde: tests y refactoring con modelo barato
aider --model gpt-4.1-nano
/add src/payments/stripe.test.ts
> genera tests para stripe.ts. cobertura: happy path + errores de pago

# Noche: debugging con modelo potente si hace falta
aider --model claude-sonnet-4
/add src/payments/stripe.ts src/payments/webhook.ts
> el webhook de Stripe falla con "signature verification failed". debug + fix
```

## Comparativa de costes real: Aider vs Cursor

Medí mi uso real durante 4 semanas:

### Semana típica: ~150 consultas

| Herramienta | Coste semanal | Coste mensual |
|-------------|--------------|---------------|
| **Cursor Pro** | $5.00 (fijo) | **$20.00** |
| **Aider + GPT-4.1** | ~$2.40 | **~$9.60** |
| **Aider + GPT-4.1 nano** (simple) **+ GPT-4.1** (complejo) | ~$1.20 | **~$4.80** |
| **Aider + Ollama** (simple) **+ GPT-4.1** (complejo) | ~$0.80 | **~$3.20** |

### Uso intensivo: ~500 consultas/semana

| Herramienta | Coste semanal | Coste mensual |
|-------------|--------------|---------------|
| **Cursor Pro** | $5-10 (cuota extra) | **$20-40** |
| **Aider + GPT-4.1** | ~$7.50 | **~$30** |
| **Aider mixto** (nano + 4.1 + local) | ~$3.50 | **~$14** |

**Conclusión**: para hasta ~300 consultas/mes, Cursor y Aider cuestan parecido. Para más, Aider mixto gana siempre.

## Archivo de configuración `.aider.conf.yml`

Crea este archivo en la raíz de tu proyecto para no repetir flags:

```yaml
# .aider.conf.yml
model: gpt-4.1
map-tokens: 768
auto-commits: true
git-commit-verify: true
dark-mode: true
stream: true

# Archivos que Aider nunca debe tocar
ignore:
  - "*.lock"
  - "dist/**"
  - "node_modules/**"
  - ".env"
```

## Integraciones útiles

### Con VS Code (como extensión)

```bash
# Aider tiene extensión experimental para VS Code
code --install-extension aider.aider
```

### Con tmux/screen (sesiones persistentes)

```bash
# Iniciar en tmux para no perder la sesión
tmux new -s aider
aider --model gpt-4.1

# Desconectar: Ctrl+B, D
# Reconectar: tmux attach -t aider
```

### Con scripts de CI/CD

```bash
# Aider en modo no-interactivo para automatización
aider --model gpt-4.1-nano --no-auto-commits --message "actualiza deps y fix breaking changes" --yes
```

## Troubleshooting

### Error: "Model not found"

```bash
# Verifica que el modelo existe
aider --list-models openai/

# Si usas Ollama, verifica que está corriendo
ollama list
```

### Aider edita archivos incorrectos

```bash
# Sé específico con los archivos en contexto
/drop *  # Quita todo
/add src/specific-file.ts  # Añade solo lo necesario
```

### Tokens consumidos más de lo esperado

```bash
# Revisa cuántos tokens usa el repo map
/tokens

# Reduce el mapa si es muy grande
/map-tokens 256
```

Si te encuentras errores de rate limit, consulta [cómo solucionar el error 429 Too Many Requests](/blog/error-429-too-many-requests-api-ia-2026/).

## Conclusión

Aider no reemplaza a Cursor para todos — si te gusta la UX visual y no te importa el coste fijo, Cursor está bien. Pero si:

- Quieres **control total** sobre qué modelo usas y cuánto pagas
- Trabajas mucho por **SSH o terminal**
- Se te **agotan las peticiones** de Cursor/Copilot
- Quieres **auto-commits** limpios en Git

Aider es la herramienta. Con el setup correcto (modelo mixto + caveman + local para lo simple), puedes programar con IA por **$5-15/mes** sin límites.

Para ver todos los repos que complementan Aider, revisa los [7 repos de GitHub imprescindibles para devs de IA](/blog/repos-github-desarrolladores-ia-2026/). Y para la guía completa de ahorro, lee [cómo programar con IA sin arruinarte](/blog/programar-con-ia-sin-arruinarte-guia-2026/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Caveman Prompting: Ahorra un 70% en Tokens con la Técnica Cavernícola]]></title>
      <link>https://francobosg.netlify.app/blog/caveman-prompting-ahorrar-tokens-ia-2026/</link>
      <description><![CDATA[¿Qué es Caveman Prompting? La técnica viral para reducir tokens en ChatGPT, Claude y Copilot. Con ejemplos reales, comparativas de coste y cuándo usarla.]]></description>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/caveman-prompting-ahorrar-tokens-ia-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Tutorial</category>
      <category>Gratis</category>
      <content:encoded><![CDATA[Tu agente de IA hace 200 llamadas por tarea. Cada prompt tiene 500 tokens de instrucciones. En un día, has quemado 100.000 tokens solo en **decirle cosas que ya entiende sin artículos ni preposiciones**.

La comunidad lo llama **Caveman Prompting** (Técnica Cavernícola) y es la forma más fácil de reducir tu factura de tokens entre un 50% y un 70%.

## ¿Qué es Caveman Prompting?

Escribes prompts como un cavernícola: sin artículos, sin preposiciones innecesarias, sin formalidades. Solo la información esencial.

### Prompt normal vs Caveman

**Prompt normal (47 tokens):**
```
Por favor, analiza el siguiente código JavaScript y encuentra todos los posibles 
errores de rendimiento. Dame una lista ordenada de los problemas encontrados con 
su explicación detallada y la solución recomendada para cada uno de ellos.
```

**Caveman Prompt (19 tokens):**
```
analiza código JS. busca errores rendimiento. lista problemas + explicación + solución
```

**Misma respuesta. 60% menos tokens.**

El truco es que los LLMs son modelos estadísticos de lenguaje — entienden contexto incluso con gramática rota. Las palabras "por favor", "el siguiente", "de los", "para cada uno de ellos" no aportan información semántica al modelo.

## Comparativa real de ahorro

Medí el consumo de tokens en 50 prompts reales de desarrollo con GPT-4.1 mini:

| Tipo de prompt | Tokens promedio | Coste por 10K llamadas | Ahorro |
|---------------|----------------|----------------------|--------|
| **Normal** (redactado) | 145 tokens | $0.93 | — |
| **Caveman** (mínimo) | 52 tokens | $0.33 | **64%** |
| **Mixto** (caveman + código) | 89 tokens | $0.57 | **39%** |

> **Nota**: El ahorro es solo en tokens de **input**. El output del modelo no cambia. Para modelos caros como Claude Opus 4 ($15/M input), la diferencia es aún mayor.

## Reglas del Caveman Prompting

### 1. Elimina artículos y preposiciones

```
// ❌ Normal
"Necesito que me crees una función que calcule el total de una factura 
incluyendo el IVA correspondiente"

// ✅ Caveman
"función calcular total factura con IVA"
```

### 2. Usa abreviaturas comunes

```
// ❌ Normal
"Crea una interfaz de TypeScript para el objeto de respuesta de la API"

// ✅ Caveman
"interfaz TS respuesta API"
```

### 3. Separa instrucciones con puntos o saltos de línea

```
// ❌ Normal
"Primero analiza el error, después busca la causa raíz, 
y finalmente dame la solución con un ejemplo de código"

// ✅ Caveman
"1. analiza error
2. causa raíz
3. solución + código"
```

### 4. Mantén los tecnicismos intactos

Los nombres de tecnologías, funciones y conceptos técnicos NO se simplifican:

```
// ❌ Demasiado caveman (pierde contexto)
"arregla cosa que no va en servidor"

// ✅ Caveman correcto (mantiene tecnicismos)  
"fix CORS error express middleware. req desde localhost:3000 a API :8080"
```

### 5. El código nunca se comprime

El código fuente se pasa tal cual. Caveman es solo para las **instrucciones**, no para el código que analizas:

```
// ✅ Instrucción caveman + código completo
refactora. reduce complejidad ciclomática.

function processOrder(order) {
  // ... código completo sin comprimir ...
}
```

## Caveman Prompting en agentes y flujos automáticos

Donde más impacto tiene es en **system prompts de agentes** que se repiten miles de veces:

### System prompt normal (210 tokens):

```
Eres un asistente experto en desarrollo web full-stack. Tu objetivo es ayudar 
al desarrollador a resolver problemas de código. Cuando el usuario te pida ayuda, 
debes analizar el problema cuidadosamente, considerar las mejores prácticas, 
y proporcionar una solución clara con código funcional. Siempre explica tu 
razonamiento paso a paso. Si no estás seguro de algo, indícalo claramente 
en vez de inventar una respuesta.
```

### System prompt caveman (62 tokens):

```
experto dev web fullstack. analiza problemas código. da solución con código funcional. 
explica razonamiento. si no seguro → dilo, no inventar.
```

**148 tokens ahorrados por llamada.** Si tu agente hace 1.000 llamadas/día:

| Modelo | Ahorro diario | Ahorro mensual |
|--------|--------------|---------------|
| GPT-4.1 | $0.30/día | **$9/mes** |
| GPT-4.1 mini | $0.06/día | **$1.80/mes** |
| Claude Sonnet 4 | $0.44/día | **$13.20/mes** |
| Claude Opus 4 | $2.22/día | **$66.60/mes** |

Con Claude Opus en flujos automatizados, caveman prompting puede ahorrarte **$66/mes** solo optimizando el system prompt.

## Combinando Caveman con otras técnicas de ahorro

Caveman Prompting es la capa más fácil de aplicar, pero se multiplica con otras optimizaciones:

### 1. Caveman + Modelo barato para tareas simples

```javascript
// Triage: modelo nano para clasificar, modelo potente solo cuando hace falta
const triage = await openai.chat.completions.create({
  model: 'gpt-4.1-nano', // $0.10/M tokens
  messages: [{ role: 'user', content: 'clasifica intención: bug, feature, pregunta. input: "no me funciona el login"' }],
});

if (triage.includes('bug')) {
  // Solo aquí usamos el modelo caro
  await openai.chat.completions.create({
    model: 'gpt-4.1',
    messages: [{ role: 'user', content: 'debug login. error: ...' }],
  });
}
```

Para una comparativa completa de precios por modelo, consulta la [calculadora de precios de IA](/blog/calculadora-precios-ia-2026/).

### 2. Caveman + Prompt Caching

El prompt caching de OpenAI y Anthropic cachea el prefijo estático de tus mensajes. Si tu system prompt caveman se repite idéntico, consigues **hasta un 90% de descuento** en esos tokens. Más detalles en el artículo de [prompt caching en OpenAI y Claude](/blog/prompt-caching-openai-claude-ahorrar-tokens-2026/).

### 3. Caveman + RAG (solo contexto relevante)

No envíes todo el documento — solo los fragmentos relevantes, y escribe la instrucción en caveman:

```
responde pregunta usando SOLO este contexto. no inventar.

contexto: {fragmentos_relevantes}

pregunta: {pregunta_usuario}
```

Para implementar RAG desde cero, sigue el tutorial de [cómo crear un chatbot RAG con OpenAI](/blog/crear-chatbot-rag-openai-tutorial-2026/).

## ¿Cuándo NO usar Caveman Prompting?

| Escenario | ¿Caveman? | Por qué |
|-----------|-----------|---------|
| System prompts de agentes | ✅ Sí | Se repiten miles de veces |
| Clasificación / triage | ✅ Sí | Instrucciones simples, alta frecuencia |
| Prompts de usuario final | ❌ No | El usuario escribe como quiere |
| Prompts con matices legales/médicos | ⚠️ Con cuidado | La ambigüedad puede ser peligrosa |
| Código a analizar | ❌ Nunca | El código va completo siempre |
| Few-shot examples | ⚠️ Parcial | Reduce la instrucción, mantén los ejemplos |

## Caveman en editores de código (Copilot, Cursor)

Si usas [Cursor, Copilot o Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/), los prompts en el chat inline también consumen tokens de tu cuota:

```
// ❌ En el chat de Cursor
"¿Podrías por favor crear una función de TypeScript que reciba un array de 
objetos de tipo Product y devuelva solo los que tengan un precio mayor a 
un valor dado como parámetro?"

// ✅ Caveman en Cursor
"fn TS: filtra Product[] por precio > parámetro"
```

En Cursor ($20/mes con límite de peticiones premium), cada prompt optimizado te deja hacer **más consultas antes de agotar la cuota**. Si buscas una alternativa más barata, [Aider](/blog/aider-ia-terminal-barata-alternativa-cursor-2026/) funciona directamente desde la terminal con tu propia API key.

## Herramienta: comprime tus prompts automáticamente

Si no quieres comprimir manualmente, puedes crear un compresor simple:

```javascript
function cavemanify(prompt) {
  // Eliminar artículos y preposiciones comunes en español
  const stopWords = new Set([
    'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas',
    'de', 'del', 'en', 'por', 'para', 'con', 'que', 'al',
    'es', 'son', 'está', 'están', 'fue', 'ser', 'como',
    'su', 'sus', 'este', 'esta', 'ese', 'esa', 'mi', 'tu',
    'me', 'te', 'se', 'lo', 'le', 'nos', 'les',
    'muy', 'más', 'ya', 'también', 'pero', 'sin embargo',
    'por favor', 'necesito que', 'quiero que', 'podrías',
  ]);

  return prompt
    .toLowerCase()
    .split(/\s+/)
    .filter(word => !stopWords.has(word))
    .join(' ')
    .replace(/\s+/g, ' ')
    .trim();
}

console.log(cavemanify(
  'Por favor analiza el siguiente código JavaScript y encuentra los errores'
));
// → "analiza siguiente código javascript encuentra errores"
```

> **Importante**: Esto es una simplificación. Para producción, usa esto como primer paso y revisa manualmente que no se pierda contexto clave.

## Comparativa final: impacto real en costes

Para un flujo de agente que procesa 500 tareas/día con 5 llamadas cada una (2.500 llamadas/día):

| Configuración | Tokens/día | Coste mensual (GPT-4.1 mini) | Coste mensual (Claude Sonnet 4) |
|--------------|-----------|------------------------------|-------------------------------|
| Prompts normales | 725K | **$8.70** | **$65.25** |
| Caveman Prompting | 260K | **$3.12** | **$23.40** |
| Caveman + modelo nano para triage | 180K | **$1.62** | **$16.20** |
| Caveman + caching + triage | 95K | **$0.57** | **$4.28** |

De **$65/mes a $4/mes** con Claude Sonnet. Un ahorro del **93%** combinando las técnicas.

Si quieres usar APIs sin gastar nada para probar estas técnicas, revisa las [APIs de IA gratuitas disponibles](/blog/usar-api-chatgpt-claude-gratis-2026/). Y para escribir prompts más efectivos (caveman o no), consulta los [mejores prompts para programar con IA](/blog/mejores-prompts-programar-ia-2026/).

## Conclusión

Caveman Prompting no es un hack — es sentido común aplicado a tokens:

1. **Elimina palabras que no aportan información** al modelo
2. **Mantén tecnicismos y código** intactos
3. **Aplícalo en system prompts y flujos automáticos** donde se repite miles de veces
4. **Combínalo con caching y triage** para multiplicar el ahorro

Es la optimización con mejor ratio esfuerzo/resultado: cambias cómo escribes prompts y ahorras un 70% desde el primer día.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Error 'Context Length Exceeded' en OpenAI y Claude: Cómo Solucionarlo]]></title>
      <link>https://francobosg.netlify.app/blog/error-context-length-exceeded-openai-claude-2026/</link>
      <description><![CDATA[¿'Maximum context length exceeded' al llamar a la API de OpenAI o Claude? Causas, límites por modelo y 4 técnicas para solucionarlo: chunking, RAG, resumen y sliding window.]]></description>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/error-context-length-exceeded-openai-claude-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Tu aplicación funcionaba perfectamente hasta que un usuario envió un documento largo:

```
Error: This model's maximum context length is 128000 tokens.
However, your messages resulted in 143892 tokens.
Please reduce the length of the messages.
```

El modelo no puede procesar más tokens de los que le caben. Y no, no basta con "cortar el texto" — perderías información crítica. En este artículo te doy 4 técnicas profesionales para manejar este error sin perder calidad.

## Límites de contexto por modelo (2026)

| Modelo | Contexto máximo | Output máximo | Coste input/1M tokens |
|--------|----------------|---------------|----------------------|
| **GPT-4.1** | 1.047.576 | 32.768 | $2.00 |
| **GPT-4.1 mini** | 1.047.576 | 32.768 | $0.40 |
| **GPT-4.1 nano** | 1.047.576 | 32.768 | $0.10 |
| **Claude Opus 4** | 200.000 | 32.000 | $15.00 |
| **Claude Sonnet 4** | 200.000 | 16.000 | $3.00 |
| **Gemini 2.5 Pro** | 1.048.576 | 65.536 | $1.25 - $2.50 |
| **Llama 4 Scout** | 10.000.000 | 16.384 | Gratis (local) |
| **DeepSeek V3** | 131.072 | 16.384 | $0.27 |

> **Nota**: que un modelo acepte 1M de tokens no significa que debas enviarlos. Más contexto = más coste y peor calidad de respuesta (la IA se "pierde" en textos largos).

## ¿Por qué ocurre? Las 3 causas

### 1. Documento demasiado grande

Intentas enviar un archivo completo como contexto:

```javascript
// ❌ Esto rompe si el archivo tiene más de ~500KB
const doc = fs.readFileSync('manual-completo.txt', 'utf-8');
await openai.chat.completions.create({
  model: 'gpt-4.1-mini',
  messages: [{ role: 'user', content: `Resume esto:\n${doc}` }]
});
// 💥 context length exceeded
```

### 2. Historial de conversación acumulado

Cada mensaje del chat se envía como contexto. Después de 50+ mensajes, el historial supera el límite:

```javascript
// ❌ El historial crece sin control
messages.push({ role: 'user', content: nuevoMensaje });
messages.push({ role: 'assistant', content: respuestaIA });
// Tras 100 mensajes → 💥
```

### 3. System prompt demasiado largo

Un system prompt con instrucciones detalladas + ejemplos puede ocupar miles de tokens:

```javascript
// ❌ System prompt de 10.000 tokens
const systemPrompt = `Eres un asistente experto en...
[200 líneas de instrucciones, ejemplos, formato de salida, reglas...]`;
```

## Técnica 1: Chunking inteligente

Divide el documento en fragmentos que quepan en el contexto. La clave es cortar en **puntos lógicos** (párrafos, secciones) y no a mitad de frase.

```javascript
/**
 * Divide un texto en chunks respetando párrafos.
 * @param text - El texto completo
 * @param maxTokens - Tokens máx. por chunk (dejar margen para prompt)
 * @param overlap - Tokens de solapamiento entre chunks
 */
function chunkText(text, maxTokens = 3000, overlap = 200) {
  const paragraphs = text.split(/\n\n+/);
  const chunks = [];
  let currentChunk = '';
  let currentTokens = 0;

  for (const paragraph of paragraphs) {
    const paragraphTokens = Math.ceil(paragraph.length / 3); // estimación español

    if (currentTokens + paragraphTokens > maxTokens && currentChunk) {
      chunks.push(currentChunk.trim());
      // Solapamiento: mantener últimos N caracteres
      const overlapText = currentChunk.slice(-(overlap * 3));
      currentChunk = overlapText + '\n\n' + paragraph;
      currentTokens = Math.ceil(currentChunk.length / 3);
    } else {
      currentChunk += (currentChunk ? '\n\n' : '') + paragraph;
      currentTokens += paragraphTokens;
    }
  }

  if (currentChunk.trim()) chunks.push(currentChunk.trim());
  return chunks;
}

// Uso: procesar un documento grande chunk a chunk
const chunks = chunkText(documentoGrande, 4000);
const resultados = [];

for (const chunk of chunks) {
  const response = await openai.chat.completions.create({
    model: 'gpt-4.1-mini',
    messages: [
      { role: 'system', content: 'Extrae los puntos clave de este fragmento.' },
      { role: 'user', content: chunk }
    ]
  });
  resultados.push(response.choices[0].message.content);
}

// Síntesis final
const resumenFinal = await openai.chat.completions.create({
  model: 'gpt-4.1-mini',
  messages: [
    { role: 'system', content: 'Combina estos resúmenes parciales en uno coherente.' },
    { role: 'user', content: resultados.join('\n---\n') }
  ]
});
```

## Técnica 2: RAG (Retrieval-Augmented Generation)

En vez de enviar todo el documento, busca solo los fragmentos relevantes para la pregunta. Es la técnica más eficiente para documentos grandes.

```javascript
import { ChromaClient } from 'chromadb';
import OpenAI from 'openai';

const chroma = new ChromaClient();
const openai = new OpenAI();

// 1. Indexar documento (una sola vez)
const collection = await chroma.getOrCreateCollection({ name: 'docs' });
const chunks = chunkText(documento, 500);  // chunks pequeños para búsqueda

await collection.add({
  ids: chunks.map((_, i) => `chunk-${i}`),
  documents: chunks,
});

// 2. Buscar solo lo relevante
async function askWithRAG(question) {
  const results = await collection.query({
    queryTexts: [question],
    nResults: 5,  // solo los 5 chunks más relevantes
  });

  const context = results.documents[0].join('\n\n');

  return openai.chat.completions.create({
    model: 'gpt-4.1-mini',
    messages: [
      { role: 'system', content: 'Responde basándote SOLO en el contexto proporcionado.' },
      { role: 'user', content: `Contexto:\n${context}\n\nPregunta: ${question}` }
    ]
  });
}
```

> Para un tutorial completo de RAG con ChromaDB, revisa [cómo crear un chatbot RAG con OpenAI desde cero](/blog/crear-chatbot-rag-openai-tutorial-2026/). Si quieres ir un paso más allá y crear un agente que use RAG como herramienta, mira el tutorial de [agentes de IA con LangChain](/blog/crear-agente-ia-langchain-nodejs-tutorial/).

## Técnica 3: Sliding Window para chat

Para conversaciones largas, mantén solo los últimos N mensajes + un resumen de los anteriores:

```javascript
const MAX_MESSAGES = 20;

async function manageChatHistory(messages) {
  if (messages.length <= MAX_MESSAGES) return messages;

  // Resumir los mensajes antiguos
  const oldMessages = messages.slice(0, -MAX_MESSAGES);
  const recentMessages = messages.slice(-MAX_MESSAGES);

  const summary = await openai.chat.completions.create({
    model: 'gpt-4.1-nano', // modelo barato para resumen
    messages: [
      {
        role: 'system',
        content: 'Resume esta conversación en 3-5 puntos clave. Mantén datos concretos.'
      },
      ...oldMessages
    ]
  });

  return [
    {
      role: 'system',
      content: `Resumen de la conversación anterior:\n${summary.choices[0].message.content}`
    },
    ...recentMessages
  ];
}
```

## Técnica 4: Contar tokens antes de enviar

Prevén el error comprobando el tamaño antes de la llamada:

```javascript
import { encoding_for_model } from 'tiktoken';

const encoder = encoding_for_model('gpt-4.1');

function countTokens(messages) {
  let total = 0;
  for (const msg of messages) {
    total += 4; // overhead por mensaje
    total += encoder.encode(msg.content).length;
  }
  total += 2; // overhead final
  return total;
}

async function safeChatCompletion(messages, maxOutputTokens = 4096) {
  const inputTokens = countTokens(messages);
  const modelLimit = 1_047_576; // gpt-4.1

  if (inputTokens + maxOutputTokens > modelLimit) {
    console.warn(`⚠️ ${inputTokens} tokens de input + ${maxOutputTokens} output = ${inputTokens + maxOutputTokens} (límite: ${modelLimit})`);
    // Aplicar sliding window o truncar
    messages = await manageChatHistory(messages);
  }

  return openai.chat.completions.create({
    model: 'gpt-4.1',
    messages,
    max_tokens: maxOutputTokens,
  });
}
```

Si combinado con este error estás viendo errores 429, es probable que estés enviando demasiadas peticiones con contextos grandes. Revisa la guía de [Error 429 Too Many Requests en APIs de IA](/blog/error-429-too-many-requests-api-ia-2026/) para implementar rate limiting.

## Cuándo usar cada técnica

| Escenario | Técnica | Por qué |
|-----------|---------|---------|
| Documento de 1 uso (resumir, analizar) | **Chunking** | Simple, no necesita base de datos |
| Base de conocimiento + preguntas | **RAG** | Solo envía lo relevante |
| Chat conversacional largo | **Sliding Window** | Mantiene contexto reciente |
| Prevención en cualquier caso | **Conteo de tokens** | Evita errores antes de que ocurran |
| Documento + necesitas JSON exacto | **Chunking + RAG** | Combina con [parseo JSON robusto](/blog/parsear-json-ia-sin-errores-openai-claude-2026/) |

## Error relacionado: respuesta truncada

A veces el modelo cabe en el contexto pero la **respuesta** se corta por `max_tokens`:

```javascript
const response = await openai.chat.completions.create({ /* ... */ });

if (response.choices[0].finish_reason === 'length') {
  // ⚠️ La respuesta se cortó — pedir continuación o aumentar max_tokens
  console.warn('Respuesta truncada. Aumentando max_tokens...');
}
```

Esto es especialmente problemático si pides respuestas en JSON — un JSON truncado es JSON inválido. Consulta las [técnicas para parsear JSON de IA sin errores](/blog/parsear-json-ia-sin-errores-openai-claude-2026/) para manejar este caso.

## Optimizar costes con contextos grandes

Enviar muchos tokens no solo genera errores — es caro. Algunas tácticas:

1. **Usa modelos con context window grande y barato**: GPT-4.1 nano (1M tokens, $0.10/M)
2. **Cachea respuestas**: si el mismo documento se consulta varias veces, guarda el resultado
3. **Preprocesa**: extrae solo texto relevante antes de enviar (quita HTML, headers, footers)

Si quieres probar estas técnicas sin coste, revisa las [APIs de IA gratuitas disponibles](/blog/usar-api-chatgpt-claude-gratis-2026/). Para ejecutar modelos con contexto ilimitado en local, [Ollama](/blog/ollama-ia-local-gratis-sin-api-2026/) es la mejor opción.

## Conclusión

El error de "context length exceeded" no es un callejón sin salida — es una señal de que necesitas una estrategia de gestión de contexto:

1. **Chunking** para documentos de un solo uso
2. **RAG** para bases de conocimiento con múltiples consultas
3. **Sliding Window** para chats largos
4. **Conteo preventivo** como red de seguridad

La técnica más robusta para producción es **RAG**: envías solo lo relevante, reduces costes y mejoras la calidad de las respuestas.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Function Calling en OpenAI y Claude: Conectar IA con APIs sin Alucinaciones]]></title>
      <link>https://francobosg.netlify.app/blog/function-calling-openai-claude-conectar-ia-apis-2026/</link>
      <description><![CDATA[Tutorial completo de function calling en GPT-4.1 y Claude. Haz que la IA consulte bases de datos, APIs externas y ejecute acciones reales. Con código Node.js paso a paso.]]></description>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/function-calling-openai-claude-conectar-ia-apis-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Tu IA puede responder preguntas generales, pero cuando el usuario pregunta "¿cuánto cuesta el vuelo MAD-BCN de mañana?" — inventa un precio. **Alucina** porque no tiene acceso a datos reales.

**Function calling** resuelve esto: le das al modelo un catálogo de funciones (buscar vuelos, consultar stock, enviar email) y el modelo **decide cuándo usarlas** y genera los argumentos correctos. Tú ejecutas la función y devuelves el resultado real.

## ¿Cómo funciona?

```
1. Usuario: "¿Cuánto cuesta el vuelo MAD-BCN mañana?"
2. Modelo: "Necesito llamar a buscar_vuelos(origen: 'MAD', destino: 'BCN', fecha: '2026-04-18')"
3. Tu código: ejecuta buscar_vuelos() → API real → [{ precio: 89, aerolinea: 'Vueling' }]
4. Modelo: recibe resultado real → "El vuelo más barato MAD-BCN mañana es 89€ con Vueling"
```

El modelo **nunca ejecuta código directamente** — solo genera el nombre de la función y los argumentos en JSON. Tú tienes el control total de la ejecución.

## Function Calling con OpenAI (GPT-4.1)

### Definir herramientas

```javascript
import OpenAI from 'openai';

const openai = new OpenAI();

const tools = [
  {
    type: 'function',
    function: {
      name: 'get_weather',
      description: 'Obtiene el clima actual de una ciudad',
      parameters: {
        type: 'object',
        properties: {
          city: {
            type: 'string',
            description: 'Nombre de la ciudad (ej: "Madrid", "Barcelona")'
          },
          units: {
            type: 'string',
            enum: ['celsius', 'fahrenheit'],
            description: 'Unidades de temperatura'
          }
        },
        required: ['city'],
        additionalProperties: false
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'search_products',
      description: 'Busca productos en la base de datos',
      parameters: {
        type: 'object',
        properties: {
          query: { type: 'string', description: 'Término de búsqueda' },
          max_price: { type: 'number', description: 'Precio máximo en euros' },
          category: {
            type: 'string',
            enum: ['electrónica', 'ropa', 'hogar', 'deportes']
          }
        },
        required: ['query'],
        additionalProperties: false
      }
    }
  }
];
```

### Implementar las funciones reales

```javascript
// Tus funciones reales — pueden llamar a APIs, bases de datos, etc.
const availableFunctions = {
  get_weather: async ({ city, units = 'celsius' }) => {
    // Llamada real a API de clima
    const res = await fetch(
      `https://api.weatherapi.com/v1/current.json?key=${process.env.WEATHER_KEY}&q=${encodeURIComponent(city)}`
    );
    const data = await res.json();
    return {
      city,
      temperature: units === 'celsius' ? data.current.temp_c : data.current.temp_f,
      condition: data.current.condition.text,
      units
    };
  },

  search_products: async ({ query, max_price, category }) => {
    // Consulta real a base de datos
    const filters = { query };
    if (max_price) filters.max_price = max_price;
    if (category) filters.category = category;
    // Simulación — en producción sería una query a tu DB
    return [
      { name: 'Auriculares Sony WH-1000XM6', price: 299, category: 'electrónica' },
      { name: 'Auriculares Apple AirPods Pro 3', price: 279, category: 'electrónica' }
    ];
  }
};
```

### El bucle de ejecución

```javascript
async function chatWithTools(userMessage) {
  const messages = [
    { role: 'system', content: 'Eres un asistente útil. Usa las herramientas cuando necesites datos reales.' },
    { role: 'user', content: userMessage }
  ];

  // Bucle: el modelo puede pedir múltiples funciones
  while (true) {
    const response = await openai.chat.completions.create({
      model: 'gpt-4.1-mini',
      messages,
      tools,
    });

    const choice = response.choices[0];

    // Si el modelo responde con texto, hemos terminado
    if (choice.finish_reason === 'stop') {
      return choice.message.content;
    }

    // Si el modelo pide funciones, ejecutarlas
    if (choice.finish_reason === 'tool_calls') {
      messages.push(choice.message); // Añadir la petición del modelo

      for (const toolCall of choice.message.tool_calls) {
        const functionName = toolCall.function.name;
        const args = JSON.parse(toolCall.function.arguments);

        console.log(`🔧 Ejecutando ${functionName}(${JSON.stringify(args)})`);

        // Ejecutar la función real
        const fn = availableFunctions[functionName];
        if (!fn) {
          throw new Error(`Función desconocida: ${functionName}`);
        }
        const result = await fn(args);

        // Devolver resultado al modelo
        messages.push({
          role: 'tool',
          tool_call_id: toolCall.id,
          content: JSON.stringify(result)
        });
      }
      // El bucle continúa — el modelo procesará los resultados
    }
  }
}

// Uso
const respuesta = await chatWithTools('¿Qué tiempo hace en Madrid?');
console.log(respuesta);
// → "En Madrid ahora mismo hay 23°C con cielo despejado."
```

## Tool Use con Claude (Anthropic)

Claude usa el mismo concepto pero con sintaxis diferente:

```javascript
import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic();

const tools = [
  {
    name: 'get_weather',
    description: 'Obtiene el clima actual de una ciudad',
    input_schema: {
      type: 'object',
      properties: {
        city: { type: 'string', description: 'Nombre de la ciudad' },
        units: { type: 'string', enum: ['celsius', 'fahrenheit'] }
      },
      required: ['city']
    }
  }
];

async function chatWithToolsClaude(userMessage) {
  const messages = [{ role: 'user', content: userMessage }];

  while (true) {
    const response = await anthropic.messages.create({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 4096,
      tools,
      messages,
    });

    // Si termina sin pedir herramientas
    if (response.stop_reason === 'end_turn') {
      const textBlock = response.content.find(b => b.type === 'text');
      return textBlock?.text || '';
    }

    // Si pide herramientas
    if (response.stop_reason === 'tool_use') {
      messages.push({ role: 'assistant', content: response.content });

      const toolResults = [];
      for (const block of response.content) {
        if (block.type !== 'tool_use') continue;

        const fn = availableFunctions[block.name];
        const result = await fn(block.input);

        toolResults.push({
          type: 'tool_result',
          tool_use_id: block.id,
          content: JSON.stringify(result)
        });
      }

      messages.push({ role: 'user', content: toolResults });
    }
  }
}
```

## Parallel function calling

GPT-4.1 puede pedir **múltiples funciones a la vez** si las necesita. Por ejemplo: "¿Qué tiempo hace en Madrid y Barcelona?"

```javascript
// El modelo devuelve tool_calls con 2 funciones:
// tool_calls: [
//   { function: { name: 'get_weather', arguments: '{"city":"Madrid"}' } },
//   { function: { name: 'get_weather', arguments: '{"city":"Barcelona"}' } }
// ]

// Ejecutar en paralelo para mejor rendimiento
const results = await Promise.all(
  choice.message.tool_calls.map(async (toolCall) => {
    const fn = availableFunctions[toolCall.function.name];
    const args = JSON.parse(toolCall.function.arguments);
    const result = await fn(args);
    return {
      role: 'tool',
      tool_call_id: toolCall.id,
      content: JSON.stringify(result)
    };
  })
);

messages.push(choice.message, ...results);
```

## Function calling + streaming

Puedes combinar function calling con streaming para que el usuario vea la respuesta final en tiempo real:

```javascript
async function streamWithTools(userMessage, onToken) {
  const messages = [
    { role: 'system', content: 'Usa las herramientas cuando necesites datos reales.' },
    { role: 'user', content: userMessage }
  ];

  // Fase 1: obtener tool calls (sin streaming)
  const initial = await openai.chat.completions.create({
    model: 'gpt-4.1-mini',
    messages,
    tools,
  });

  if (initial.choices[0].finish_reason === 'tool_calls') {
    messages.push(initial.choices[0].message);

    // Ejecutar funciones
    for (const tc of initial.choices[0].message.tool_calls) {
      const fn = availableFunctions[tc.function.name];
      const result = await fn(JSON.parse(tc.function.arguments));
      messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) });
    }
  }

  // Fase 2: respuesta final con streaming
  const stream = await openai.chat.completions.create({
    model: 'gpt-4.1-mini',
    messages,
    stream: true,
  });

  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content;
    if (content) onToken(content);
  }
}
```

Para una implementación completa de streaming con SSE hasta el navegador, consulta el tutorial de [streaming SSE con ChatGPT y Claude en Node.js](/blog/streaming-sse-chatgpt-claude-nodejs-2026/).

## Seguridad: validar antes de ejecutar

**NUNCA** ejecutes funciones que el modelo pida sin validación. El modelo podría intentar ejecutar funciones que no has definido o pasar argumentos maliciosos.

```javascript
// ✅ Whitelist de funciones permitidas
const ALLOWED_FUNCTIONS = new Set(['get_weather', 'search_products']);

function executeToolCall(toolCall) {
  const { name, arguments: rawArgs } = toolCall.function;

  // 1. Verificar que la función existe y está permitida
  if (!ALLOWED_FUNCTIONS.has(name)) {
    throw new Error(`Función no permitida: ${name}`);
  }

  // 2. Parsear y validar argumentos
  const args = JSON.parse(rawArgs);

  // 3. Ejecutar
  return availableFunctions[name](args);
}
```

## Function calling vs. agentes

Function calling es el **mecanismo base**. Un agente lo usa dentro de un **bucle de razonamiento**:

| Aspecto | Function calling | Agente |
|---------|-----------------|--------|
| **Llamadas** | 1-3 por turno | Múltiples en bucle |
| **Decisión** | Modelo elige función | Modelo planifica y ejecuta secuencia |
| **Complejidad** | Baja | Alta |
| **Ejemplo** | "¿Qué clima hace?" → 1 API call | "Planifica un viaje" → buscar vuelos + hotel + clima |
| **Framework** | SDK nativo de OpenAI/Claude | LangChain, CrewAI |

Si necesitas un agente completo que encadene múltiples herramientas, revisa el tutorial de [cómo crear un agente de IA con LangChain y Node.js](/blog/crear-agente-ia-langchain-nodejs-tutorial/).

## Caso práctico: asistente de e-commerce

```javascript
const ecommerceTools = [
  {
    type: 'function',
    function: {
      name: 'search_products',
      description: 'Busca productos en el catálogo',
      parameters: {
        type: 'object',
        properties: {
          query: { type: 'string' },
          category: { type: 'string' },
          max_price: { type: 'number' }
        },
        required: ['query'],
        additionalProperties: false
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'get_order_status',
      description: 'Consulta el estado de un pedido por ID',
      parameters: {
        type: 'object',
        properties: {
          order_id: { type: 'string', description: 'ID del pedido (ej: ORD-12345)' }
        },
        required: ['order_id'],
        additionalProperties: false
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'check_stock',
      description: 'Verifica el stock disponible de un producto',
      parameters: {
        type: 'object',
        properties: {
          product_id: { type: 'string' }
        },
        required: ['product_id'],
        additionalProperties: false
      }
    }
  }
];

// Ahora el chatbot puede:
// "¿Tienen auriculares por menos de 100€?" → search_products
// "¿Dónde está mi pedido ORD-12345?" → get_order_status
// "¿Hay stock del Sony XM6?" → check_stock + search_products
```

## Errores comunes

### 1. No manejar funciones desconocidas

El modelo puede alucinar un nombre de función. **Siempre** verifica contra tu whitelist.

### 2. No devolver errores al modelo

Si la función falla, devuelve el error para que el modelo pueda informar al usuario:

```javascript
try {
  const result = await fn(args);
  return { role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) };
} catch (error) {
  return { role: 'tool', tool_call_id: tc.id, content: JSON.stringify({ error: error.message }) };
}
```

### 3. Descripciones vagas en las herramientas

El modelo decide qué función usar basándose en `description`. Si es vaga, elegirá mal:

```javascript
// ❌ El modelo no sabe cuándo usarla
{ description: 'Hace cosas con datos' }

// ✅ El modelo sabe exactamente cuándo usarla
{ description: 'Busca productos en el catálogo por nombre, categoría o rango de precio. Devuelve nombre, precio y disponibilidad.' }
```

Para escribir mejores descripciones de herramientas (y prompts en general), revisa los [mejores prompts para programar con IA](/blog/mejores-prompts-programar-ia-2026/).

## Conclusión

Function calling es la pieza clave para pasar de un chatbot que inventa datos a uno que **consulta información real**:

1. **Define herramientas** con schemas JSON claros
2. **Implementa un bucle** que ejecute las funciones y devuelva resultados al modelo
3. **Valida siempre** que las funciones y argumentos son legítimos
4. **Combina con streaming** para UX en tiempo real

Es más sencillo de lo que parece — el 80% del trabajo es definir buenos schemas y descripciones. El modelo se encarga del resto.

Si quieres que las respuestas del modelo sean JSON estructurado (no solo texto), revisa [cómo parsear JSON de IA sin errores](/blog/parsear-json-ia-sin-errores-openai-claude-2026/). Y para ejecutar todo esto con modelos gratuitos en local, [Ollama](/blog/ollama-ia-local-gratis-sin-api-2026/) es tu mejor opción.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cómo Parsear JSON con IA sin Errores: Evitar Alucinaciones en OpenAI y Claude]]></title>
      <link>https://francobosg.netlify.app/blog/parsear-json-ia-sin-errores-openai-claude-2026/</link>
      <description><![CDATA[¿Tu IA devuelve JSON roto o con campos inventados? Guía práctica para forzar respuestas JSON válidas con OpenAI, Claude y Zod. Con código Node.js listo.]]></description>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/parsear-json-ia-sin-errores-openai-claude-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Estás construyendo una feature que depende de la IA: extraer datos de un texto, clasificar contenido, generar un objeto estructurado. Todo funciona en tu prompt de prueba, pero en producción:

```
SyntaxError: Unexpected token 'C' at position 0
  → La IA respondió: "Claro, aquí tienes el JSON: {..."
```

O peor: el JSON es válido pero tiene **campos inventados** que rompen tu tipado.

Esto le pasa al 90% de los desarrolladores que integran LLMs. En este artículo te doy las **3 técnicas definitivas** para obtener JSON perfecto de cualquier modelo de IA.

## El problema real: por qué la IA rompe tu JSON

Los LLMs generan texto token a token. No "piensan" en estructuras de datos — predicen la siguiente palabra más probable. Esto causa:

| Problema | Ejemplo | Frecuencia |
|----------|---------|-----------|
| **Texto antes del JSON** | `"Aquí tienes: { ... }"` | Muy común |
| **Markdown wrapping** | ````json\n{...}\n```` | Común |
| **Comillas simples** | `{'key': 'value'}` | Ocasional |
| **Campos inventados** | `{"nombre": "...", "sentimiento_astrológico": "..."}` | Común |
| **Campos faltantes** | Omite campos que el prompt pedía | Ocasional |
| **JSON truncado** | Se corta a mitad por límite de tokens | Raro pero crítico |

## Solución 1: Structured Outputs de OpenAI (la mejor)

Desde 2024, OpenAI soporta **Structured Outputs** — la respuesta está **garantizada** como JSON válido que cumple tu schema. No es un prompt trick: es una restricción a nivel de generación.

```javascript
import OpenAI from 'openai';

const openai = new OpenAI();

const response = await openai.chat.completions.create({
  model: 'gpt-4.1-mini',
  messages: [
    {
      role: 'system',
      content: 'Extrae los datos del producto del texto del usuario.'
    },
    {
      role: 'user',
      content: 'El MacBook Pro M4 cuesta 1.999€, tiene 16GB RAM y pantalla de 14 pulgadas.'
    }
  ],
  response_format: {
    type: 'json_schema',
    json_schema: {
      name: 'producto',
      strict: true,
      schema: {
        type: 'object',
        properties: {
          nombre: { type: 'string', description: 'Nombre del producto' },
          precio: { type: 'number', description: 'Precio en euros' },
          specs: {
            type: 'array',
            items: { type: 'string' },
            description: 'Lista de especificaciones'
          },
          disponible: { type: 'boolean' }
        },
        required: ['nombre', 'precio', 'specs', 'disponible'],
        additionalProperties: false
      }
    }
  }
});

const producto = JSON.parse(response.choices[0].message.content);
// ✅ TypeSafe: { nombre: "MacBook Pro M4", precio: 1999, specs: [...], disponible: true }
```

**¿Por qué funciona al 100%?** Con `strict: true`, OpenAI usa *constrained decoding*: el modelo solo puede generar tokens que producen JSON válido según tu schema. No es un "intento" — es una **garantía**.

### Limitaciones

- Solo funciona con modelos `gpt-4.1`, `gpt-4.1-mini` y `gpt-4.1-nano`
- El schema debe ser compatible con JSON Schema (no soporta `oneOf` complejo)
- Añade ~100ms de latencia en la primera petición (compila el schema)

## Solución 2: Parseo robusto para Claude y modelos sin Structured Outputs

Claude (Anthropic) y modelos open-source no tienen Structured Outputs nativos. Necesitas un **parser robusto** que extraiga JSON aunque venga envuelto en texto.

```javascript
/**
 * Extrae y parsea JSON de una respuesta de IA,
 * aunque venga envuelto en texto o markdown.
 */
function parseAIJson(raw) {
  // 1. Intentar parseo directo
  try {
    return JSON.parse(raw);
  } catch {
    // Continuar con limpieza
  }

  // 2. Extraer de bloque markdown ```json ... ```
  const markdownMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
  if (markdownMatch) {
    try {
      return JSON.parse(markdownMatch[1].trim());
    } catch {
      // Continuar
    }
  }

  // 3. Extraer el primer objeto/array JSON del texto
  const jsonMatch = raw.match(/[\[{][\s\S]*[\]}]/);
  if (jsonMatch) {
    try {
      return JSON.parse(jsonMatch[0]);
    } catch {
      // Continuar
    }
  }

  throw new Error(`No se pudo extraer JSON válido de la respuesta: ${raw.slice(0, 200)}`);
}
```

### Ejemplo con Claude

```javascript
import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic();

const message = await anthropic.messages.create({
  model: 'claude-sonnet-4-20250514',
  max_tokens: 1024,
  system: `Responde SOLO con un objeto JSON válido, sin texto adicional.
Schema: { "nombre": string, "precio": number, "specs": string[] }`,
  messages: [
    {
      role: 'user',
      content: 'Extrae datos: iPhone 16 Pro, 1.219€, chip A18 Pro, 256GB'
    }
  ]
});

const producto = parseAIJson(message.content[0].text);
// ✅ { nombre: "iPhone 16 Pro", precio: 1219, specs: ["chip A18 Pro", "256GB"] }
```

> **Tip pro**: Con Claude, añadir `Responde SOLO con JSON` al system prompt reduce el texto extra en un 95%. Si necesitas prompts más efectivos, revisa la guía de [mejores prompts para programar con IA](/blog/mejores-prompts-programar-ia-2026/).

## Solución 3: Validación con Zod (la capa de seguridad)

Parsear JSON no es suficiente. Necesitas **validar** que los campos existen, tienen el tipo correcto y no hay campos inventados. [Zod](https://zod.dev) es el estándar para esto en TypeScript.

```typescript
import { z } from 'zod';

// Define el schema UNA vez — sirve para validación y como tipo
const ProductoSchema = z.object({
  nombre: z.string().min(1),
  precio: z.number().positive(),
  specs: z.array(z.string()).min(1),
  disponible: z.boolean().default(true),
});

type Producto = z.infer<typeof ProductoSchema>;

function parseProductoIA(raw: string): Producto {
  const parsed = parseAIJson(raw);  // extrae JSON del texto
  return ProductoSchema.parse(parsed);  // valida y tipa
}

// Uso
try {
  const producto = parseProductoIA(respuestaIA);
  console.log(producto.nombre); // ✅ TypeSafe
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error('La IA devolvió datos inválidos:', error.issues);
    // → Reintentar con prompt más específico
  }
}
```

### Patrón completo: retry con validación

En producción, combinas las 3 técnicas con retry automático:

```typescript
async function extractWithRetry<T>(
  schema: z.ZodType<T>,
  prompt: string,
  maxRetries = 3
): Promise<T> {
  let lastError: Error | null = null;

  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await anthropic.messages.create({
        model: 'claude-sonnet-4-20250514',
        max_tokens: 1024,
        system: `Responde SOLO con JSON válido. Sin texto adicional.`,
        messages: [{ role: 'user', content: prompt }]
      });

      const raw = response.content[0].text;
      const parsed = parseAIJson(raw);
      return schema.parse(parsed);  // ✅ Válido
    } catch (error) {
      lastError = error as Error;
      console.warn(`Intento ${i + 1} fallido:`, error.message);
    }
  }

  throw new Error(`Fallo tras ${maxRetries} intentos: ${lastError?.message}`);
}

// Uso
const producto = await extractWithRetry(
  ProductoSchema,
  'Extrae datos: MacBook Air M4, 1.299€, 16GB RAM, 512GB SSD'
);
```

Si estás experimentando errores 429 durante los reintentos, consulta la guía de [cómo solucionar el Error 429 en APIs de IA](/blog/error-429-too-many-requests-api-ia-2026/) para implementar backoff exponencial.

## Cuándo usar cada técnica

| Escenario | Técnica recomendada |
|-----------|-------------------|
| Solo usas OpenAI (gpt-4.1) | Structured Outputs (`strict: true`) |
| Usas Claude o modelos open-source | Parser robusto + Zod |
| Multi-proveedor (OpenAI + Claude) | Structured Outputs en OpenAI, parser + Zod en el resto |
| Datos críticos (pagos, médicos) | Structured Outputs + Zod como segunda validación |
| Prototipo rápido | `JSON.parse()` + try/catch básico |

## Errores comunes y cómo evitarlos

### 1. No definir `additionalProperties: false`

Sin esto, la IA puede inventar campos que pasan el parseo pero rompen tu lógica:

```javascript
// ❌ Sin restricción
schema: { properties: { nombre: { type: 'string' } } }
// La IA puede añadir: { nombre: "X", opinion_personal: "Me encanta este producto" }

// ✅ Con restricción
schema: { properties: { nombre: { type: 'string' } }, additionalProperties: false }
```

### 2. Prompt demasiado vago

```
// ❌ Vago → la IA decide el formato
"Dame los datos del producto"

// ✅ Específico → formato predecible
"Extrae nombre (string), precio (number en €) y specs (array de strings)"
```

Para aprender a escribir prompts que produzcan output estructurado fiable, revisa los [20 mejores prompts para programar con IA](/blog/mejores-prompts-programar-ia-2026/).

### 3. No manejar JSON truncado

Si el `max_tokens` es bajo, el JSON puede cortarse:

```javascript
// Detectar respuesta truncada
if (response.choices[0].finish_reason === 'length') {
  throw new Error('Respuesta truncada: aumenta max_tokens');
}
```

Este problema está directamente relacionado con el [error de context length exceeded](/blog/error-context-length-exceeded-openai-claude-2026/): si tu prompt de entrada consume casi todo el límite, queda poco espacio para la respuesta.

## Checklist para producción

- [ ] Schema definido con Zod (validación + tipos)
- [ ] `additionalProperties: false` en schemas de OpenAI
- [ ] Parser robusto que maneja texto extra y markdown
- [ ] Retry automático (2-3 intentos) con backoff
- [ ] Detección de respuesta truncada (`finish_reason`)
- [ ] Logging de respuestas inválidas para mejorar prompts
- [ ] Fallback a modelo alternativo si falla el principal

Si estás construyendo un sistema que integra la IA con APIs externas, el [function calling](/blog/function-calling-openai-claude-conectar-ia-apis-2026/) es una alternativa más robusta que parsear JSON manualmente.

## Conclusión

El JSON roto es el error más común al integrar LLMs, pero tiene solución:

1. **OpenAI**: usa Structured Outputs — JSON garantizado
2. **Claude y otros**: parser robusto + prompt estricto
3. **Siempre**: valida con Zod antes de usar los datos

La combinación de estas tres capas elimina el 99.9% de los errores de parseo en producción. Si quieres probar esto con APIs gratuitas antes de pagar, revisa [cómo usar la API de ChatGPT y Claude gratis](/blog/usar-api-chatgpt-claude-gratis-2026/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cómo Programar con IA sin Arruinarte: Guía Completa 2026]]></title>
      <link>https://francobosg.netlify.app/blog/programar-con-ia-sin-arruinarte-guia-2026/</link>
      <description><![CDATA[Guía práctica para reducir el coste de usar IA al programar. 10 técnicas reales para ahorrar en tokens, APIs y suscripciones. Con cálculos y ejemplos de código.]]></description>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/programar-con-ia-sin-arruinarte-guia-2026/</guid>
      <category>IA</category>
      <category>Gratis</category>
      <category>Tutorial</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Estás programando con IA. Te suscribiste a Cursor ($20/mes), usas la API de Claude para tu agente ($50+/mes) y de vez en cuando pagas por Copilot ($10/mes). Sin darte cuenta, gastas **$80/mes** en herramientas de IA.

¿Quieres bajar a **$10-15/mes** sin perder productividad? Aquí van las 10 técnicas que uso y que combinadas pueden ahorrar un 80-90% del coste.

## El mapa completo de costes de IA para devs

Antes de optimizar, veamos dónde se va el dinero:

| Categoría | Coste típico | Ejemplo |
|-----------|-------------|---------|
| **Suscripción IDE** | $10-20/mes | Cursor Pro, GitHub Copilot |
| **API para agentes** | $20-200/mes | OpenAI, Anthropic, Google |
| **Chat asistente** | $0-20/mes | ChatGPT Plus, Claude Pro |
| **Modelos locales** | $0/mes | Ollama (pero necesitas hardware) |
| **Total típico** | **$40-120/mes** | — |

Para ver precios exactos de cada modelo, consulta la [calculadora de precios de IA actualizada](/blog/calculadora-precios-ia-2026/).

## Técnica 1: Usa el modelo correcto para cada tarea

El error más caro: usar Claude Opus 4 ($15/M tokens input) para todo. El 80% de las tareas de un dev se resuelven con modelos que cuestan 100x menos.

| Tarea | Modelo recomendado | Coste input/1M |
|-------|-------------------|---------------|
| Autocompletado | GPT-4.1 nano / modelo local | $0.10 |
| Clasificar/resumir | GPT-4.1 mini | $0.40 |
| Generar código estándar | GPT-4.1 / Claude Sonnet 4 | $2-3 |
| Arquitectura compleja | Claude Opus 4 / o3 | $15 |
| Tests simples | DeepSeek V3 | $0.27 |

```javascript
// Ejemplo: router por tipo de tarea
function elegirModelo(tarea) {
  const modelos = {
    autocompletado: 'gpt-4.1-nano',     // $0.10/M
    clasificacion: 'gpt-4.1-mini',       // $0.40/M
    codigo: 'gpt-4.1',                   // $2.00/M
    arquitectura: 'claude-sonnet-4',     // $3.00/M
    critico: 'claude-opus-4',            // $15.00/M — solo cuando de verdad hace falta
  };
  return modelos[tarea] || 'gpt-4.1-mini';
}
```

**Ahorro estimado**: 60-70% si antes usabas un modelo premium para todo.

## Técnica 2: Caveman Prompting — menos palabras, misma respuesta

En vez de escribir prompts con frases completas y formales, escribe lo mínimo necesario. Los LLMs entienden perfectamente:

```
// ❌ 52 tokens
"Por favor, crea una función en TypeScript que reciba un array de números 
y devuelva la suma de todos los elementos que sean mayores que cero"

// ✅ 14 tokens  
"fn TS: suma números positivos de array"
```

En un flujo de agente con miles de llamadas, esto reduce el coste un 50-70%. Tengo un artículo completo sobre [caveman prompting](/blog/caveman-prompting-ahorrar-tokens-ia-2026/) con todas las reglas y comparativas.

## Técnica 3: Modelos locales para desarrollo

Para desarrollo local (autocompletado, tests, prototipos), no necesitas pagar API:

```bash
# Instalar Ollama
winget install Ollama.Ollama

# Descargar modelo de código
ollama pull qwen2.5-coder:7b

# Usarlo desde VS Code con extensión Continue
# → Autocompletado gratuito sin enviar código a la nube
```

**Cuando usar local vs API**:

| Escenario | Local (Ollama) | API (OpenAI/Claude) |
|-----------|---------------|-------------------|
| Autocompletado | ✅ Perfecto | Excesivo y caro |
| Generar función simple | ✅ Suficiente | No necesario |
| Refactoring complejo | ❌ Limitado | ✅ Necesario |
| Analizar proyecto entero | ❌ Contexto corto | ✅ 1M tokens |
| Sin internet | ✅ Funciona | ❌ No funciona |

Tutorial completo: [Cómo usar IA en local con Ollama](/blog/ollama-ia-local-gratis-sin-api-2026/).

## Técnica 4: Prompt Caching — ahorra 90% en prompts repetitivos

Si tu agente usa el mismo system prompt en cada llamada, estás pagando por los mismos tokens una y otra vez. OpenAI y Anthropic cachean automáticamente los prefijos idénticos:

```javascript
// El system prompt se cachea después de la primera llamada
const systemPrompt = `experto dev fullstack. analiza código. da solución con código.
si no seguro → dilo, no inventar.`;  // caveman + se cachea

// 1ª llamada: pagas 100% del system prompt
// 2ª-Nª llamadas: pagas solo 10-25% del system prompt
const response = await openai.chat.completions.create({
  model: 'gpt-4.1',
  messages: [
    { role: 'system', content: systemPrompt },  // cacheado
    { role: 'user', content: codigoDelUsuario }, // solo esto cambia
  ],
});
```

Artículo detallado: [Prompt Caching en OpenAI y Claude](/blog/prompt-caching-openai-claude-ahorrar-tokens-2026/).

## Técnica 5: Aider en vez de Cursor para uso intensivo

Cursor Pro cuesta $20/mes con un límite de peticiones premium. Si las agotas, pagas más. Aider usa tu propia API key — pagas solo lo que consumes:

| Uso mensual | Coste con Cursor | Coste con Aider + API |
|-------------|------------------|-----------------------|
| 100 consultas/mes | $20 (sobra cuota) | ~$3-5 |
| 500 consultas/mes | $20 (ajustado) | ~$8-15 |
| 2000 consultas/mes | $40+ (cuota extra) | ~$20-35 |
| 5000 consultas/mes | $60+ | ~$40-70 |

Para pocas consultas, Cursor sale igual. Para uso intensivo, Aider gana. Guía: [Aider: programa con IA desde la terminal](/blog/aider-ia-terminal-barata-alternativa-cursor-2026/).

Para una comparativa de los editores con IA, lee [Cursor vs Copilot vs Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/).

## Técnica 6: Tiers gratuitos de APIs

Antes de pagar, exprime los tiers gratuitos que existen en 2026:

| Proveedor | Gratis incluido | Límite |
|-----------|----------------|--------|
| **Google Gemini API** | Gemini 2.0 Flash | 15 RPM, 1M tokens/min |
| **OpenAI** | $5 créditos iniciales | Se agotan |
| **DeepSeek** | Chat web ilimitado | Sin API gratis |
| **Groq** | API con modelos open source | Rate limits generosos |
| **Together AI** | $25 créditos | Se agotan |
| **Mistral** | API tier gratuito | Límites bajos |

Guía completa: [Cómo usar APIs de IA gratis](/blog/usar-api-chatgpt-claude-gratis-2026/).

## Técnica 7: Reduce el contexto que envías

Si tu agente envía 50.000 tokens de código para que la IA analice un bug de 5 líneas, estás tirando dinero. Estrategias:

### Solo envía lo relevante

```javascript
// ❌ Enviar todo el proyecto
const contexto = await fs.readFile('src/app.js', 'utf-8'); // 5000 líneas

// ✅ Enviar solo lo relevante
const contexto = extraerFuncion('src/app.js', 'handleLogin'); // 40 líneas
```

### Tree-sitter para extraer funciones

```bash
# Aider ya hace esto automáticamente con "repo map"
# Envía la estructura del proyecto (funciones, clases) sin el código completo
aider --map-tokens 1024  # Limitar el mapa a 1024 tokens
```

### Chunking inteligente para RAG

Si usas RAG, no envíes documentos completos — usa embeddings para enviar solo los fragmentos que responden la pregunta:

```javascript
// Buscar los 3 fragmentos más relevantes, no el documento entero
const chunks = await vectorStore.similaritySearch(pregunta, 3);
const contexto = chunks.map(c => c.pageContent).join('\n---\n');
```

Tutorial de RAG: [Cómo crear un chatbot RAG con OpenAI](/blog/crear-chatbot-rag-openai-tutorial-2026/).

## Técnica 8: Batching de requests

Si haces 10 llamadas secuenciales a la API, combínalas en una sola con instrucciones claras:

```javascript
// ❌ 10 llamadas separadas (10x coste de prompt)
for (const archivo of archivos) {
  await analizar(archivo); // Cada una envía el system prompt completo
}

// ✅ 1 llamada con batch
const prompt = `analiza estos 10 archivos. por cada uno: bugs + sugerencias.

${archivos.map((a, i) => `### Archivo ${i+1}: ${a.nombre}\n${a.codigo}`).join('\n\n')}`;

await analizar(prompt); // 1 sola llamada, 1 solo system prompt
```

**Cuidado**: esto funciona bien hasta ~20-30K tokens de input. Más allá, la calidad baja. Para modelos con ventana grande (1M tokens), funciona mejor.

Si te encuentras errores de contexto demasiado largo, lee [cómo solucionar el error "context length exceeded"](/blog/error-context-length-exceeded-openai-claude-2026/).

## Técnica 9: Caché local de respuestas

Para preguntas frecuentes o repetidas, cachea las respuestas:

```javascript
import { createHash } from 'crypto';

const cache = new Map();

async function consultarConCache(prompt, model = 'gpt-4.1-mini') {
  const key = createHash('md5').update(`${model}:${prompt}`).digest('hex');
  
  if (cache.has(key)) {
    console.log('Cache hit — $0');
    return cache.get(key);
  }

  const response = await openai.chat.completions.create({
    model,
    messages: [{ role: 'user', content: prompt }],
  });

  const result = response.choices[0].message.content;
  cache.set(key, result);
  return result;
}
```

Para una solución más avanzada con búsqueda semántica, usa [GPTCache](/blog/repos-github-desarrolladores-ia-2026/#5-gptcache--cachea-respuestas-y-ahorra-un-90).

## Técnica 10: Monitoriza lo que gastas

No puedes optimizar lo que no mides. Activa el tracking de tokens:

```javascript
const response = await openai.chat.completions.create({
  model: 'gpt-4.1',
  messages: [...],
});

// OpenAI te dice exactamente cuánto consumiste
const { prompt_tokens, completion_tokens, total_tokens } = response.usage;
const coste = (prompt_tokens * 2 + completion_tokens * 8) / 1_000_000;
console.log(`Tokens: ${total_tokens} | Coste: $${coste.toFixed(4)}`);
```

### Dashboard semanal de costes

Lleva un registro semanal. Si notas un pico, revisa qué agente o flujo lo causó:

| Semana | Tokens usados | Coste | Modelo principal |
|--------|--------------|-------|-----------------|
| Sem 1 | 2.4M | $12.30 | Claude Sonnet 4 |
| Sem 2 | 1.1M | $4.20 | GPT-4.1 (tras optimizar) |
| Sem 3 | 0.8M | $1.80 | GPT-4.1 + caching |
| Sem 4 | 0.6M | $0.95 | + caveman + triage |

## Plan de acción: de $80/mes a $10/mes

| Paso | Acción | Ahorro estimado |
|------|--------|----------------|
| 1 | Clasificar tareas por complejidad → modelo adecuado | 60% |
| 2 | [Caveman prompting](/blog/caveman-prompting-ahorrar-tokens-ia-2026/) en system prompts | +15% |
| 3 | [Ollama](/blog/ollama-ia-local-gratis-sin-api-2026/) para autocompletado y tareas simples | +10% |
| 4 | [Prompt caching](/blog/prompt-caching-openai-claude-ahorrar-tokens-2026/) en flujos repetitivos | +10% |
| 5 | Reducir contexto enviado (solo código relevante) | +5% |
| **Total** | | **~80-90%** |

## Ejemplo real: mi setup actual

| Herramienta | Uso | Coste/mes |
|-------------|-----|-----------|
| **Ollama** (local) | Autocompletado, prototipos | $0 |
| **GPT-4.1 nano** (API) | Triage, clasificación | ~$1 |
| **GPT-4.1** (API) | Código, agentes | ~$5 |
| **Claude Sonnet 4** (API) | Refactoring complejo | ~$4 |
| **Aider** (terminal) | Editor IA principal | $0 (usa mis API keys) |
| **Total** | | **~$10/mes** |

Antes gastaba $75/mes. Ahora $10. La diferencia no es usar peor IA — es usar la IA correcta para cada tarea.

## Conclusión

No necesitas gastar $100/mes para programar con IA en 2026. Con las técnicas correctas:

1. **Elige el modelo por tarea**, no por marca
2. **Comprime prompts** con caveman prompting
3. **Usa modelos locales** para lo que no necesita nube
4. **Cachea** todo lo que puedas
5. **Mide** y ajusta cada semana

La IA más cara no es siempre la mejor para tu caso de uso. La IA **más eficiente** es la que resuelve tu problema al menor coste.

Para empezar a probar APIs sin gastar nada, revisa las [alternativas gratuitas a ChatGPT](/blog/alternativas-gratis-chatgpt-2026/) y los [mejores modelos de IA para programar](/blog/mejores-modelos-ia-para-programar-2026/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Prompt Caching en OpenAI y Claude: Ahorra hasta un 90% en Tokens]]></title>
      <link>https://francobosg.netlify.app/blog/prompt-caching-openai-claude-ahorrar-tokens-2026/</link>
      <description><![CDATA[Guía práctica de prompt caching en OpenAI y Anthropic. Cómo funciona, cuánto ahorra, configuración en Node.js y Python, y cuándo activarlo para reducir costes de API.]]></description>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/prompt-caching-openai-claude-ahorrar-tokens-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Cada vez que tu agente hace una llamada a la API, envía el mismo system prompt, las mismas instrucciones, el mismo contexto base. Y pagas por esos tokens **cada vez** como si fueran nuevos.

Con prompt caching, OpenAI y Anthropic guardan ese prefijo repetido en sus servidores y te cobran entre un **50% y un 90% menos** en las siguientes llamadas.

## ¿Cómo funciona el prompt caching?

El concepto es simple: si dos requests consecutivos empiezan con los **mismos tokens** (system prompt, instrucciones fijas, contexto base), el proveedor no los reprocesa. Solo procesa los tokens nuevos (la pregunta del usuario, el código específico).

```
Request 1:
[System prompt: 2000 tokens] + [User: 500 tokens]  → Pago: 2500 tokens (100%)

Request 2:
[System prompt: 2000 tokens] + [User: 300 tokens]  → Pago: 2000 tokens al 10% + 300 al 100%
                                ↑ cacheado                  = 500 tokens equivalentes

Ahorro: 80% del total
```

## Prompt Caching en OpenAI

### Cómo se activa

**Se activa automáticamente.** No necesitas configurar nada. OpenAI cachea los primeros 1024+ tokens que sean idénticos entre llamadas consecutivas.

### Requisitos

- Prefijo de **1024 tokens mínimo** (idéntico byte a byte)
- Incrementos de **128 tokens** a partir de ahí
- El caché dura entre **5 y 10 minutos** de inactividad
- Modelos soportados: GPT-4o, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano, o3, o4-mini

### Descuento

**50% de descuento** en tokens cacheados.

### Ejemplo en Node.js

```javascript
import OpenAI from 'openai';

const openai = new OpenAI();

// System prompt largo (>1024 tokens para activar caché)
const systemPrompt = `Eres un revisor de código experto en TypeScript, Node.js y React.

REGLAS:
1. Analiza el código línea por línea
2. Busca: bugs, vulnerabilidades de seguridad, problemas de rendimiento
3. Clasifica cada issue: critico / medio / bajo
4. Da la solución con código corregido
5. Si el código está bien, di "Sin issues"

FORMATO DE RESPUESTA:
- archivo: nombre del archivo
- linea: número de línea
- severidad: critico | medio | bajo  
- descripcion: qué pasa
- solucion: código corregido

CONTEXTO DEL PROYECTO:
Framework: NestJS 11 con TypeScript 5.8
Base de datos: PostgreSQL con Prisma ORM
Auth: JWT con refresh tokens
Testing: Jest + Supertest
Arquitectura: módulos por dominio (users, auth, payments, notifications)
Convenciones: camelCase, barrel exports, DTOs con class-validator
...`; // Esto ya pasa de 1024 tokens

// Primera llamada — se cachea el system prompt
const resp1 = await openai.chat.completions.create({
  model: 'gpt-4.1',
  messages: [
    { role: 'system', content: systemPrompt },
    { role: 'user', content: 'Revisa este código:\n```ts\n' + codigoArchivo1 + '\n```' },
  ],
});

console.log(resp1.usage);
// { prompt_tokens: 2847, completion_tokens: 340,
//   prompt_tokens_details: { cached_tokens: 0 } }  ← primera vez, no hay caché

// Segunda llamada — system prompt viene del caché
const resp2 = await openai.chat.completions.create({
  model: 'gpt-4.1',
  messages: [
    { role: 'system', content: systemPrompt },  // idéntico → cacheado
    { role: 'user', content: 'Revisa este código:\n```ts\n' + codigoArchivo2 + '\n```' },
  ],
});

console.log(resp2.usage);
// { prompt_tokens: 2650, completion_tokens: 280,
//   prompt_tokens_details: { cached_tokens: 2048 } }  ← ¡2048 tokens cacheados!
```

**En `resp2`, esos 2048 tokens cacheados te cuestan la mitad**: $1.00/M en vez de $2.00/M con GPT-4.1.

## Prompt Caching en Anthropic (Claude)

### Cómo se activa

En Anthropic **necesitas activarlo explícitamente** añadiendo `cache_control` a los bloques de mensaje que quieres cachear.

### Requisitos

- Mínimo **1024 tokens** para Claude Sonnet/Opus, **2048** para Haiku
- Máximo **4 bloques** con cache_control por request
- El caché dura **5 minutos** (se renueva con cada uso)
- Modelos soportados: Claude Opus 4, Claude Sonnet 4, Claude Haiku 3.5

### Descuento

- **Escritura al caché**: 25% más caro que el precio normal (solo la primera vez)
- **Lectura del caché**: **90% de descuento** (las siguientes veces)

El breakeven está en **~3-4 llamadas** con el mismo prefijo. A partir de ahí, todo es ahorro.

### Ejemplo en Node.js

```javascript
import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic();

const systemPrompt = `Eres un revisor de código experto en TypeScript...
[mismo prompt largo de antes, >1024 tokens]`;

// Llamada con cache_control
const response = await anthropic.messages.create({
  model: 'claude-sonnet-4-20250514',
  max_tokens: 1024,
  system: [
    {
      type: 'text',
      text: systemPrompt,
      cache_control: { type: 'ephemeral' },  // ← Esto activa el caching
    },
  ],
  messages: [
    { role: 'user', content: `Revisa:\n${codigoArchivo}` },
  ],
});

console.log(response.usage);
// Primera llamada:
// { input_tokens: 2847, output_tokens: 340,
//   cache_creation_input_tokens: 2048, cache_read_input_tokens: 0 }

// Siguientes llamadas:
// { input_tokens: 799, output_tokens: 280,
//   cache_creation_input_tokens: 0, cache_read_input_tokens: 2048 }
//                                    ↑ 2048 tokens al 10% del precio
```

### Cachear también el contexto (documentos, código base)

Puedes cachear no solo el system prompt sino también documentos de referencia:

```javascript
const response = await anthropic.messages.create({
  model: 'claude-sonnet-4-20250514',
  max_tokens: 2048,
  system: [
    {
      type: 'text',
      text: systemPrompt,
      cache_control: { type: 'ephemeral' },  // Caché bloque 1
    },
  ],
  messages: [
    {
      role: 'user',
      content: [
        {
          type: 'text',
          text: `Documentación del proyecto:\n${documentacion}`,
          cache_control: { type: 'ephemeral' },  // Caché bloque 2
        },
        {
          type: 'text',
          text: `Código base:\n${codigoBase}`,
          cache_control: { type: 'ephemeral' },  // Caché bloque 3
        },
        {
          type: 'text',
          text: 'Añade endpoint POST /api/users con validación',  // Solo esto es nuevo
        },
      ],
    },
  ],
});
```

Así solo pagas precio completo por la instrucción final. Todo lo demás viene del caché al **10% del precio**.

## Ejemplo en Python

### OpenAI (automático)

```python
from openai import OpenAI

client = OpenAI()

system_prompt = """...[prompt largo >1024 tokens]..."""

# Misma API de siempre — el caché es transparente
for archivo in archivos_a_revisar:
    resp = client.chat.completions.create(
        model="gpt-4.1",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": f"revisa:\n{archivo.codigo}"},
        ],
    )
    # resp.usage.prompt_tokens_details.cached_tokens > 0 a partir de la 2ª llamada
    print(f"Cacheados: {resp.usage.prompt_tokens_details.cached_tokens}")
```

### Anthropic (explícito)

```python
import anthropic

client = anthropic.Anthropic()

system_prompt = """...[prompt largo >1024 tokens]..."""

for archivo in archivos_a_revisar:
    resp = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=1024,
        system=[{
            "type": "text",
            "text": system_prompt,
            "cache_control": {"type": "ephemeral"},
        }],
        messages=[
            {"role": "user", "content": f"revisa:\n{archivo.codigo}"},
        ],
    )
    print(f"Cache read: {resp.usage.cache_read_input_tokens}")
```

## Comparativa de ahorro real

### Escenario: Agente revisor de código

- System prompt: 2000 tokens
- Código por request: 800 tokens
- 100 requests/día

| Sin caching | Con caching (OpenAI) | Con caching (Anthropic) |
|------------|---------------------|------------------------|
| 280K tokens/día (100%) | 80K nuevos + 200K cacheados | 80K nuevos + 200K cacheados |
| **GPT-4.1**: $0.56/día | $0.36/día (**-36%**) | — |
| **Claude Sonnet 4**: $0.84/día | — | $0.30/día (**-64%**) |
| **Mensual GPT-4.1**: $16.80 | **$10.80** | — |
| **Mensual Claude Sonnet**: $25.20 | — | **$9.00** |

### Escenario: Chatbot con contexto largo

- System prompt + base de conocimiento: 15.000 tokens
- Pregunta del usuario: 200 tokens
- 500 requests/día

| Sin caching | Con caching (OpenAI) | Con caching (Anthropic) |
|------------|---------------------|------------------------|
| 7.6M tokens/día | 100K nuevos + 7.5M cacheados | 100K nuevos + 7.5M cacheados |
| **GPT-4.1**: $15.20/día | $7.70/día (**-49%**) | — |
| **Claude Sonnet 4**: $22.80/día | — | $2.55/día (**-89%**) |
| **Mensual Claude Sonnet**: $684 | — | **$76.50** |

En contextos largos repetitivos, Anthropic con caching es brutalmente más barato.

## Cuándo usar y cuándo NO usar prompt caching

### ✅ Usar cuando:

| Caso | Por qué |
|------|---------|
| System prompts fijos en agentes | Se repiten idénticos miles de veces |
| Chatbots con base de conocimiento | El contexto es el mismo para todos los usuarios |
| Revisión de código en batch | Mismo prompt para N archivos |
| RAG con documentos estables | Los docs no cambian entre queries |
| Flujos multi-paso | El contexto del paso 1 se reutiliza en pasos 2-5 |

### ❌ No usar (no tiene efecto) cuando:

| Caso | Por qué |
|------|---------|
| Cada prompt es único | No hay prefijo común que cachear |
| System prompt < 1024 tokens | No cumple el mínimo |
| Llamadas espaciadas > 10 min | El caché expira |
| Prompt dinámico que cambia siempre | Nada que cachear |

## Combinando Prompt Caching con otras técnicas

El caching se multiplica con otras optimizaciones:

### Caching + Caveman Prompting

Usa [caveman prompting](/blog/caveman-prompting-ahorrar-tokens-ia-2026/) para las instrucciones. El system prompt caveman es más corto, pero **se cachea igual**. Ahorras en ambos frentes:

```javascript
// System prompt caveman (más corto) + caching (más barato)
const systemPrompt = `revisor código TS/Node/React.
reglas: analizar línea a línea. buscar bugs, seguridad, rendimiento.
clasificar: critico/medio/bajo. dar solución con código.
formato: archivo, linea, severidad, descripcion, solucion.
proyecto: NestJS 11, TS 5.8, PostgreSQL, Prisma, JWT, Jest.`;
```

### Caching + Modelo correcto

No uses Claude Opus 4 ($15/M) con caching si Claude Sonnet 4 ($3/M) con caching resuelve la tarea. Primero elige el modelo correcto, luego activa caching.

Para una comparativa completa de precios, consulta la [calculadora de precios de IA](/blog/calculadora-precios-ia-2026/).

### Caching + Batching

Si haces 10 requests seguidos, el caché se mantiene vivo entre ellos:

```javascript
// Procesar 50 archivos seguidos = caché activo todo el rato
for (const archivo of archivos) {
  const resp = await revisar(archivo);
  // A partir del 2º archivo, el system prompt viene del caché
}

// ❌ No hagas esto: esperar 15 min entre llamadas mata el caché
```

## Monitorizar el uso del caché

### OpenAI

```javascript
const resp = await openai.chat.completions.create({ ... });

const { prompt_tokens, cached_tokens } = {
  prompt_tokens: resp.usage.prompt_tokens,
  cached_tokens: resp.usage.prompt_tokens_details?.cached_tokens || 0,
};

const hitRate = cached_tokens / prompt_tokens * 100;
console.log(`Cache hit rate: ${hitRate.toFixed(1)}%`);
// Objetivo: >70% en flujos repetitivos
```

### Anthropic

```javascript
const resp = await anthropic.messages.create({ ... });

const cacheRead = resp.usage.cache_read_input_tokens || 0;
const total = resp.usage.input_tokens + cacheRead;
const hitRate = cacheRead / total * 100;
console.log(`Cache hit rate: ${hitRate.toFixed(1)}%`);
```

Si tu hit rate es bajo (<50%), revisa que el prefijo sea **idéntico byte a byte** entre llamadas. Una diferencia de un espacio rompe el caché.

## Conclusión

Prompt caching es la optimización más infravalorada para reducir costes de API:

1. **En OpenAI es gratis y automático** — solo asegúrate de que tu system prompt > 1024 tokens y las llamadas sean frecuentes
2. **En Anthropic necesitas `cache_control`** pero el descuento del 90% lo compensa con creces
3. **Combínalo con [caveman prompting](/blog/caveman-prompting-ahorrar-tokens-ia-2026/)** y **modelo correcto** para ahorrar un 80-90% total
4. **Monitoriza el hit rate** para verificar que funciona

Si estás construyendo agentes o chatbots con contexto repetitivo, prompt caching puede ser la diferencia entre $500/mes y $50/mes.

Para la guía completa con las 10 técnicas de ahorro, lee [cómo programar con IA sin arruinarte](/blog/programar-con-ia-sin-arruinarte-guia-2026/). Y para los repos que implementan estas técnicas, revisa los [7 repos de GitHub para devs de IA](/blog/repos-github-desarrolladores-ia-2026/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[7 Repositorios de GitHub que Todo Desarrollador de IA Debería Clonar en 2026]]></title>
      <link>https://francobosg.netlify.app/blog/repos-github-desarrolladores-ia-2026/</link>
      <description><![CDATA[Los 7 repos de GitHub más útiles para desarrolladores que trabajan con IA: frameworks, herramientas de ahorro de tokens, agentes y plantillas de producción. Actualizados a 2026.]]></description>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/repos-github-desarrolladores-ia-2026/</guid>
      <category>IA</category>
      <category>Herramientas</category>
      <category>Código</category>
      <content:encoded><![CDATA[Cada semana aparecen 50 repos nuevos de IA en GitHub. El 95% son demos que no sirven para producción. Aquí van los **7 que realmente uso** y que te van a ahorrar tiempo y dinero si trabajas con modelos de IA.

Todos son open source, activamente mantenidos y con enfoque práctico.

## 1. LiteLLM — Un gateway para gobernarlos a todos

**⭐ Estrellas**: 18K+ | **Lenguaje**: Python | [GitHub](https://github.com/BerriAI/litellm)

LiteLLM te da una **interfaz única compatible con OpenAI** para llamar a +100 modelos (Claude, Gemini, DeepSeek, Ollama, etc.). ¿Por qué importa? Porque puedes cambiar de modelo sin cambiar tu código.

### Lo que ahorra:

```python
from litellm import completion

# Mismo código, cualquier modelo
response = completion(
    model="gpt-4.1-nano",       # Tarea simple → modelo barato
    messages=[{"role": "user", "content": "clasifica: bug o feature"}]
)

# Cuando necesitas potencia → cambias una línea
response = completion(
    model="claude-sonnet-4",    # Tarea compleja → modelo potente
    messages=[{"role": "user", "content": "refactora este módulo de auth..."}]
)
```

### Router con fallback por coste:

```python
from litellm import Router

router = Router(
    model_list=[
        {"model_name": "barato", "litellm_params": {"model": "gpt-4.1-nano"}},
        {"model_name": "potente", "litellm_params": {"model": "claude-sonnet-4"}},
    ],
    routing_strategy="cost-optimized",  # Elige el más barato que pueda
)
```

**Combo perfecto**: usa LiteLLM como gateway + [caveman prompting](/blog/caveman-prompting-ahorrar-tokens-ia-2026/) en las instrucciones = máximo ahorro.

Si quieres ver cuánto cuesta cada modelo antes de elegir, consulta la [calculadora de precios de IA](/blog/calculadora-precios-ia-2026/).

---

## 2. Aider — Programar con IA desde la terminal

**⭐ Estrellas**: 30K+ | **Lenguaje**: Python | [GitHub](https://github.com/Aider-AI/aider)

Aider es un asistente de código que funciona desde la terminal. Edita archivos directamente, hace commits, y soporta cualquier modelo via API o local.

### Por qué lo uso en vez de Cursor:

| Característica | Aider | Cursor |
|---------------|-------|--------|
| Precio | Tu API key (pago por uso) | $20/mes fijo |
| Modelo | Cualquiera (OpenAI, Claude, Ollama) | Los de su plan |
| Funciona en servidor SSH | ✅ Sí | ❌ No |
| Git integrado | ✅ Auto-commit | ❌ Manual |
| Consumo de tokens | Optimizado (map files) | Variable |

```bash
# Instalar
pip install aider-chat

# Usar con GPT-4.1 (barato y potente)
export OPENAI_API_KEY=sk-...
aider --model gpt-4.1

# O con modelo local gratis
aider --model ollama/deepseek-coder-v3
```

Si te interesa un tutorial completo, escribí una [guía de Aider como alternativa barata a Cursor](/blog/aider-ia-terminal-barata-alternativa-cursor-2026/).

---

## 3. CrewAI — Agentes que se coordinan solos

**⭐ Estrellas**: 28K+ | **Lenguaje**: Python | [GitHub](https://github.com/crewAIInc/crewAI)

Si necesitas que varios agentes trabajen juntos en una tarea compleja, CrewAI te ahorra montarlo desde cero. Cada agente tiene un rol, herramientas y un objetivo.

```python
from crewai import Agent, Task, Crew

investigador = Agent(
    role="Investigador de APIs",
    goal="encontrar mejor API para el proyecto",
    backstory="experto en evaluar APIs REST", # ← caveman en backstory ahorra tokens
    llm="gpt-4.1-nano"  # Modelo barato para investigar
)

evaluador = Agent(
    role="Evaluador técnico",
    goal="analizar pros/contras de cada opción",
    llm="claude-sonnet-4"  # Modelo potente solo para evaluar
)

# Los agentes se coordinan automáticamente
crew = Crew(agents=[investigador, evaluador], tasks=[...])
resultado = crew.kickoff()
```

**Truco de ahorro**: asigna modelos baratos a agentes con tareas simples y reserva el modelo caro para el agente que toma la decisión final. Eso puede reducir el coste un 60%.

Para construir agentes desde cero con LangChain, consulta el [tutorial de crear un agente IA con LangChain y Node.js](/blog/crear-agente-ia-langchain-nodejs-tutorial/).

---

## 4. Ollama — IA en tu PC, gratis

**⭐ Estrellas**: 120K+ | **Lenguaje**: Go | [GitHub](https://github.com/ollama/ollama)

Ejecuta modelos de IA en tu máquina sin pagar nada. Perfecto para desarrollo local, pruebas y tareas que no necesitan el mejor modelo.

```bash
# Instalar y correr un modelo
ollama pull llama4-scout
ollama run llama4-scout

# Usarlo como API local compatible con OpenAI
curl http://localhost:11434/v1/chat/completions \
  -d '{"model": "llama4-scout", "messages": [{"role": "user", "content": "hola"}]}'
```

### Modelos recomendados por uso:

| Modelo | Tamaño | RAM mínima | Mejor para |
|--------|--------|-----------|-----------|
| `qwen2.5-coder:7b` | 4.7 GB | 8 GB | Autocompletado |
| `deepseek-coder-v3:16b` | 9 GB | 16 GB | Refactoring |
| `llama4-scout` | 18 GB | 24 GB | Tareas generales |
| `codellama:34b` | 19 GB | 32 GB | Proyectos complejos |

Tengo un [tutorial completo de Ollama](/blog/ollama-ia-local-gratis-sin-api-2026/) con setup en Windows, macOS y Linux + integración con VS Code.

---

## 5. GPTCache — Cachea respuestas y ahorra un 90%

**⭐ Estrellas**: 7K+ | **Lenguaje**: Python | [GitHub](https://github.com/zilliztech/GPTCache)

Si tu app hace las mismas preguntas (o similares) muchas veces, GPTCache intercepta las llamadas y devuelve respuestas cacheadas. Ahorro brutal.

```python
from gptcache import Cache
from gptcache.adapter import openai

cache = Cache()
cache.init()

# Primera llamada → va a la API ($)
resp1 = openai.ChatCompletion.create(
    model="gpt-4.1",
    messages=[{"role": "user", "content": "qué es REST"}]
)

# Segunda llamada similar → viene del caché (gratis)
resp2 = openai.ChatCompletion.create(
    model="gpt-4.1",
    messages=[{"role": "user", "content": "explica qué es REST"}]
)
```

**Caso real**: en un chatbot de soporte técnico, GPTCache redujo las llamadas a la API un **85%** porque el 80% de las preguntas eran variaciones de las mismas 50 dudas.

Para una técnica de caché nativa sin librerías externas, lee sobre el [prompt caching de OpenAI y Claude](/blog/prompt-caching-openai-claude-ahorrar-tokens-2026/).

---

## 6. Instructor — Respuestas estructuradas sin gastar tokens en parsing

**⭐ Estrellas**: 10K+ | **Lenguaje**: Python | [GitHub](https://github.com/instructor-ai/instructor)

Instructor fuerza al modelo a devolver datos estructurados usando Pydantic. Se acabó gastar tokens en "devuélveme un JSON con este formato..." y luego que el modelo te devuelva texto plano.

```python
import instructor
from openai import OpenAI
from pydantic import BaseModel

client = instructor.from_openai(OpenAI())

class Bug(BaseModel):
    archivo: str
    linea: int
    severidad: str  # "critico" | "medio" | "bajo"
    descripcion: str

bugs = client.chat.completions.create(
    model="gpt-4.1-nano",
    response_model=list[Bug],
    messages=[{"role": "user", "content": f"bugs en este código:\n{codigo}"}]
)

# bugs es una lista de objetos Bug tipados — no hay que parsear nada
for bug in bugs:
    print(f"{bug.archivo}:{bug.linea} [{bug.severidad}] {bug.descripcion}")
```

**Ahorro**: elimina reintentos por JSON malformado (cada reintento = más tokens). Para entender el problema del parsing de JSON con LLMs, lee sobre [cómo parsear JSON de IA sin errores](/blog/parsear-json-ia-sin-errores-openai-claude-2026/).

---

## 7. Microsoft Semantic Kernel — Orquestación de IA para producción

**⭐ Estrellas**: 23K+ | **Lenguaje**: C#, Python, Java | [GitHub](https://github.com/microsoft/semantic-kernel)

Si trabajas en entorno enterprise o .NET, Semantic Kernel es la alternativa a LangChain de Microsoft. Soporta plugins, planificadores automáticos y gestión de memoria.

```python
import semantic_kernel as sk

kernel = sk.Kernel()

# Registrar múltiples modelos con prioridad por coste
kernel.add_service(
    sk.connectors.OpenAIChatCompletion("gpt-4.1-nano", api_key="...")
)

# Plugin de función personalizada
@kernel.function(name="clasificar_ticket")
async def clasificar(input: str) -> str:
    # Lógica de clasificación con modelo barato
    return resultado
```

**Ventaja de ahorro**: el planificador automático de Semantic Kernel puede elegir qué modelo usar según la complejidad de la tarea, similar al routing de LiteLLM pero con un framework más completo.

---

## Tabla resumen: ¿cuál clonar primero?

| Repo | Para qué | Ahorro potencial | Dificultad |
|------|----------|-----------------|------------|
| **LiteLLM** | Gateway multi-modelo | 30-50% (routing) | ⭐ Fácil |
| **Aider** | Código con IA en terminal | 40-60% vs Cursor | ⭐ Fácil |
| **CrewAI** | Agentes coordinados | 60% (modelo por rol) | ⭐⭐ Media |
| **Ollama** | IA local gratis | 100% (sin API) | ⭐ Fácil |
| **GPTCache** | Caché de respuestas | 80-90% (repetidas) | ⭐⭐ Media |
| **Instructor** | Outputs estructurados | 20-30% (sin reintentos) | ⭐ Fácil |
| **Semantic Kernel** | Orquestación enterprise | 30-50% (planner) | ⭐⭐⭐ Alta |

## Mi stack recomendado para máximo ahorro

```
LiteLLM (gateway) → routing por coste entre modelos
  ├── Ollama (modelos locales) → tareas simples, gratis
  ├── GPT-4.1 nano (API) → clasificación, triage
  ├── Claude Sonnet 4 (API) → código complejo
  └── GPTCache → evitar llamadas duplicadas

+ Instructor → JSON sin fallos
+ Caveman Prompting → 70% menos tokens en instrucciones
```

Con este stack, un proyecto que antes costaba **$120/mes en APIs** puede bajar a **$15-25/mes**.

Para comparar todos los precios actualizados de modelos, usa la [calculadora de precios de IA 2026](/blog/calculadora-precios-ia-2026/). Y si quieres explorar alternativas totalmente gratuitas, revisa las [mejores alternativas gratis a ChatGPT](/blog/alternativas-gratis-chatgpt-2026/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cómo Testear Código Generado por IA: Tests Automáticos para Copilot, Cursor y Claude]]></title>
      <link>https://francobosg.netlify.app/blog/testear-codigo-generado-ia-copilot-cursor-2026/</link>
      <description><![CDATA[El código de Copilot y Cursor compila pero ¿funciona? Framework de testing para validar código generado por IA: unit tests, snapshot testing, property-based testing y CI.]]></description>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/testear-codigo-generado-ia-copilot-cursor-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Copilot te genera una función en 5 segundos. Compila. El tipo de retorno es correcto. La integras en tu código y haces deploy.

Una semana después: un bug en producción. La función no manejaba arrays vacíos. O convertía `"0"` a `false`. O mutaba el argumento de entrada.

El problema no es que la IA genere "mal código" — es que genera código que **parece** correcto pero no cubre los edge cases de tu dominio. La solución: **tests automatizados** diseñados específicamente para validar código de IA.

## Por qué el código de IA necesita tests especiales

Los LLMs tienen patrones de error predecibles:

| Tipo de error | Ejemplo | Frecuencia |
|--------------|---------|-----------|
| **Edge cases ignorados** | No maneja `null`, `undefined`, arrays vacíos | Muy común |
| **Off-by-one** | `<=` en vez de `<`, índices incorrectos | Común |
| **Mutación de inputs** | Modifica el array/objeto original | Común |
| **Tipos implícitos** | Asume string cuando puede ser number | Ocasional |
| **Lógica de negocio** | Implementa una aproximación, no la regla exacta | Frecuente |
| **Seguridad** | SQL injection, XSS, path traversal | Ocasional |

Un test suite convencional podría no detectar estos errores porque suele testar el "camino feliz". Necesitas una estrategia específica.

## Estrategia 1: Test-First (la más robusta)

Escribe los tests **antes** de pedirle el código a la IA. Así:
1. Defines exactamente el comportamiento esperado
2. La IA genera la implementación
3. Corres los tests → pasan o no

```javascript
// 1. TÚ escribes el test primero
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './pricing.js';

describe('calculateDiscount', () => {
  // Happy path
  it('aplica 10% para compras > 100€', () => {
    expect(calculateDiscount(150)).toBe(135);
  });

  // Edge cases que la IA probablemente ignorará
  it('no aplica descuento para compras <= 100€', () => {
    expect(calculateDiscount(100)).toBe(100);
    expect(calculateDiscount(50)).toBe(50);
  });

  it('maneja 0€ correctamente', () => {
    expect(calculateDiscount(0)).toBe(0);
  });

  it('rechaza valores negativos', () => {
    expect(() => calculateDiscount(-10)).toThrow();
  });

  it('redondea a 2 decimales', () => {
    expect(calculateDiscount(101)).toBe(90.90);
  });

  it('maneja valores no numéricos', () => {
    expect(() => calculateDiscount('abc')).toThrow();
    expect(() => calculateDiscount(null)).toThrow();
  });
});

// 2. Ahora le pides a Copilot/Cursor que implemente calculateDiscount
// 3. Corres los tests: vitest run
```

> **Tip**: Puedes pedirle a la IA que genere el esqueleto de tests, pero **siempre** revisa y añade edge cases manualmente. La IA tiende a generar tests que "pasan" su propia implementación — no los que la desafían.

## Estrategia 2: Snapshot Testing para output complejo

Cuando la IA genera funciones que producen output complejo (HTML, JSON, configuraciones), usa snapshot testing para detectar regresiones:

```javascript
import { describe, it, expect } from 'vitest';
import { generateEmailTemplate } from './email.js';

describe('generateEmailTemplate', () => {
  it('genera template de bienvenida correctamente', () => {
    const html = generateEmailTemplate({
      type: 'welcome',
      userName: 'Fran',
      planName: 'Pro'
    });

    // La primera vez crea el snapshot, las siguientes compara
    expect(html).toMatchSnapshot();
  });

  it('escapa caracteres HTML en el nombre de usuario', () => {
    const html = generateEmailTemplate({
      type: 'welcome',
      userName: '<script>alert("xss")</script>',
      planName: 'Pro'
    });

    expect(html).not.toContain('<script>');
    expect(html).toContain('&lt;script&gt;');
  });
});
```

## Estrategia 3: Property-Based Testing

En vez de testear casos específicos, defines **propiedades** que siempre deben cumplirse. [fast-check](https://github.com/dubzzz/fast-check) genera cientos de inputs aleatorios:

```javascript
import { describe, it } from 'vitest';
import fc from 'fast-check';
import { sortProducts } from './catalog.js';

describe('sortProducts (generado por IA)', () => {
  it('siempre devuelve la misma cantidad de elementos', () => {
    fc.assert(
      fc.property(
        fc.array(fc.record({
          name: fc.string(),
          price: fc.float({ min: 0, max: 10000 }),
        })),
        (products) => {
          const sorted = sortProducts(products, 'price');
          return sorted.length === products.length;
        }
      )
    );
  });

  it('el resultado está ordenado por precio', () => {
    fc.assert(
      fc.property(
        fc.array(fc.record({
          name: fc.string(),
          price: fc.float({ min: 0, max: 10000, noNaN: true }),
        }), { minLength: 2 }),
        (products) => {
          const sorted = sortProducts(products, 'price');
          for (let i = 1; i < sorted.length; i++) {
            if (sorted[i].price < sorted[i - 1].price) return false;
          }
          return true;
        }
      )
    );
  });

  it('no muta el array original', () => {
    fc.assert(
      fc.property(
        fc.array(fc.record({
          name: fc.string(),
          price: fc.float({ min: 0, max: 10000 }),
        })),
        (products) => {
          const original = JSON.parse(JSON.stringify(products));
          sortProducts(products, 'price');
          return JSON.stringify(products) === JSON.stringify(original);
        }
      )
    );
  });
});
```

Property-based testing es especialmente potente contra código de IA porque genera inputs que un humano no pensaría: strings vacíos, arrays de 1000 elementos, números `NaN`, `Infinity`, caracteres unicode raros.

## Estrategia 4: Checklist de seguridad

Los editores de IA como [Cursor, Copilot y Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/) generan código funcional, pero rara vez aplican las mejores prácticas de seguridad por defecto:

```javascript
import { describe, it, expect } from 'vitest';
import { searchUsers } from './users.js';

describe('seguridad: searchUsers (generado por IA)', () => {
  it('no es vulnerable a SQL injection', async () => {
    // La IA podría haber generado: `SELECT * FROM users WHERE name = '${query}'`
    const maliciousInput = "'; DROP TABLE users; --";
    // Si no tira error y no borra la tabla, está bien
    await expect(searchUsers(maliciousInput)).resolves.toBeDefined();
  });

  it('no expone campos sensibles', async () => {
    const users = await searchUsers('test');
    for (const user of users) {
      expect(user).not.toHaveProperty('password');
      expect(user).not.toHaveProperty('password_hash');
      expect(user).not.toHaveProperty('token');
    }
  });

  it('limita la cantidad de resultados', async () => {
    const users = await searchUsers('a'); // query muy genérica
    expect(users.length).toBeLessThanOrEqual(100);
  });
});
```

## El flujo completo: IA + tests en tu workflow

```
┌─────────────────────────────────────────────┐
│  1. Escribe tests (TÚ)                     │
│     └→ Define comportamiento esperado       │
│                                             │
│  2. Genera código (IA)                      │
│     └→ Copilot / Cursor / Claude            │
│                                             │
│  3. Corre tests                             │
│     ├→ ✅ Pasan → Review manual + merge     │
│     └→ ❌ Fallan → Vuelve a paso 2          │
│                                             │
│  4. CI automático                           │
│     └→ Tests en cada PR / commit            │
└─────────────────────────────────────────────┘
```

## Configurar CI para código de IA

```yaml
# .github/workflows/test.yml
name: Test AI-generated code
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx vitest run --coverage
      - name: Check coverage threshold
        run: |
          npx vitest run --coverage --coverage.thresholds.lines=80
```

> **Regla**: el código generado por IA debe tener **al menos** 80% de cobertura. Si la IA genera código que es difícil de testear, es código que probablemente deberías reescribir.

## Anti-patrones: lo que NO hacer

### 1. Pedirle a la IA que genere los tests Y la implementación

```javascript
// ❌ Los tests de la IA validan la implementación de la IA
// → Si ambos tienen el mismo error, los tests pasan
```

La IA tiende a generar tests que "coinciden" con su propia implementación. Son tests tautológicos que no validan nada.

### 2. Solo testear el happy path

```javascript
// ❌ Esto es lo que Copilot genera como test
it('suma dos números', () => {
  expect(add(2, 3)).toBe(5);
});

// ✅ Lo que realmente necesitas
it('suma dos números', () => { expect(add(2, 3)).toBe(5); });
it('maneja negativos', () => { expect(add(-1, 1)).toBe(0); });
it('maneja floats', () => { expect(add(0.1, 0.2)).toBeCloseTo(0.3); });
it('maneja strings numéricos', () => { expect(() => add('2', '3')).toThrow(); });
```

### 3. Confiar en que "compila = funciona"

TypeScript atrapa errores de tipo, pero no errores de lógica. El código puede tipar perfecto y tener bugs sutiles. Para evitar los errores más comunes al usar IA en tu editor, revisa los [errores comunes al programar con Copilot y Cline](/blog/errores-comunes-ia-vscode-copilot-cline-2026/).

## Prompt para generar tests útiles con IA

Si usas IA para ayudarte a generar tests (no como única fuente), este prompt produce buenos resultados:

```
Genera tests con Vitest para esta función:
[pega la función]

Requisitos:
- Incluye al menos 3 edge cases (null, undefined, array vacío, valores límite)
- Testea que no muta los argumentos de entrada
- Testea tipos incorrectos de input
- Incluye un test de rendimiento para inputs grandes (>10000 elementos)
- NO generes tests que simplemente repitan la lógica de la implementación
```

Para más prompts efectivos para programar con IA, consulta la guía de [mejores prompts para programar con IA](/blog/mejores-prompts-programar-ia-2026/).

## Herramientas recomendadas

| Herramienta | Para qué | Por qué |
|------------|----------|---------|
| **Vitest** | Unit + integration tests | Rápido, compatible con Vite, ESM nativo |
| **fast-check** | Property-based testing | Genera inputs que humanos no pensarían |
| **MSW** | Mock de APIs | Para testear funciones que llaman a APIs de IA |
| **Stryker** | Mutation testing | Verifica que los tests realmente detectan bugs |

## Testear integraciones con APIs de IA

Si tu código llama a APIs de OpenAI o Claude, no las llames en tests — son lentas, caras e impredecibles. Usa mocks:

```javascript
import { vi, describe, it, expect } from 'vitest';
import { analyzeText } from './ai-service.js';

// Mock del SDK de OpenAI
vi.mock('openai', () => ({
  default: vi.fn().mockImplementation(() => ({
    chat: {
      completions: {
        create: vi.fn().mockResolvedValue({
          choices: [{ message: { content: '{"sentiment": "positive", "score": 0.9}' } }]
        })
      }
    }
  }))
}));

describe('analyzeText', () => {
  it('parsea correctamente la respuesta de OpenAI', async () => {
    const result = await analyzeText('Me encanta este producto');
    expect(result).toEqual({ sentiment: 'positive', score: 0.9 });
  });
});
```

Si necesitas que la IA devuelva JSON estructurado de forma fiable (para que tus tests sean predecibles), revisa las [técnicas de parseo JSON con IA](/blog/parsear-json-ia-sin-errores-openai-claude-2026/).

## Conclusión

El código generado por IA es como el código de un junior muy rápido: funciona en el happy path pero se rompe en los bordes. Tu trabajo no es revisar cada línea — es escribir tests que la obliguen a ser correcta.

1. **Test-first**: escribe tests antes de pedir código
2. **Property-based**: genera cientos de inputs aleatorios con fast-check
3. **Seguridad**: testea injection, exposición de datos, límites
4. **CI obligatorio**: ningún código de IA llega a producción sin tests verdes

La IA acelera el desarrollo 3x. Los tests garantizan que esa velocidad no se traduzca en bugs 3x.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Streaming SSE con ChatGPT y Claude en Node.js: Respuestas en Tiempo Real]]></title>
      <link>https://francobosg.netlify.app/blog/streaming-sse-chatgpt-claude-nodejs-2026/</link>
      <description><![CDATA[Implementa streaming de respuestas de IA con Server-Sent Events (SSE) en Node.js. Tutorial paso a paso con OpenAI, Claude y Express. Código listo para producción.]]></description>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/streaming-sse-chatgpt-claude-nodejs-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Cuando tu aplicación llama a la API de OpenAI o Claude **sin streaming**, el usuario ve una pantalla en blanco durante 5-15 segundos hasta que el modelo termina de generar toda la respuesta. Con streaming, el texto aparece **token a token** en tiempo real — exactamente como en chat.openai.com.

En este tutorial implementamos streaming completo: desde la API de IA hasta el navegador del usuario, usando **Server-Sent Events (SSE)** con Node.js y Express.

## Arquitectura

```
[Navegador]  ←SSE←  [Express/Node.js]  ←stream←  [API OpenAI/Claude]
  EventSource          Tu servidor           Modelo de IA
```

El flujo es: tu servidor abre un stream con la API de IA, recibe tokens incrementales, y los reenvía al navegador como eventos SSE. El navegador los pinta conforme llegan.

## Paso 1: Streaming desde la API de OpenAI

```javascript
import OpenAI from 'openai';

const openai = new OpenAI();

async function* streamOpenAI(messages) {
  const stream = await openai.chat.completions.create({
    model: 'gpt-4.1-mini',
    messages,
    stream: true,
  });

  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content;
    if (content) yield content;
  }
}
```

`stream: true` cambia la respuesta de un objeto JSON completo a un flujo de chunks. Cada chunk contiene un `delta` con el siguiente fragmento de texto.

## Paso 2: Streaming desde la API de Claude

```javascript
import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic();

async function* streamClaude(messages) {
  const stream = anthropic.messages.stream({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 4096,
    messages,
  });

  for await (const event of stream) {
    if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
      yield event.delta.text;
    }
  }
}
```

> Si todavía no tienes acceso a la API, revisa [cómo usar la API de ChatGPT y Claude gratis](/blog/usar-api-chatgpt-claude-gratis-2026/) — tienes $5 de créditos al registrarte en ambas plataformas.

## Paso 3: Endpoint SSE con Express

Server-Sent Events es el estándar HTTP para streaming unidireccional. El servidor envía eventos, el navegador los recibe con `EventSource`. No necesitas WebSockets.

```javascript
import express from 'express';

const app = express();
app.use(express.json());

app.post('/api/chat', async (req, res) => {
  const { messages, provider = 'openai' } = req.body;

  // Cabeceras SSE
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no', // Desactiva buffering en Nginx/proxies
  });

  try {
    // Elegir proveedor
    const streamer = provider === 'claude'
      ? streamClaude(messages)
      : streamOpenAI(messages);

    for await (const token of streamer) {
      // Formato SSE: "data: contenido\n\n"
      res.write(`data: ${JSON.stringify({ token })}\n\n`);
    }

    // Señal de fin
    res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
    res.end();
  } catch (error) {
    res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
    res.end();
  }
});

app.listen(3000, () => console.log('Server en http://localhost:3000'));
```

### ¿Por qué SSE y no WebSockets?

| Característica | SSE | WebSockets |
|---------------|-----|-----------|
| **Dirección** | Servidor → Cliente | Bidireccional |
| **Protocolo** | HTTP estándar | Protocolo propio (ws://) |
| **Reconexión** | Automática | Manual |
| **Proxies/CDN** | Funciona sin config | Puede requerir config |
| **Complejidad** | Baja | Media-alta |
| **Para streaming IA** | ✅ Perfecto | ⚠️ Overkill |

El streaming de IA es unidireccional: la IA responde, el usuario lee. SSE es la herramienta exacta para esto.

## Paso 4: Cliente en el navegador

```javascript
async function streamChat(messages, onToken, onDone) {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages }),
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop() || ''; // Guardar línea incompleta

    for (const line of lines) {
      if (!line.startsWith('data: ')) continue;

      const data = JSON.parse(line.slice(6));
      if (data.done) {
        onDone();
        return;
      }
      if (data.error) {
        throw new Error(data.error);
        return;
      }
      onToken(data.token);
    }
  }
}

// Uso en tu UI
const outputEl = document.getElementById('output');
outputEl.textContent = '';

streamChat(
  [{ role: 'user', content: '¿Qué es Node.js?' }],
  (token) => { outputEl.textContent += token; },      // cada token
  () => { console.log('Respuesta completa'); }          // fin
);
```

> **Nota**: Usamos `fetch` + `ReadableStream` en vez de `EventSource` porque `EventSource` solo soporta GET y necesitamos enviar el body con POST.

## Paso 5: Efecto de escritura (typewriter)

Para un efecto visual profesional, renderiza con un pequeño delay entre tokens:

```javascript
function createTypewriter(element) {
  const queue = [];
  let isProcessing = false;

  async function process() {
    if (isProcessing) return;
    isProcessing = true;

    while (queue.length > 0) {
      const token = queue.shift();
      element.textContent += token;
      element.scrollIntoView({ behavior: 'smooth', block: 'end' });
      // Micro-delay para efecto visual (los tokens llegan más rápido de lo que se lee)
      await new Promise(r => setTimeout(r, 15));
    }

    isProcessing = false;
  }

  return {
    add(token) {
      queue.push(token);
      process();
    }
  };
}

const typewriter = createTypewriter(document.getElementById('output'));
streamChat(
  [{ role: 'user', content: 'Explica qué es SSE' }],
  (token) => typewriter.add(token),
  () => console.log('Hecho')
);
```

## Manejo de errores en producción

### Desconexión del cliente

Si el usuario cierra la pestaña mientras se genera la respuesta, debes abortar el stream para no desperdiciar tokens:

```javascript
app.post('/api/chat', async (req, res) => {
  const abortController = new AbortController();

  // Si el cliente se desconecta, abortar el stream de la IA
  req.on('close', () => {
    abortController.abort();
  });

  // OpenAI soporta signal para abortar
  const stream = await openai.chat.completions.create({
    model: 'gpt-4.1-mini',
    messages: req.body.messages,
    stream: true,
  }, { signal: abortController.signal });

  // ... continúa con el streaming
});
```

### Timeout y reconexión

```javascript
// Cliente: reintentar si el stream se corta
async function streamWithRetry(messages, onToken, onDone, maxRetries = 2) {
  let fullText = '';

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      await streamChat(
        messages,
        (token) => {
          fullText += token;
          onToken(token);
        },
        onDone
      );
      return; // Éxito
    } catch (error) {
      if (attempt === maxRetries) throw error;
      console.warn(`Stream cortado, reintentando (${attempt + 1})...`);
    }
  }
}
```

Si los reintentos generan errores 429, necesitas implementar backoff exponencial. Consulta la guía completa de [Error 429 en APIs de IA](/blog/error-429-too-many-requests-api-ia-2026/).

## Streaming + JSON estructurado

¿Necesitas streaming Y respuesta en JSON? Puedes hacer streaming del texto y parsear al final:

```javascript
let fullResponse = '';

for await (const token of streamOpenAI(messages)) {
  fullResponse += token;
  // Enviar token al cliente para visualización
  res.write(`data: ${JSON.stringify({ token })}\n\n`);
}

// Al terminar, parsear el JSON completo
import { parseAIJson } from './utils.js'; // de nuestro artículo de parseo JSON
const data = parseAIJson(fullResponse);
```

Para técnicas avanzadas de parseo de JSON de IA (incluyendo streaming), revisa el artículo dedicado a [parsear JSON de IA sin errores](/blog/parsear-json-ia-sin-errores-openai-claude-2026/).

## Alternativa: NestJS para producción

Si tu backend es más complejo (auth, base de datos, múltiples endpoints), Express se queda corto. NestJS ofrece estructura, inyección de dependencias y soporte nativo para SSE:

```typescript
// NestJS: SSE nativo con @Sse()
@Controller('chat')
export class ChatController {
  @Post('stream')
  @Sse()
  stream(@Body() dto: ChatDto): Observable<MessageEvent> {
    return new Observable((subscriber) => {
      const streamer = this.aiService.stream(dto.messages);
      (async () => {
        for await (const token of streamer) {
          subscriber.next({ data: { token } } as MessageEvent);
        }
        subscriber.complete();
      })();
    });
  }
}
```

¿Por qué NestJS en vez de Express para SaaS? Lo explico en detalle en [por qué NestJS sobre Express para un SaaS](/blog/por-que-nestjs-sobre-express-saas-2026/).

## Checklist de implementación

- [ ] Endpoint SSE con cabeceras correctas (`text/event-stream`, `no-cache`)
- [ ] Desactivar buffering en proxy (`X-Accel-Buffering: no`)
- [ ] Abortar stream si el cliente se desconecta
- [ ] Señal de fin de stream (`data: { done: true }`)
- [ ] Manejo de errores enviado como evento SSE (no como HTTP 500)
- [ ] Reconexión automática en el cliente
- [ ] Efecto typewriter para UX

## Conclusión

El streaming con SSE transforma la experiencia de tu aplicación de IA: de "esperar 10 segundos sin feedback" a "ver la respuesta escribirse en tiempo real".

La implementación es más sencilla de lo que parece: `stream: true` en la API, cabeceras SSE en Express, y `ReadableStream` en el cliente. Tres piezas que conectas en una tarde.

Si quieres conectar el streaming con herramientas externas (bases de datos, APIs, búsqueda web), el siguiente paso es implementar [function calling](/blog/function-calling-openai-claude-conectar-ia-apis-2026/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Sueldo Desarrollador Web España 2026: Tablas Reales por Rol, Ciudad y Experiencia]]></title>
      <link>https://francobosg.netlify.app/blog/sueldo-desarrollador-web-espana-2026/</link>
      <description><![CDATA[¿Cuánto cobra un programador en España en 2026? Tablas de sueldos reales: junior, mid, senior, frontend, backend, fullstack y DevOps. Datos de InfoJobs, Manfred y LinkedIn.]]></description>
      <pubDate>Wed, 15 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/sueldo-desarrollador-web-espana-2026/</guid>
      <category>Carrera</category>
      <category>España</category>
      <category>Trabajo</category>
      <category>Salarios</category>
      <content:encoded><![CDATA[Los salarios en desarrollo web en España han cambiado profundamente desde 2022. El trabajo remoto para empresas extranjeras, la irrupción de la IA en los equipos de producto y la escasez de perfiles especializados han reconfigurado el mercado. Esta guía recoge datos reales de 2026 procedentes de InfoJobs, LinkedIn, Manfred State of Tech Spain y encuestas en comunidades de developers.

> **Respuesta rápida:** Un junior cobra 20.000€-28.000€, un mid-level 30.000€-50.000€ y un senior 45.000€-90.000€ brutos/año en empresa española. Con trabajo remoto para empresa extranjera, los seniors pueden llegar a 120.000€+.

---

## Tabla resumen: sueldos por nivel de experiencia en 2026

| Nivel | Años exp. | Rango bruto/año | Madrid/Barcelona |
|-------|-----------|-----------------|-----------------|
| Junior | 0-2 | 20.000€ – 28.000€ | 22.000€ – 32.000€ |
| Mid-level | 2-5 | 30.000€ – 50.000€ | 35.000€ – 55.000€ |
| Senior | 5-8 | 45.000€ – 70.000€ | 50.000€ – 80.000€ |
| Tech Lead | 7+ | 60.000€ – 90.000€ | 65.000€ – 100.000€ |
| Staff / Principal | 10+ | 80.000€ – 120.000€+ | 90.000€ – 130.000€+ |

*Datos: InfoJobs Tech, Manfred State of Tech Spain 2025, LinkedIn Salary Insights. Actualizado abril 2026.*

---

## Disclaimer: por qué los sueldos varían tanto

Antes de entrar en detalle, hay que entender por qué dos developers con el mismo nivel pueden tener sueldos muy distintos:

- **Tipo de empresa**: consultoría vs. startup de producto vs. empresa americana. La diferencia puede ser del 40-60% para el mismo perfil.
- **Ciudad**: Madrid paga hasta un 25% más que ciudades medianas. El remoto ha reducido este gap.
- **Stack y especialización**: un DevOps con Kubernetes gana más que un frontend con Vue. Los datos lo muestran abajo.
- **Habilidad de negociación**: quien negocia activamente puede conseguir 5.000€-10.000€ más que quien acepta la primera oferta.
- **Empresa española vs. extranjera en remoto**: el mayor diferencial salarial del mercado actual.

---

## Salarios de desarrollador junior en España 2026 (0-2 años)

El mercado junior es el más competitivo. Hay más candidatos que plazas porque las empresas prefieren perfiles con algo de experiencia. Pero **las plazas junior existen** y quien llega con un portfolio real tiene ventaja.

### ¿Cuánto cobra un junior según el tipo de empresa?

| Tipo de empresa | Sueldo bruto/año | Notas |
|-----------------|-----------------|-------|
| Consultora grande (Accenture, Indra...) | 18.000€ – 22.000€ | Estabilidad, formación, rotación |
| Startup early stage | 20.000€ – 26.000€ + equity | Aprendizaje rápido, incertidumbre |
| Empresa de producto (mid-size) | 22.000€ – 28.000€ | El mejor punto de partida |
| Agencia web | 18.000€ – 23.000€ | Mucha variedad, poco sueldo |
| Banco / gran empresa | 22.000€ – 27.000€ | Conciliación, ritmo lento |

### ¿Cuánto cobra un junior por rol?

| Rol | Sueldo bruto/año | Por qué varía |
|-----|-----------------|---------------|
| Frontend Junior (React/Vue) | 20.000€ – 27.000€ | Alta oferta de candidatos |
| Backend Junior (Node/Python) | 22.000€ – 28.000€ | Algo menos candidatos |
| Fullstack Junior | 21.000€ – 28.000€ | Muy demandado en pymes |
| Mobile Junior (Flutter/RN) | 22.000€ – 29.000€ | Menos candidatos |
| QA / Testing Junior | 18.000€ – 24.000€ | Perfil subestimado |
| Data Junior | 22.000€ – 28.000€ | Creciendo rápido |

**Factores que suman al sueldo junior:**
- Haber hecho prácticas remuneradas: +1.500€-3.000€
- Portfolio con proyectos reales desplegados: mejor poder negociador
- Madrid/Barcelona vs. resto: +3.000€-5.000€
- Inglés técnico demostrable: +1.000€-2.000€

---

## Salarios de desarrollador mid-level en España 2026 (2-5 años)

El rango mid es donde más dispersión existe. Un developer con 3 años en consultora puede ganar lo mismo que uno con 2 años en startup de producto. Lo que cuenta es la **complejidad de lo que has construido**, no solo los años.

### Tabla por rol (mid-level, ~3 años de experiencia)

| Rol | Rango bruto/año | Stack más valorado |
|-----|-----------------|-------------------|
| Frontend Developer | 32.000€ – 47.000€ | React + TypeScript |
| Backend Developer | 34.000€ – 50.000€ | Node.js, Python, Java |
| Fullstack Developer | 33.000€ – 48.000€ | React + Node/Python |
| DevOps / SRE | 40.000€ – 58.000€ | AWS/GCP + Kubernetes |
| Data Engineer | 36.000€ – 52.000€ | Python + Spark/dbt |
| Mobile (Flutter/RN) | 35.000€ – 50.000€ | Dart, React Native |
| Cloud Architect | 45.000€ – 62.000€ | AWS/Azure certified |
| Security / SecOps | 40.000€ – 60.000€ | Alta demanda, poca oferta |

---

## Salarios de desarrollador senior en España 2026 (5+ años)

Aquí el mercado cambia radicalmente. Un senior tiene **poder de negociación real** y puede elegir entre mercado local o internacional.

### Tabla por rol (senior, 5-8 años)

| Rol | Empresa española | Empresa extranjera remoto |
|-----|-----------------|--------------------------|
| Frontend Senior | 45.000€ – 65.000€ | 65.000€ – 95.000€ |
| Backend Senior | 50.000€ – 70.000€ | 70.000€ – 110.000€ |
| Fullstack Senior | 48.000€ – 68.000€ | 65.000€ – 100.000€ |
| Tech Lead | 60.000€ – 90.000€ | 80.000€ – 130.000€ |
| DevOps / Platform Eng. | 55.000€ – 85.000€ | 80.000€ – 130.000€ |
| Staff Engineer | 75.000€ – 110.000€ | 100.000€ – 160.000€+ |
| Engineering Manager | 70.000€ – 100.000€ | 90.000€ – 150.000€ |

*Para empresas extranjeras que pagan en USD, el tipo de cambio puede aumentar o reducir el equivalente en euros. Los rangos son orientativos a tipo de cambio actual.*

---

## Salarios por tecnología: ¿qué stack paga más?

No todas las tecnologías tienen el mismo valor de mercado. Esta tabla es para mid-level con 3 años de experiencia en Madrid:

| Tecnología / Stack | Sueldo bruto/año | Demanda | Dificultad aprendizaje |
|-------------------|-----------------|---------|----------------------|
| AWS / Cloud | 45.000€ – 62.000€ | ★★★★★ | Alta |
| Kubernetes / GitOps | 48.000€ – 65.000€ | ★★★★☆ | Alta |
| IA / ML (Python) | 42.000€ – 60.000€ | ★★★★★ | Alta |
| Seguridad / SecOps | 45.000€ – 65.000€ | ★★★★★ | Alta |
| React + TypeScript | 37.000€ – 48.000€ | ★★★★★ | Media |
| Java / Spring Boot | 38.000€ – 52.000€ | ★★★★☆ | Media-Alta |
| Node.js / Express | 35.000€ – 47.000€ | ★★★★☆ | Media |
| Python / Django | 36.000€ – 48.000€ | ★★★★☆ | Media |
| Rust / Go | 45.000€ – 65.000€ | ★★★☆☆ | Muy Alta |
| Flutter / Dart | 36.000€ – 50.000€ | ★★★★☆ | Media |
| PHP / Laravel | 26.000€ – 40.000€ | ★★★☆☆ | Media-Baja |

**Conclusión:** invertir en Cloud, seguridad o IA es la apuesta con mejor ROI salarial en 2026. React sigue siendo el más demandado en términos absolutos pero también el más saturado de candidatos.

---

## Salarios por ciudad en España

| Ciudad | Factor vs. media nacional | Sueldo mid ejemplo |
|--------|--------------------------|-------------------|
| Madrid | +15-25% | 40.000€ – 52.000€ |
| Barcelona | +10-20% | 38.000€ – 50.000€ |
| País Vasco (Bilbao/Donosti) | +5-15% | 36.000€ – 48.000€ |
| Valencia | 0-5% | 32.000€ – 42.000€ |
| Málaga / Andalucía Tech | -5-10% | 30.000€ – 40.000€ |
| Sevilla | -5-10% | 29.000€ – 39.000€ |
| Ciudades medianas | -15-25% | 26.000€ – 38.000€ |

**Con trabajo remoto**, la diferencia por ciudad se reduce para el empleado: puedes cobrar un sueldo de Madrid viviendo en Zaragoza. Sin embargo, algunas empresas siguen aplicando "geographic pay" ajustando el sueldo al coste de vida local.

---

## El diferencial del trabajo remoto internacional: por qué muchos seniors emigran (sin moverse)

El mayor cambio en el mercado desde 2022 es que un senior español puede trabajar para una empresa alemana, holandesa o americana **sin salir de casa**, cobrando en euros o dólares a tarifa europea/americana.

```
Comparativa senior backend (5 años exp., Madrid):
─────────────────────────────────────────────────
Consultora española:        52.000€ brutos/año
Startup española (serie A):  65.000€ brutos/año
Empresa alemana (remoto):    85.000€ brutos/año
Empresa americana (remoto): 110.000€ equiv./año
```

Esto no es la excepción; es la regla para seniors con buen inglés y perfil internacional. Si quieres acceder a ese mercado, el artículo sobre [trabajo remoto para developers](/blog/trabajo-remoto-developers-como-encontrar-2026/) explica exactamente cómo hacerlo.

---

## Freelance vs. empleado: ¿cuánto hay que cobrar como autónomo?

Un freelance cobra más por hora, pero tiene gastos reales que los empleados no ven:

| Concepto | Impacto económico |
|----------|-----------------|
| Cuota autónomo | ~300€/mes = 3.600€/año |
| IRPF (estimado 30%) | ~30% de la facturación |
| Vacaciones sin cobrar (22 días) | ~8% del ingreso anual |
| Seguro médico privado | ~100-150€/mes |
| Material y software | Variable |

**¿Cuánto debe cobrar un freelance para equivaler a un empleado?**

| Sueldo empleado (bruto/año) | Tarifa freelance equivalente/día |
|----------------------------|----------------------------------|
| 35.000€ | 300€ – 350€/día |
| 45.000€ | 400€ – 450€/día |
| 60.000€ | 500€ – 600€/día |

Un autónomo que cobra 350€/día y factura 220 días al año ingresa 77.000€ brutos, pero su equivalente neto es comparable a un empleado de 45.000€ brutos.

---

## ¿Cuánto sube el sueldo cada año como programador en España?

La progresión salarial depende mucho del tipo de empresa y de si cambias de empresa o no:

- **Sin cambiar de empresa**: 2-5% anual en empresas grandes; 5-10% en startups con crecimiento
- **Cambiando de empresa cada 2-3 años**: 15-30% de subida por cambio, que es la estrategia más efectiva para acelerar el sueldo
- **Al pasar de junior a mid**: +8.000€-15.000€ de media
- **Al pasar de mid a senior**: +10.000€-20.000€ de media

**Estrategia real**: cambiar de empresa cada 2-3 años es la forma más rápida de llegar a salario senior. Las empresas pagan más a candidatos externos que a empleados internos en la mayoría de los casos.

---

## Beneficios más valorados además del sueldo

En 2026, muchos developers priorizan el **paquete completo** sobre el sueldo bruto:

1. **Trabajo remoto total o híbrido** — El más valorado, equivale a 5.000€-10.000€ en ahorro de transporte/calidad de vida
2. **Formación pagada** — Cursos, conferencias, certificaciones (AWS, GCP)
3. **Seguro médico privado** — Sanitas, Adeslas, etc. (valor ~1.500€-2.000€/año)
4. **Flexibilidad horaria** — Especialmente valorada para conciliar
5. **Ticket restaurante / transporte** — Libre de impuestos hasta ciertos límites
6. **Plan de pensiones** — Menos común en startups, más en empresas grandes
7. **Opciones sobre acciones / equity** — En startups, puede ser el mayor multiplicador de riqueza a largo plazo

---

## Cómo negociar tu sueldo como desarrollador en España

La negociación mal hecha te puede costar 5.000€-10.000€ al año. Estos pasos funcionan:

### Antes de la entrevista
- Investiga rangos reales: Manfred Salary, InfoJobs Tech, Glassdoor, grupos de Telegram como "Developers España"
- Conoce el rango del puesto específico en esa empresa (LinkedIn, ex-empleados)
- Decide tu mínimo aceptable y tu objetivo

### Durante la oferta
- **No des el número primero** si puedes evitarlo: "¿qué rango tienen para este puesto?"
- Si tienes que dar un número, pide un 20% más de lo que aceptarías
- Usa framing de valor: "con mi experiencia en [X tecnología] y habiendo implementado [Y] en mi empresa anterior..."
- Negocia el paquete completo: si no pueden subir el base, quizás pueden añadir días de teletrabajo, formación o revisión salarial a 6 meses

### Lo que no hay que hacer
- No mencionar la hipoteca o necesidades personales como justificación
- No aceptar "ya veremos en 6 meses" sin ponerlo por escrito
- No aceptar el primer número sin al menos intentar contra-ofertar

---

## Herramientas para investigar sueldos en España

- **[Manfred Salary](https://salary.getmanfred.com/)** — La más fiable para tech en España, con datos de perfiles reales
- **[InfoJobs](https://www.infojobs.net/)** — Útil para ver rangos en ofertas activas
- **[Glassdoor](https://www.glassdoor.es/)** — Mejor para empresas grandes y multinacionales
- **[LinkedIn Salary](https://www.linkedin.com/salary/)** — Con datos de usuarios verificados
- **Stack Overflow Developer Survey** — Referencia global anual
- **Telegram: Developers ES / Salary Transparency Tech ES** — Datos directos de la comunidad

---

## ¿Estás empezando?

Si todavía no tienes tu primer trabajo como desarrollador, los sueldos de esta guía son el horizonte. El camino para llegar empieza con el portfolio y las entrevistas. Te recomendamos leer:

- [Cómo conseguir tu primer trabajo de programador sin experiencia en España](/blog/conseguir-trabajo-programador-sin-experiencia-espana-2026/) — La guía completa para entrar al mercado desde cero
- [Trabajo remoto para developers: cómo encontrarlo en 2026](/blog/trabajo-remoto-developers-como-encontrar-2026/) — Para cuando ya tengas experiencia y quieras multiplicar tu sueldo
- [Aprender inglés para developers](/blog/aprender-ingles-para-developers-2026/) — El requisito mínimo para el mercado internacional]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Por Qué Elegí NestJS sobre Express para un SaaS Real]]></title>
      <link>https://francobosg.netlify.app/blog/por-que-nestjs-sobre-express-saas-2026/</link>
      <description><![CDATA[Decisiones de arquitectura reales: por qué NestJS gana a Express en un SaaS multitenant, cómo estructuré la API y errores que evité con la arquitectura modular.]]></description>
      <pubDate>Tue, 14 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/por-que-nestjs-sobre-express-saas-2026/</guid>
      <category>Arquitectura</category>
      <category>NestJS</category>
      <category>Express</category>
      <category>TypeScript</category>
      <category>SaaS</category>
      <category>Backend</category>
      <category>Caso Real</category>
      <content:encoded><![CDATA[## TL;DR

Elegí NestJS sobre Express para construir un SaaS con 18 entidades, 75+ endpoints y 3 roles con 8 permisos. Express me habría dado libertad total, pero en un proyecto de esta escala esa libertad es una trampa. Aquí explico por qué, con código real del proyecto.

---

## Contexto: qué iba a construir

El proyecto era [Atrapaclientes](/blog/caso-real-saas-atrapaclientes-nestjs-react/), un SaaS multitenant con:

- **18 entidades** en PostgreSQL (usuarios, tenants, campañas, códigos, participaciones, terminales, formularios...)
- **75+ endpoints** REST documentados con Swagger
- **3 roles** (SUPERADMIN, ADMIN, GERENTE) con **8 permisos granulares**
- **WebSockets** para comunicación en tiempo real con tablets
- **Multi-tenancy** con aislamiento a nivel de fila
- **App móvil** consumiendo la misma API

Cuando empecé, tenía que hacer la elección: **Express con estructura propia** o **NestJS con convenciones**.

---

## Lo que Express me habría obligado a inventar

Con Express, el típico proyecto arranca así:

```javascript
const express = require('express');
const app = express();

app.get('/api/campaigns', getCampaigns);
app.post('/api/campaigns', createCampaign);
// ... 73 rutas más
```

Para un proyecto con 75+ endpoints, necesitaría inventar:

1. **Sistema de módulos** — ¿carpetas por feature? ¿por tipo? ¿mono-router o multi-router?
2. **Inyección de dependencias** — ¿singleton manual? ¿factory functions? ¿contenedor IoC?
3. **Middlewares de autenticación** — ¿dónde van? ¿cómo comparto el usuario entre middlewares?
4. **Validación de DTOs** — ¿Joi? ¿Yup? ¿Zod? ¿validación manual?
5. **Documentación de API** — ¿Swagger manual? ¿comentarios JSDoc?
6. **Manejo de errores** — ¿middleware global?¿try/catch en cada controller?
7. **Guards de permisos** — ¿middleware por ruta? ¿decorador custom?

Cada una de estas decisiones es tiempo perdido reinventando lo que ya existe.

---

## Lo que NestJS me dio "gratis"

### 1. Módulos que aíslan funcionalidad

```typescript
@Module({
  imports: [TypeOrmModule.forFeature([Campaign, Code])],
  controllers: [CampaignController],
  providers: [CampaignService],
  exports: [CampaignService],
})
export class CampaignModule {}
```

Cada módulo tiene sus controllers, services y entidades. Si algo falla en campañas, no toco el módulo de usuarios. En Atrapaclientes tengo ~10 módulos independientes.

### 2. Decoradores para autenticación y permisos

```typescript
@Controller('campaigns')
@UseGuards(JwtAuthGuard, TenantContextGuard)
export class CampaignController {
  
  @Post()
  @RequirePermission(Permission.MANAGE_CAMPAIGNS)
  create(@Body() dto: CreateCampaignDto, @Req() req) {
    return this.service.create(dto, req.tenantId);
  }
}
```

Una línea para proteger el endpoint. Una línea para requerir un permiso. No hay middleware chains confusos ni `if (req.user.role !== 'admin')` repetido en 30 sitios.

### 3. Validación automática de DTOs

```typescript
export class CreateCampaignDto {
  @IsString()
  @MinLength(3)
  name: string;

  @IsEnum(CampaignType)
  type: CampaignType;

  @IsDateString()
  startDate: string;

  @IsOptional()
  @IsObject()
  formSchema?: Record<string, any>;
}
```

NestJS valida automáticamente el body contra el DTO. Si falta `name` o `type` no es un enum válido, devuelve un 400 con el error exacto. Sin código adicional.

### 4. Swagger auto-generado

```typescript
@ApiTags('campaigns')
@ApiOperation({ summary: 'Crear campaña' })
@ApiResponse({ status: 201, type: CampaignResponseDto })
@Post()
create(@Body() dto: CreateCampaignDto) { ... }
```

Mis 75+ endpoints están documentados con Swagger. Los decoradores generan la documentación automáticamente. Un frontend developer o un partner que consuma la API tiene documentación actualizada siempre.

### 5. Inyección de dependencias real

```typescript
@Injectable()
export class CampaignService {
  constructor(
    @InjectRepository(Campaign)
    private readonly repo: Repository<Campaign>,
    private readonly codeService: CodeService,
    private readonly notificationService: NotificationService,
  ) {}
}
```

NestJS resuelve las dependencias automáticamente. Si `CampaignService` necesita `CodeService`, NestJS lo instancia y lo inyecta. No hay `new CodeService()` manual ni singletons caseros.

---

## Estructura real del proyecto

```
src/
├── auth/               → JWT, RBAC, guards, strategies
├── campaigns/          → CRUD de campañas, wizard logic
├── codes/              → Generación y validación de códigos
├── terminals/          → WebSocket gateway, comandos remotos
├── participants/       → Participaciones y formularios
├── tenants/            → Multi-tenancy, aislamiento
├── users/              → Gestión de usuarios y roles
├── notifications/      → Email con Nodemailer
├── uploads/            → Imágenes con volúmenes persistentes
├── common/             → Decoradores, guards, pipes, filters
└── app.module.ts       → Root module
```

Cada carpeta es autónoma. Si mañana necesito quitar el módulo de notificaciones, borro la carpeta y quito el import del `app.module`. Sin efectos secundarios.

**Con Express**, esta estructura sería posible pero no obligatoria. Y "no obligatoria" significa que en la tercera iteración alguien mete una ruta en el archivo equivocado y empieza el caos.

---

## Cuándo NO elegiría NestJS

NestJS no es la respuesta a todo. Lo evitaría en:

- **APIs pequeñas (5-15 endpoints)** — el boilerplate de NestJS no se justifica
- **Serverless puro** — los cold starts de NestJS son más largos que Express vanilla
- **Scripts/workers simples** — si solo necesito un cron job, Express o incluso un script puro es mejor
- **Prototipo rápido** — si necesito validar una idea en 2 días, Express + Zod es más ágil

Mi regla: **si el proyecto va a tener más de 20 endpoints o más de 2 desarrolladores, NestJS.**

---

## Express vs NestJS: comparación honesta

| Aspecto | Express | NestJS |
|---|---|---|
| Arranque | 2 min | 10 min |
| Estructura | Tú decides | Convenciones |
| 20 endpoints | Perfecto | Overkill |
| 75 endpoints | Caos sin disciplina | Manejable |
| TypeScript | Posible | Nativo |
| Testing | Tu setup | Jest integrado |
| Documentación API | Manual | Swagger auto |
| Curva de aprendizaje | Baja | Media |
| Mantenibilidad | Depende del dev | Buena por defecto |

---

## Decisiones de arquitectura que funcionaron

### TypeORM con migraciones manuales

```bash
pnpm migration:generate -- -n AddFormSchemaToParticipation
pnpm migration:run
```

Nunca `synchronize: true` en producción. Las 27 migraciones están versionadas y son reversibles. Si algo falla, puedo hacer rollback a cualquier punto.

### Monorepo con Turborepo

```
apps/
├── api/          → NestJS
├── web/          → React
└── mobile/       → React Native
packages/
└── shared-types/ → Interfaces compartidas
```

Los tipos se comparten entre las tres apps. Si cambio un DTO en la API, TypeScript me avisa en el frontend y en la app móvil. Cero "el campo se llama `campaignId` en la API pero `campaign_id` en el front".

### Autenticación JWT dual

- **Access token**: 15 minutos de vida
- **Refresh token**: 7 días con sliding session

Si alguien roba un access token, tiene 15 minutos. Si roba el refresh, el sliding session lo invalida al detectar uso sospechoso.

---

## Conclusión

Express es una herramienta excelente. Pero para un SaaS con 18 entidades, 75 endpoints, RBAC, multi-tenancy y WebSockets, necesitaba estructura obligatoria, no sugerida. NestJS me la dio, y el resultado fue un proyecto mantenible que construí solo en 2 meses.

La verdadera pregunta no es "¿NestJS o Express?". Es: **¿tu proyecto va a crecer lo suficiente como para que la estructura importe?** Si la respuesta es sí, NestJS.

---

## Recursos relacionados

- [Caso real: Atrapaclientes — SaaS completo con NestJS + React](/blog/caso-real-saas-atrapaclientes-nestjs-react/)
- [Caso real: Ecosistema IA para reuniones con Gemini](/blog/caso-real-ia-reuniones-gemini-supabase/)
- [Los mejores modelos de IA para programar en 2026](/blog/mejores-modelos-ia-para-programar-2026/)
- [¿Necesitas desarrollo a medida? Hablemos →](/blog/servicios/)]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cómo Usar IA en Local con Ollama (Gratis, Privado y Sin API)]]></title>
      <link>https://francobosg.netlify.app/blog/ollama-ia-local-gratis-sin-api-2026/</link>
      <description><![CDATA[Instala Ollama y ejecuta modelos de IA como Llama 4, DeepSeek y Qwen en tu PC gratis. Sin APIs, sin límites, sin enviar tu código a la nube.]]></description>
      <pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/ollama-ia-local-gratis-sin-api-2026/</guid>
      <category>IA</category>
      <category>Gratis</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[> **TL;DR**: Instala Ollama → descarga un modelo (`ollama pull llama4`) → úsalo desde terminal, VS Code o tu propia app. Cero coste, cero rate limits, tu código nunca sale de tu máquina.

## Requisitos previos

- [ ] PC con **16 GB de RAM** mínimo (32 GB recomendado)
- [ ] **10-30 GB de disco** libres (los modelos pesan)
- [ ] Windows 10/11, macOS o Linux
- [ ] Opcional: GPU con 8+ GB VRAM (NVIDIA recomendada)

---

Estás pagando $20/mes por una API de IA para programar. A veces recibes [errores 429 de rate limit](/blog/error-429-too-many-requests-api-ia-2026/). Y encima, cada línea de código que escribes pasa por los servidores de OpenAI o Anthropic.

**¿Y si pudieras tener tu propia IA corriendo en tu PC?** Sin pagar, sin límites y sin que tu código salga de tu máquina.

Con Ollama es posible. Te enseño cómo montarlo en 10 minutos.

## Paso 1: Instalar Ollama

### Windows

```powershell
# Descarga e instala desde la web oficial
winget install Ollama.Ollama

# Verifica la instalación
ollama --version
```

### macOS

```bash
brew install ollama
```

### Linux

```bash
curl -fsSL https://ollama.com/install.sh | sh
```

Ollama se ejecuta como servicio local en `http://localhost:11434`. No necesitas Docker, no necesitas configurar nada.

## Paso 2: Descargar un modelo

```bash
# El modelo más equilibrado para programar (8B parámetros, ~5 GB)
ollama pull llama4

# Alternativas por caso de uso:
ollama pull deepseek-coder-v3      # Mejor para código (7B, ~4 GB)
ollama pull qwen2.5-coder:7b       # Excelente para Python (7B, ~4 GB)
ollama pull codellama:13b           # Más potente, necesita 16+ GB RAM
ollama pull llama4-scout:17b        # Mejor modelo gratuito 2026 (17B, ~10 GB)
```

> **Pro-Tip**: Si tienes 32 GB de RAM o una GPU con 12+ GB VRAM, usa modelos de 32B. La diferencia de calidad con los de 7B es enorme, especialmente en debugging y refactoring.

## Paso 3: Usarlo desde terminal

```bash
# Chat interactivo
ollama run deepseek-coder-v3

# Una sola pregunta (ideal para scripts)
ollama run deepseek-coder-v3 "Escribe un decorador Python para cachear resultados con TTL"
```

Resultado:

```python
import time
from functools import wraps

def cache_with_ttl(ttl_seconds=300):
    def decorator(func):
        cache = {}
        @wraps(func)
        def wrapper(*args, **kwargs):
            key = (args, tuple(sorted(kwargs.items())))
            if key in cache:
                result, timestamp = cache[key]
                if time.time() - timestamp < ttl_seconds:
                    return result
            result = func(*args, **kwargs)
            cache[key] = (result, time.time())
            return result
        return wrapper
    return decorator

@cache_with_ttl(ttl_seconds=60)
def fetch_data(url):
    # tu lógica aquí
    pass
```

Funciona. Primera vez. Sin pagar un euro.

## Paso 4: Integrar con VS Code

### Opción A: Continue (extensión gratuita)

1. Instala la extensión [Continue](https://marketplace.visualstudio.com/items?itemName=Continue.continue) desde VS Code
2. Abre la configuración (`~/.continue/config.json`):

```json
{
  "models": [
    {
      "title": "DeepSeek Local",
      "provider": "ollama",
      "model": "deepseek-coder-v3"
    }
  ],
  "tabAutocompleteModel": {
    "title": "Autocomplete",
    "provider": "ollama",
    "model": "qwen2.5-coder:7b"
  }
}
```

3. Ahora tienes autocompletado + chat con IA en VS Code, **completamente gratis**.

### Opción B: Usar Ollama como backend para Cline

Si prefieres Cline (antes Claude Dev), puedes apuntar sus peticiones a Ollama:

```json
{
  "cline.apiProvider": "ollama",
  "cline.ollamaBaseUrl": "http://localhost:11434",
  "cline.ollamaModel": "llama4-scout:17b"
}
```

> **Advertencia**: Los modelos locales funcionan bien para autocompletado y scripts, pero para edición multi-archivo tipo "Agent" (crear/editar 10 archivos a la vez), los modelos de API siguen siendo mejores. Si necesitas esa funcionalidad, consulta la [comparativa Cursor vs Copilot vs Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/).

## Paso 5: Crear tu propio chatbot local con API

Ollama expone una API REST en `localhost:11434` que puedes usar desde cualquier lenguaje:

```javascript
// Node.js — chatbot local con Ollama
const response = await fetch('http://localhost:11434/api/chat', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    model: 'deepseek-coder-v3',
    messages: [
      { role: 'system', content: 'Eres un experto en Python. Responde solo con código.' },
      { role: 'user', content: 'Crea un servidor FastAPI con endpoint /health' }
    ],
    stream: false
  })
});

const data = await response.json();
console.log(data.message.content);
```

```python
# Python — chatbot local con Ollama
import requests

response = requests.post('http://localhost:11434/api/chat', json={
    'model': 'deepseek-coder-v3',
    'messages': [
        {'role': 'user', 'content': 'Función para validar emails con regex'}
    ],
    'stream': False
})

print(response.json()['message']['content'])
```

Si quieres llevar esto más lejos y construir un chatbot con RAG (que pueda buscar en tus documentos), mira el [tutorial de chatbot RAG con OpenAI](/blog/crear-chatbot-rag-openai-tutorial-2026/) — la arquitectura es la misma, solo cambias el proveedor por Ollama.

## Comparativa honesta: Ollama vs APIs de pago

| Criterio | Ollama (local) | API OpenAI/Anthropic |
|----------|---------------|---------------------|
| **Coste** | $0 (solo electricidad) | $10-100+/mes |
| **Privacidad** | 100% local | Tu código va a la nube |
| **Rate limits** | Sin límites | [Error 429 frecuente](/blog/error-429-too-many-requests-api-ia-2026/) |
| **Calidad (7B)** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| **Calidad (32B+)** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| **Velocidad** | Depende de tu hardware | ~80 tokens/s |
| **Offline** | ✅ Funciona sin internet | ❌ Requiere conexión |
| **Multiarchivo/Agent** | ⭐⭐ | ⭐⭐⭐⭐⭐ |

## Qué modelo elegir según tu hardware

| RAM disponible | GPU VRAM | Modelo recomendado | Calidad |
|---------------|----------|-------------------|---------|
| 8 GB | Sin GPU | `qwen2.5-coder:3b` | Básica (autocompletado) |
| 16 GB | Sin GPU | `deepseek-coder-v3` (7B) | Buena (scripts, código simple) |
| 16 GB | 8 GB NVIDIA | `llama4` (8B) | Buena+ (más rápido con GPU) |
| 32 GB | 12 GB NVIDIA | `llama4-scout:17b` | Muy buena (casi nivel API) |
| 32 GB | 24 GB NVIDIA | `qwen2.5-coder:32b` | Excelente (compite con GPT-4.1 mini) |
| 64 GB+ | 2x GPU | `deepseek-v3:67b` | Premium (nivel Claude Sonnet) |

> **Pro-Tip**: Ejecuta `ollama ps` para ver cuánta memoria está usando el modelo cargado. Si tu PC se ralentiza, baja a un modelo más pequeño. Ollama descarga los modelos bajo demanda, así que puedes probar varios sin compromiso.

## 5 cosas que puedes hacer con Ollama que no puedes con APIs

1. **Programar en un avión** — sin WiFi, tu IA sigue funcionando
2. **Código confidencial** — proyectos con NDA donde no puedes enviar código a la nube
3. **Aprender sin límites** — pregunta 1.000 veces al día sin pagar ni recibir rate limits
4. **Personalizar modelos** — crea Modelfiles con tu propio system prompt persistente
5. **Integrar en CI/CD privado** — revisa PRs con IA sin exponer tu código

## Cuándo NO usar Ollama

Sé honesto: Ollama **no es para todo**.

- Si necesitas calidad máxima para lógica compleja → usa [Claude Opus 4 o GPT-4.1](/blog/claude-vs-gpt-programar-python-2026/)
- Si necesitas editar 20 archivos a la vez con un Agent → usa Cursor o Copilot
- Si tu PC tiene menos de 16 GB de RAM → usa las [alternativas gratis en la nube](/blog/alternativas-gratis-chatgpt-2026/)
- Si necesitas modelos multimodales (imágenes) → las [mejores IA para generar imágenes](/blog/mejores-ia-generar-imagenes-2026/) son todas en la nube

Ollama es una herramienta más en tu arsenal. El setup ideal en 2026 es: **Ollama para el 70% de tareas + API de pago para el 30% complejo**.

---

¿Tienes otro modelo local favorito que no mencioné? Compártelo en [LinkedIn](https://www.linkedin.com/in/francobosg/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[5 Errores Comunes al Configurar Copilot, Cline y Cursor en VS Code (Soluciones)]]></title>
      <link>https://francobosg.netlify.app/blog/errores-comunes-ia-vscode-copilot-cline-2026/</link>
      <description><![CDATA[¿Tu extensión de IA no funciona en VS Code? Los 5 errores más frecuentes al configurar GitHub Copilot, Cline y Cursor con soluciones paso a paso.]]></description>
      <pubDate>Sat, 11 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/errores-comunes-ia-vscode-copilot-cline-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[> **TL;DR**: Los errores más comunes son: sesión expirada (Error 1), API key mal configurada (Error 2), conflicto entre extensiones (Error 3), el modelo no responde por rate limits (Error 4) y la extensión no reconoce archivos (Error 5). Abajo tienes la solución de cada uno en menos de 2 minutos.

## Requisitos previos

- [ ] VS Code actualizado a la última versión (2026+)
- [ ] Al menos una extensión de IA instalada (Copilot, Cline o Cursor)
- [ ] Panel de Output abierto (`Ctrl+Shift+U`) para ver logs de errores

---

Instalas la extensión de IA en VS Code, la configuras siguiendo el README… y no pasa nada. Sin sugerencias, sin chat, sin respuestas. O peor: un error críptico que no aparece en ningún tutorial.

Estos son los **5 errores que más se repiten** en comunidades de desarrollo, GitHub Issues y Stack Overflow. Los he sufrido todos.

## Error 1: "GitHub Copilot is not available" / No aparecen sugerencias

### Síntomas

- El icono de Copilot en la barra inferior muestra ⚠️ o ❌
- No aparece autocompletado inline al escribir código
- El chat de Copilot dice "Session expired" o simplemente no responde

### Causa

Tu sesión de GitHub en VS Code ha expirado o la autenticación OAuth se ha roto. Esto pasa frecuentemente después de actualizaciones de VS Code o del SO.

### Solución (2 minutos)

```
1. Abre Command Palette: Ctrl+Shift+P
2. Busca: "GitHub: Sign Out"
3. Confirma el cierre de sesión
4. Busca: "GitHub: Sign In"
5. Autentícate de nuevo en el navegador
6. Reinicia VS Code completamente (cerrar y abrir)
```

Si sigue sin funcionar:

```
1. Ctrl+Shift+P → "Extensions: Show Installed Extensions"
2. Busca "GitHub Copilot" → clic en ⚙️ → "Update" (si hay actualización)
3. Busca "GitHub Copilot Chat" → igual, actualiza
4. Reinicia VS Code
```

> **Pro-Tip**: Si usas una cuenta de organización de GitHub, tu admin puede haber desactivado Copilot para tu equipo sin avisarte. Verifica en `github.com/settings/copilot` que tu suscripción esté activa.

## Error 2: "Invalid API Key" / "Authentication failed" en Cline

### Síntomas

- Cline muestra "Error: Invalid API key" al intentar enviar un mensaje
- El chat se abre pero las respuestas fallan con error 401 o 403
- Funciona un rato y luego deja de funcionar

### Causa

La API key está mal copiada (espacio extra, carácter invisible), ha expirado, o estás apuntando al proveedor incorrecto.

### Solución

```json
// ❌ Error común: espacio al final de la key
"cline.apiKey": "sk-ant-api03-xxxx... "

// ✅ Correcto: sin espacios
"cline.apiKey": "sk-ant-api03-xxxx..."
```

**Checklist de verificación:**

1. Abre la configuración de Cline en VS Code (icono de Cline → Settings)
2. Verifica que el **proveedor** coincide con tu key:
   - Keys que empiezan con `sk-ant-` → Anthropic
   - Keys que empiezan con `sk-` (sin `ant`) → OpenAI
   - Keys que empiezan con `AI` → Google AI Studio
3. Ve a la consola del proveedor y verifica que la key sigue activa:
   - OpenAI: [platform.openai.com/api-keys](https://platform.openai.com/api-keys)
   - Anthropic: [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys)
4. Si usas **Ollama local**, la key no es necesaria — asegúrate de que el servicio está corriendo:

```bash
# Verifica que Ollama esté activo
curl http://localhost:11434/api/tags
# Si no responde, inicia el servicio:
ollama serve
```

Si prefieres usar IA en local sin depender de API keys, tengo una [guía completa para configurar Ollama](/blog/ollama-ia-local-gratis-sin-api-2026/).

> **Advertencia**: Nunca guardes API keys directamente en `settings.json` si sincronizas VS Code con GitHub. Usa la opción de almacenamiento seguro de Cline o variables de entorno del sistema.

## Error 3: Conflicto entre extensiones de IA (sugerencias duplicadas o vacías)

### Síntomas

- Aparecen dos sugerencias de autocompletado superpuestas
- Las sugerencias parpadean o desaparecen antes de poder aceptarlas
- El autocompletado es más lento de lo normal
- Una extensión funciona pero la otra bloquea

### Causa

Tienes **dos o más extensiones** que intentan controlar el autocompletado inline al mismo tiempo: Copilot + Cline, Copilot + Continue, o Cursor + Copilot.

### Solución

La regla es: **solo una extensión debe manejar el autocompletado inline**. Las demás solo para chat.

Añade a tu `settings.json`:

```json
{
  // Si usas Copilot como autocompletado principal:
  "github.copilot.enable": {
    "*": true
  },
  // Desactiva autocompletado de Cline (usa solo su chat/agent)
  "cline.enableTabAutocomplete": false,

  // Si usas Continue, desactiva su tab autocomplete
  "continue.enableTabAutocomplete": false
}
```

Si estás en **Cursor** (que ya tiene Copilot integrado):

```json
{
  // Desactiva la extensión de Copilot — Cursor tiene la suya
  "github.copilot.enable": {
    "*": false
  }
}
```

> **Pro-Tip**: Para saber qué extensión está causando el conflicto, desactívalas todas, activa una sola y prueba. Luego ve añadiendo de una en una. Es el debugging más básico pero es el que funciona.

## Error 4: Respuestas cortadas, timeout o Error 429 en VS Code

### Síntomas

- La IA empieza a responder y se corta a mitad de frase
- Ves "Request timed out" o "Rate limit exceeded" en el output
- El chat se queda "pensando" infinitamente

### Causa

Estás alcanzando los **rate limits** de la API del proveedor (si en cambio ves errores de "context length exceeded" o respuestas truncadas, revisa [cómo solucionar el error context length exceeded](/blog/error-context-length-exceeded-openai-claude-2026/)). Esto es especialmente frecuente con:

- Cuentas nuevas de OpenAI (Tier 1: 500 RPM)
- Tier gratuito de Anthropic (5 RPM)
- Extensiones que hacen múltiples llamadas en background (indexación, codebase analysis)

### Solución

**Paso 1**: Identifica qué proveedor está limitándote

```
1. Abre Output Panel: Ctrl+Shift+U
2. En el dropdown, selecciona la extensión (Cline, Copilot, etc.)
3. Busca mensajes con "429", "rate limit" o "quota exceeded"
```

**Paso 2**: Ajusta la configuración de la extensión

```json
{
  // Reduce la frecuencia de peticiones automáticas en Cline
  "cline.maxConcurrentRequests": 1,

  // Desactiva indexación automática del codebase (reduce llamadas API)
  "cline.enableCodebaseIndexing": false,

  // Aumenta el timeout para peticiones lentas
  "cline.requestTimeout": 120000
}
```

**Paso 3**: Si el problema persiste, implementa la solución a nivel de código para tus proyectos. Tengo una [guía completa para solucionar el Error 429 con retry y backoff](/blog/error-429-too-many-requests-api-ia-2026/).

**Paso 4**: Cambia a un modelo más barato para tareas simples, que tiene rate limits más altos:

| Modelo | RPM en Tier 1 | Uso recomendado en VS Code |
|--------|--------------|---------------------------|
| GPT-4.1 | 500 | Chat, refactoring |
| GPT-4.1 mini | 10.000 | Autocompletado, inline edits |
| Claude Sonnet 4 | 50 | Chat, debugging |
| Ollama (local) | ∞ | Todo (sin límites) |

## Error 5: La extensión no reconoce el tipo de archivo

### Síntomas

- Copilot/Cline funcionan en `.js` y `.py` pero no en `.astro`, `.svelte`, `.vue` o `.md`
- No hay sugerencias en archivos de configuración (`.toml`, `.yaml`, `.env`)
- El chat funciona pero el autocompletado inline no aparece en ciertos archivos

### Causa

Las extensiones de IA no soportan todos los lenguajes por defecto, o VS Code no ha detectado bien el tipo de archivo.

### Solución

```json
{
  // Activa Copilot para lenguajes adicionales
  "github.copilot.enable": {
    "*": true,
    "markdown": true,
    "yaml": true,
    "plaintext": true,
    "astro": true,
    "svelte": true
  },

  // Asocia extensiones no estándar con un lenguaje
  "[astro]": {
    "editor.defaultFormatter": "astro-build.astro-vscode"
  }
}
```

Para archivos `.env` y `.toml`:

```json
{
  "files.associations": {
    "*.env.*": "dotenv",
    "*.toml": "toml"
  }
}
```

> **Pro-Tip**: Si trabajas con Astro (como este blog), instala la extensión oficial `astro-build.astro-vscode`. Sin ella, Copilot no entiende la sintaxis `.astro` y genera sugerencias de HTML genérico en vez de componentes Astro válidos.

## Tabla resumen: diagnóstico rápido

| Síntoma | Error probable | Solución rápida |
|---------|---------------|----------------|
| Icono ⚠️ en barra inferior | Error 1: Sesión expirada | Sign Out → Sign In → Reiniciar |
| "Invalid API Key" | Error 2: Key mal configurada | Verificar proveedor + key sin espacios |
| Sugerencias parpadean | Error 3: Conflicto extensiones | Desactivar autocompletado duplicado |
| "Rate limit exceeded" | Error 4: 429 Too Many Requests | Reducir concurrencia + [guía 429](/blog/error-429-too-many-requests-api-ia-2026/) |
| Sin sugerencias en .astro | Error 5: Archivo no reconocido | Añadir asociación en settings.json |

## Checklist post-configuración

Después de solucionar el error, verifica que todo funciona:

- [ ] El icono de Copilot/Cline muestra ✅ en la barra inferior
- [ ] Las sugerencias inline aparecen al escribir en un archivo `.js` o `.py`
- [ ] El chat responde al enviar un mensaje
- [ ] No hay errores en el Output Panel (`Ctrl+Shift+U`)
- [ ] Solo **una** extensión maneja el autocompletado inline

## ¿Qué extensión elegir?

Si después de solucionar los errores sigues sin saber cuál usar, aquí va el resumen:

| Si necesitas... | Usa |
|----------------|-----|
| Autocompletado rápido y barato | GitHub Copilot ($10/mes) |
| Agent multi-archivo potente | Cursor ($20/mes) |
| Flexibilidad de proveedores | Cline (gratis, tú pones la API key) |
| IA gratis y local | [Ollama + Continue](/blog/ollama-ia-local-gratis-sin-api-2026/) |

Para una comparativa más profunda, lee la [comparativa completa entre Cursor, Copilot y Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/).

Una vez que tus herramientas estén configuradas y generando código sin errores, el siguiente paso es validar ese código. Revisa [cómo testear código generado por IA](/blog/testear-codigo-generado-ia-copilot-cursor-2026/) para detectar bugs antes de que lleguen a producción.

Si necesitas ayuda para estructurar mejor tus peticiones a la IA y evitar errores, prueba el [generador de prompts interactivo](/blog/herramientas/generador-prompts/). Y si buscas soluciones a errores técnicos específicos, consulta el [índice de errores comunes en desarrollo](/blog/errores/) con guías paso a paso.

---

¿Has encontrado otro error de configuración que no menciono? Cuéntamelo en [LinkedIn](https://www.linkedin.com/in/francobosg/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Error 429 Too Many Requests en APIs de IA: Causas y Solución]]></title>
      <link>https://francobosg.netlify.app/blog/error-429-too-many-requests-api-ia-2026/</link>
      <description><![CDATA[¿Error 429 al llamar a la API de OpenAI, Anthropic o Google? Causas técnicas, código para solucionarlo con retry y backoff exponencial, y límites por proveedor.]]></description>
      <pubDate>Fri, 10 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/error-429-too-many-requests-api-ia-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Estás desarrollando tu app, los tests pasan, todo funciona… y de repente:

```
Error 429: Too Many Requests
Rate limit reached for gpt-4.1 in organization org-xxx on tokens per min (TPM):
Limit 30000, Used 28500, Requested 2000.
```

Tu app se para. Tus usuarios ven errores. Y tú no sabes si es un bug, si has gastado demasiado o si te están bloqueando.

**Es el Error 429**. El error más común (y más frustrante) al trabajar con APIs de IA. En este artículo te explico por qué ocurre y te doy código listo para solucionarlo.

## ¿Qué significa el Error 429 Too Many Requests?

El código HTTP 429 significa que el servidor rechaza tu petición porque has superado un **límite de uso** (rate limit). No es un bug de tu código ni un problema del servidor — es un mecanismo de protección.

En las APIs de IA, los límites existen por:

- **Proteger la infraestructura** — los modelos de IA consumen GPU caras
- **Garantizar servicio equitativo** — que un usuario no acapare todos los recursos
- **Controlar costes** — evitar facturas sorpresa por bucles infinitos

## ¿Por qué ocurre? Las 4 causas técnicas

### 1. Rate limit por peticiones (RPM)

Has hecho demasiadas peticiones en un minuto. Cada proveedor tiene un límite de **Requests Per Minute** (RPM).

```
// ❌ Esto dispara el 429: 100 peticiones simultáneas
const promises = urls.map(url => openai.chat.completions.create({...}));
await Promise.all(promises); // 💥 429
```

### 2. Rate limit por tokens (TPM)

Has enviado demasiados tokens en un minuto. Aunque hagas pocas peticiones, si cada una lleva un contexto de 50K tokens, puedes superar el límite de **Tokens Per Minute** (TPM).

```
// ❌ Enviar un archivo enorme como contexto
const context = fs.readFileSync('codebase-entero.txt', 'utf-8'); // 100K tokens
await openai.chat.completions.create({
  messages: [{ role: 'user', content: context + '\nResume esto' }],
}); // 💥 429 por TPM
```

### 3. Cuota mensual agotada

Has gastado el presupuesto que tu cuenta permite. OpenAI y Anthropic tienen límites de gasto mensual por tier. Si estás en Tier 1 y tu límite es $100/mes, al llegar a esa cifra obtienes un 429 hasta el siguiente ciclo.

### 4. Peticiones concurrentes excesivas

Algunos proveedores limitan cuántas peticiones pueden estar procesándose a la vez. Si lanzas 50 llamadas simultáneas y el límite es 25, las que sobren reciben un 429.

## Solución paso a paso

### Paso 1: Identifica qué límite estás superando

El Error 429 viene con headers que te dicen qué está pasando:

```javascript
try {
  const response = await openai.chat.completions.create({...});
} catch (error) {
  if (error.status === 429) {
    console.log('Tipo:', error.headers['x-ratelimit-limit-requests']);
    console.log('Restantes:', error.headers['x-ratelimit-remaining-requests']);
    console.log('Reset en:', error.headers['x-ratelimit-reset-requests']);
    console.log('Retry-After:', error.headers['retry-after']);
  }
}
```

| Header | Qué indica |
|--------|-----------|
| `x-ratelimit-limit-requests` | Tu límite de RPM |
| `x-ratelimit-remaining-requests` | Peticiones que te quedan |
| `x-ratelimit-limit-tokens` | Tu límite de TPM |
| `x-ratelimit-remaining-tokens` | Tokens que te quedan |
| `retry-after` | Segundos que debes esperar |

### Paso 2: Implementa retry con backoff exponencial

La solución estándar: si recibes un 429, espera y reintenta. Cada reintento duplica el tiempo de espera para no saturar el servidor.

```javascript
async function callWithRetry(fn, maxRetries = 5) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (error.status !== 429 || attempt === maxRetries - 1) {
        throw error;
      }

      // Backoff exponencial: 1s, 2s, 4s, 8s, 16s
      const retryAfter = error.headers?.['retry-after'];
      const delay = retryAfter
        ? parseInt(retryAfter) * 1000
        : Math.pow(2, attempt) * 1000 + Math.random() * 1000;

      console.log(`429 recibido. Reintentando en ${Math.round(delay / 1000)}s...`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Uso
const response = await callWithRetry(() =>
  openai.chat.completions.create({
    model: 'gpt-4.1',
    messages: [{ role: 'user', content: 'Hola' }],
  })
);
```

El `+ Math.random() * 1000` (jitter) es importante: evita que múltiples instancias de tu app reintenten al mismo tiempo y vuelvan a saturar el límite.

### Paso 3: Controla el flujo de peticiones (rate limiter)

Si haces muchas llamadas (scraping, procesamiento masivo, agentes), necesitas un rate limiter que controle cuántas peticiones se envían por minuto.

```javascript
class RateLimiter {
  constructor(maxPerMinute) {
    this.maxPerMinute = maxPerMinute;
    this.queue = [];
    this.timestamps = [];
  }

  async acquire() {
    const now = Date.now();
    this.timestamps = this.timestamps.filter(t => now - t < 60000);

    if (this.timestamps.length >= this.maxPerMinute) {
      const waitTime = 60000 - (now - this.timestamps[0]);
      await new Promise(resolve => setTimeout(resolve, waitTime));
    }

    this.timestamps.push(Date.now());
  }
}

// Uso: máximo 50 peticiones/minuto
const limiter = new RateLimiter(50);

async function callAPI(prompt) {
  await limiter.acquire();
  return openai.chat.completions.create({
    model: 'gpt-4.1-mini',
    messages: [{ role: 'user', content: prompt }],
  });
}

// Procesar 500 items sin 429
for (const item of items) {
  const result = await callAPI(item.text);
  // ...
}
```

### Paso 4: Optimiza tokens para gastar menos cuota

Menos tokens = menos probabilidad de alcanzar el TPM.

```javascript
// ❌ System prompt de 500 tokens
system: "Eres un asistente altamente cualificado experto en... [párrafo enorme]"

// ✅ System prompt de 30 tokens
system: "Senior dev. Responde en español. Solo código, sin explicación."

// ❌ Enviar todo el archivo
content: entireFileContent // 50K tokens

// ✅ Enviar solo lo relevante
content: extractRelevantChunk(entireFileContent, query) // 2K tokens
```

### Paso 5: Cachea respuestas repetidas

Si haces la misma pregunta varias veces, no llames a la API de nuevo.

```javascript
const cache = new Map();

async function cachedCompletion(prompt, model = 'gpt-4.1-mini') {
  const key = `${model}:${prompt}`;

  if (cache.has(key)) {
    return cache.get(key);
  }

  const response = await callWithRetry(() =>
    openai.chat.completions.create({
      model,
      messages: [{ role: 'user', content: prompt }],
    })
  );

  const result = response.choices[0].message.content;
  cache.set(key, result);
  return result;
}
```

Para producción, sustituye el `Map()` por Redis o un archivo JSON persistente.

## Límites reales por proveedor en 2026

Estos son los límites con los que te vas a encontrar:

### OpenAI

| Tier | RPM (GPT-4.1) | TPM (GPT-4.1) | Gasto mensual máx. |
|------|---------------|----------------|---------------------|
| Free | 3 | 40.000 | — |
| Tier 1 ($5+ gastados) | 500 | 200.000 | $100 |
| Tier 2 ($50+) | 5.000 | 2.000.000 | $500 |
| Tier 3 ($100+) | 5.000 | 4.000.000 | $1.000 |
| Tier 5 ($1.000+) | 10.000 | 30.000.000 | $5.000 |

### Anthropic (Claude)

| Tier | RPM | TPM input | TPM output |
|------|-----|-----------|------------|
| Free (Build) | 5 | 20.000 | 8.000 |
| Tier 1 ($5+) | 50 | 80.000 | 16.000 |
| Tier 2 ($40+) | 1.000 | 160.000 | 32.000 |
| Tier 3 ($200+) | 2.000 | 320.000 | 64.000 |
| Tier 4 ($400+) | 4.000 | 640.000 | 128.000 |

### Google (Gemini)

| Plan | RPM | TPM | Precio |
|------|-----|-----|--------|
| AI Studio (gratis) | 15 | 1.000.000 | $0 |
| AI Studio (pay-as-you-go) | 2.000 | 4.000.000 | Variable |
| Vertex AI | 1.000+ | Configurable | Variable |

### DeepSeek

| Plan | RPM | TPM |
|------|-----|-----|
| API estándar | 60 | 500.000 |
| Con saldo > $10 | 300 | 2.000.000 |

## Cuándo la solución es cambiar de proveedor

A veces el 429 te está diciendo que necesitas otro approach:

| Situación | Solución |
|-----------|----------|
| Necesitas >2.000 RPM y estás en Tier 1 de OpenAI | Gasta más para subir a Tier 2+, o usa Google AI Studio (2.000 RPM pay-as-you-go) |
| Procesas datos masivos (10K+ documentos) | Usa **Batch API** de OpenAI (50% descuento + sin rate limit con cola) |
| Tu app tiene picos impredecibles | Distribuye entre proveedores: OpenAI + Anthropic + Google |
| No puedes permitirte rate limits | Ejecuta modelos locales con Ollama (sin límites) |
| Haces muchas peticiones idénticas | [Prompt Caching](/blog/prompt-caching-openai-claude-ahorrar-tokens-2026/) (90% descuento en tokens cacheados) |

Si estás evaluando costes, consulta la [calculadora de precios de IA](/blog/calculadora-precios-ia-2026/) para comparar todos los proveedores. Y si buscas opciones sin coste, mira cómo [usar la API de ChatGPT y Claude gratis](/blog/usar-api-chatgpt-claude-gratis-2026/). Si el error ocurre en tu extensión de VS Code, consulta los [5 errores comunes con Copilot, Cline y Cursor](/blog/errores-comunes-ia-vscode-copilot-cline-2026/).

Este error también está documentado en el [índice de errores comunes en desarrollo](/blog/errores/), donde encontrarás guías para otros errores frecuentes como CORS y ENOENT.

## Solución completa: wrapper robusto para producción

Aquí tienes una clase que combina retry, rate limiting y cache. Lista para copiar en tu proyecto:

```javascript
import OpenAI from 'openai';

class RobustAIClient {
  constructor({ apiKey, maxRPM = 50, maxRetries = 5 }) {
    this.client = new OpenAI({ apiKey });
    this.maxRetries = maxRetries;
    this.timestamps = [];
    this.maxRPM = maxRPM;
    this.cache = new Map();
  }

  async rateLimit() {
    const now = Date.now();
    this.timestamps = this.timestamps.filter(t => now - t < 60000);
    if (this.timestamps.length >= this.maxRPM) {
      const wait = 60000 - (now - this.timestamps[0]);
      await new Promise(r => setTimeout(r, wait));
    }
    this.timestamps.push(Date.now());
  }

  async chat(prompt, { model = 'gpt-4.1-mini', useCache = true } = {}) {
    const cacheKey = `${model}:${prompt}`;
    if (useCache && this.cache.has(cacheKey)) return this.cache.get(cacheKey);

    await this.rateLimit();

    for (let attempt = 0; attempt < this.maxRetries; attempt++) {
      try {
        const res = await this.client.chat.completions.create({
          model,
          messages: [{ role: 'user', content: prompt }],
        });
        const result = res.choices[0].message.content;
        if (useCache) this.cache.set(cacheKey, result);
        return result;
      } catch (err) {
        if (err.status !== 429 || attempt === this.maxRetries - 1) throw err;
        const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
        console.warn(`429 → retry ${attempt + 1}/${this.maxRetries} en ${Math.round(delay / 1000)}s`);
        await new Promise(r => setTimeout(r, delay));
      }
    }
  }
}

// Uso
const ai = new RobustAIClient({
  apiKey: process.env.OPENAI_API_KEY,
  maxRPM: 100,
});

const answer = await ai.chat('Explica qué es un rate limiter');
```

Si estás construyendo agentes que hacen múltiples llamadas, este wrapper te evitará el 429 en el 99% de los casos. Para un ejemplo real de agente en producción, sigue mi [tutorial de agente con LangChain y Node.js](/blog/crear-agente-ia-langchain-nodejs-tutorial/).

## Checklist rápido anti-429

- [ ] ¿Tienes retry con backoff exponencial + jitter?
- [ ] ¿Controlas el RPM con un rate limiter?
- [ ] ¿Cacheas respuestas repetidas?
- [ ] ¿Usas el modelo más pequeño posible para cada tarea?
- [ ] ¿Limitas `max_tokens` en cada petición?
- [ ] ¿Envías solo el contexto necesario (no archivos enteros)?
- [ ] ¿Sabes en qué tier estás y cuáles son tus límites?
- [ ] ¿Tienes un fallback a otro proveedor si el principal falla?

Si marca todos: el Error 429 no debería volver a pararte.

---

¿Te ha funcionado? Si conoces otro truco para manejar rate limits o has encontrado un caso raro de 429, compártelo en [LinkedIn](https://www.linkedin.com/in/francobosg/) o visita [mi portfolio](/) para ver proyectos donde gestiono miles de llamadas a APIs de IA en producción.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Bun vs Node.js en Producción 2026: ¿Vale la Pena Migrar?]]></title>
      <link>https://francobosg.netlify.app/blog/bun-vs-nodejs-produccion-2026/</link>
      <description><![CDATA[Comparativa real de Bun vs Node.js para producción en 2026: velocidad, compatibilidad, ecosistema y cuándo tiene sentido migrar tu proyecto.]]></description>
      <pubDate>Fri, 10 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/bun-vs-nodejs-produccion-2026/</guid>
      <category>Node.js</category>
      <category>Herramientas</category>
      <category>Backend</category>
      <content:encoded><![CDATA[Bun ya no es un experimento. Con versión estable y adopción creciente, en 2026 vale la pena considerar si tiene sentido en tu stack. Esta no es la comparativa de "mira qué fast los benchmarks" — es la de "¿te sirve para tu proyecto real?".

## Qué es Bun exactamente

Bun es un runtime JavaScript/TypeScript escrito en Zig, diseñado desde cero para ser rápido. Incluye:

- **Runtime** JavaScript (como Node.js)
- **Package manager** (como npm/yarn)
- **Bundler** (como Webpack/Vite)
- **Test runner** (como Jest/Vitest)
- **TypeScript nativo** — no necesitas `ts-node` ni transpilación previa

Todo en un único ejecutable de ~100MB.

---

## Velocidad: los números reales

```bash
# Benchmarks de instalación (proyecto mediano, ~200 deps)
npm install     → ~45s
yarn install    → ~30s
pnpm install    → ~15s
bun install     → ~3s
```

```bash
# Tiempo de inicio de servidor HTTP básico
Node.js (v22)   → ~80ms
Bun (v1.1)      → ~12ms
```

Para tests, Bun suele ser 3-10x más rápido que Jest en ejecución:

```bash
jest            → 8.2s (500 tests)
vitest          → 3.1s
bun test        → 1.4s
```

---

## Compatibilidad con Node.js

El punto crítico. Bun implementa las APIs de Node.js más comunes, pero no todo:

**Compatible sin problemas:**
- `fs`, `path`, `os`, `crypto`
- HTTP/HTTPS (`http.createServer`)
- Express, Fastify, Hono
- Prisma (con Bun adapter), Drizzle
- dotenv (no necesario — Bun carga `.env` automáticamente)
- TypeScript y JSX nativos

**Puede dar problemas:**
- Paquetes con binarios nativos (addons de C/C++)
- Algunas APIs de `child_process` complejas
- `worker_threads` (soporte en progreso)

**Verificar antes de migrar:**
```bash
bun pm why <paquete> # inspecciona dependencias
```

---

## Usar Bun como package manager (sin cambiar el runtime)

El caso de uso más seguro y con más ROI inmediato:

```bash
# Instalar Bun
curl -fsSL https://bun.sh/install | bash  # Mac/Linux
# Windows: via scoop o winget

# Usar en tu proyecto Node.js existente
bun install          # sustituye a npm install
bun add express      # sustituye a npm install express
bun remove express   # sustituye a npm uninstall express

# bun.lockb en vez de package-lock.json
# Añadir al .gitignore si trabajas con equipos mixtos o
# commitear para reproducibilidad
```

---

## Usar Bun como runtime

### Servidor básico con Bun HTTP nativo

```typescript
// server.ts
const server = Bun.serve({
  port: 3000,
  async fetch(request) {
    const url = new URL(request.url);
    
    if (url.pathname === '/api/health') {
      return Response.json({ status: 'ok', runtime: 'bun' });
    }
    
    return new Response('Not Found', { status: 404 });
  },
});

console.log(`Servidor en http://localhost:${server.port}`);
```

### Con Hono (framework recomendado para Bun)

```bash
bun add hono
```

```typescript
// app.ts
import { Hono } from 'hono';

const app = new Hono();

app.get('/', (c) => c.text('Hola desde Bun + Hono'));
app.get('/api/users', async (c) => {
  const users = await getUsers(); // tu lógica de DB
  return c.json(users);
});

export default {
  port: 3000,
  fetch: app.fetch,
};
```

```bash
bun run app.ts
```

### Con Express (compatible)

```typescript
// server.ts
import express from 'express';

const app = express();
app.use(express.json());

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.listen(3000, () => console.log('Servidor en :3000'));
```

```bash
bun run server.ts  # TypeScript directo, sin ts-node
```

---

## Bun test runner

```typescript
// usuario.test.ts
import { describe, it, expect, beforeEach } from 'bun:test';

describe('Usuario', () => {
  it('debe crear usuario correctamente', () => {
    const user = { id: '1', email: 'test@ejemplo.com' };
    expect(user.email).toBe('test@ejemplo.com');
  });

  it('debe validar email', () => {
    const esValido = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
    expect(esValido('valido@ejemplo.com')).toBe(true);
    expect(esValido('invalido')).toBe(false);
  });
});
```

```bash
bun test
bun test --watch        # watch mode
bun test --coverage     # coverage
```

---

## Migrar proyecto Node.js a Bun: proceso real

```bash
# 1. Instalar Bun
curl -fsSL https://bun.sh/install | bash

# 2. Convertir dependencias
rm package-lock.json
bun install  # crea bun.lockb

# 3. Actualizar scripts en package.json
# "start": "node dist/index.js" → "start": "bun dist/index.js"
# "dev": "ts-node src/index.ts" → "dev": "bun src/index.ts"
# "test": "jest"                → "test": "bun test"

# 4. Probar que todo funciona
bun run dev
bun test

# 5. Si hay problemas con algún paquete, volver a Node.js solo para ese paso
# (puedes usar Bun como package manager y Node.js como runtime)
```

---

## ¿Cuándo NO migrar?

- Tu app usa paquetes con binarios nativos problemáticos
- El equipo es grande y la compatibilidad 100% es prioritaria
- Estás en un entorno corporativo donde Node.js está certificado/aprobado
- Tu app ya es lo suficientemente rápida — el ROI de la migración puede no compensar el riesgo

## ¿Cuándo SÍ tiene sentido?

- Proyecto nuevo sin baggage de compatibilidad
- Quieres un DX mejor con TypeScript nativo sin configuración
- Los tiempos de CI/CD son lentos por la instalación de dependencias
- Tu app es un script, CLI tool o serverless function donde el cold start importa

Para un workflow de desarrollo moderno con TypeScript, complementa con [la guía de TypeScript para developers JavaScript](/blog/typescript-para-javascript-developers-guia-2026/). Si usas Prisma como ORM con Bun, consulta el [tutorial completo de Prisma](/blog/prisma-desde-cero-tutorial-completo-2026/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Caso Real: SaaS de Captación de Clientes con NestJS, React y React Native]]></title>
      <link>https://francobosg.netlify.app/blog/caso-real-saas-atrapaclientes-nestjs-react/</link>
      <description><![CDATA[Atrapaclientes: plataforma SaaS multitenant con 155 endpoints, 29 entidades, 74 migraciones, RBAC con 31 permisos, WebSockets, email marketing, SMS/WhatsApp Twilio, Club Infantil, talleres, ludoteca y app kiosko en React Native. Desplegado en producción en atrapaclientes.es.]]></description>
      <pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/caso-real-saas-atrapaclientes-nestjs-react/</guid>
      <category>Caso Real</category>
      <category>NestJS</category>
      <category>React</category>
      <category>TypeScript</category>
      <category>SaaS</category>
      <category>Docker</category>
      <category>PostgreSQL</category>
      <category>Arquitectura</category>
      <category>React Native</category>
      <category>Twilio</category>
      <content:encoded><![CDATA[## TL;DR

Construí **Atrapaclientes**, una plataforma SaaS multitenant completa en 2 meses. Backend con NestJS (155 endpoints, 25 módulos, RBAC con 31 permisos, WebSockets, email marketing, SMS/WhatsApp Twilio), frontend React 19 con 30 páginas, app kiosko con React Native en producción, PostgreSQL 17 con 74 migraciones y despliegue con Docker + Coolify en VPS propio en [atrapaclientes.es](https://atrapaclientes.es). Aquí explico las decisiones técnicas, los retos reales y cómo los resolví.

<video
  src="/videos/atrapaclientes.mp4"
  controls
  muted
  playsinline
  class="w-full rounded-xl my-6 shadow-lg"
  style="max-height: 480px; object-fit: cover;"
>
  Tu navegador no soporta vídeo HTML5.
</video>

---

## El problema

Un negocio necesitaba captar datos de clientes en el punto de venta físico (centros comerciales, tiendas, eventos) mediante tablets interactivas con campañas atractivas: sorteos, descuentos, Club Infantil. Las soluciones existentes eran caras, sin personalización y sin control sobre los datos propios.

**Requisitos reales:**
- Cada cliente (tenant) debe ver solo sus datos — aislamiento total
- Tablets en tiendas mostrando campañas, recogiendo formularios en tiempo real
- Generación de códigos alfanuméricos sin duplicados para miles de participantes
- Email marketing, SMS y WhatsApp para comunicar premios y reactivar clientes
- Dashboard con métricas de participación, empleados y terminales en directo
- Control total del dato — nada de SaaS de terceros

---

## La arquitectura que elegí

```
┌──────────────────────────────────────────────────┐
│  App Móvil (React Native 0.81 + Expo SDK 54)     │
│  5 pantallas · Modo kiosko · i18n · Autostart    │
│  Puerta admin oculta (7 toques + PIN)            │
└──────────────────┬───────────────────────────────┘
                   │ HTTPS / JWT Terminal
                   ▼
┌──────────────────────────────────────────────────┐
│  Frontend Web (React 19.2 + Vite 7.3)            │
│  30 páginas · 42 componentes · 12 modales        │
│  24 servicios API · TypeScript strict mode       │
└──────────────────┬───────────────────────────────┘
                   │ HTTPS / JWT Bearer
                   ▼
┌──────────────────────────────────────────────────┐
│  Backend (NestJS 11 + TypeORM 0.3)               │
│  25 módulos · 155 endpoints REST                 │
│  RBAC: 5 roles + 31 permisos + IDOR protection  │
│  WebSocket Gateway (Socket.io)                   │
│  Email marketing + SMS/WhatsApp (Twilio)         │
│  Talleres + Ludoteca + Club Infantil             │
│  Reportes PDF (PDFKit) + Audit logs              │
└──────────────────┬───────────────────────────────┘
                   │ TypeORM
                   ▼
┌──────────────────────────────────────────────────┐
│  PostgreSQL 17 · 29 entidades · 74 migraciones   │
│  UUID PKs · JSONB · Soft delete · Row-level MT   │
└──────────────────────────────────────────────────┘
```

### Por qué NestJS y no Express

Express es flexible, pero en un SaaS con 155 endpoints y 25 módulos necesitas estructura. NestJS me dio:

- **Módulos**: cada funcionalidad (auth, campañas, newsletters, sms, talleres, ludoteca) completamente aislada
- **Guards y decoradores**: el `TenantContextGuard` inyecta el tenant en cada request sin tocar los controllers
- **Pipes**: validación automática con `class-validator` en cada DTO
- **Swagger**: documentación auto-generada de los 155 endpoints en `/api/docs`

Con Express habría terminado con un `app.js` de 5.000 líneas. NestJS impone convenciones que escalan.

### Multi-tenancy: aislamiento a nivel de fila

No creé una base de datos por tenant (caro y complejo). Añadí `tenantId` como FK en todas las tablas y creé un guard que lo inyecta automáticamente:

```typescript
@Injectable()
export class TenantContextGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    request.tenantId = user.tenantId;
    return true;
  }
}
```

Cada query filtra por tenant sin que el desarrollador tenga que acordarse. **Cero posibilidad de data leak entre clientes.**

### RBAC con 5 roles y 31 permisos granulares

No usé librerías de permisos externas. Definí 31 permisos y un decorador `@RequirePermission()`:

| Rol | Scope | Descripción |
|-----|-------|-------------|
| `SUPERADMIN` | Global | Control total del sistema, todos los tenants |
| `ADMIN` | Global | Administración general |
| `GERENTE` | Un tenant | Gestión de su centro comercial |
| `USER` | Un tenant | Acceso básico |
| `TERMINAL` | Un tenant | Token de tablet/kiosco |

Permisos como `USERS_MANAGE`, `CAMPAIGNS_VIEW`, `TERMINALS_MANAGE`, `AUDIT_LOGS_VIEW` se asignan por rol. El guard verifica en cada endpoint. Si no tienes permiso, 403. Sin excepciones.

Además, implementé protección IDOR: validación `@IsUUID()` en todos los DTOs. Si un GERENTE intenta acceder a datos de otro tenant, se registra `[IDOR BLOCKED]` en los audit logs con IP y userAgent, y recibe 403.

---

## Los 3 componentes del sistema

### 1. Panel Web de Administración

SPA con React 19.2 + TypeScript strict + Vite 7.3 con 30 páginas y 42 componentes:

- **Wizard de 3 pasos** para crear campañas (datos, reglas, diseño visual)
- **Constructor de formularios dinámicos** con JSON Schema (5+ tipos de campo)
- **Gestión de terminales**: ver cuáles están online, asignar campañas, espejo en tiempo real
- **Módulo de newsletters**: plantillas HTML personalizables, envío masivo, tracking aperturas/clics
- **Campañas SMS y WhatsApp** vía Twilio con estadísticas y webhooks de entrega
- **Segmentación dinámica** de clientes por criterios personalizados + sistema de tags
- **Gestión de empleados** con ranking de captación y estadísticas por campaña
- **Talleres infantiles**: inscripciones, lista de espera, check-in y notificaciones
- **Ludoteca**: registros de entrada/salida y estadísticas de aforo
- **Reportes PDF** generados en servidor por campaña y por tenant
- **Audit logs** filtrables con exportación XLSX

### 2. App para Tablets / Kioscos (Android)

React Native 0.81.5 + Expo SDK 54, 5 pantallas (Setup, Idle, Home, Form, Result):

- **Modo kiosko real**: pantalla siempre encendida, barra de navegación oculta
- **Autostart en boot**: arranca automáticamente al encender el dispositivo
- **Salvapantallas animado** que atrae al cliente; al tocar muestra el formulario
- **Teclado personalizado** y selector de idioma (i18n por campaña)
- **Resultado instantáneo**: si el cliente ha ganado o no, con el código o mensaje
- **Vuelta automática** al salvapantallas tras 30s de inactividad
- **Puerta de admin oculta**: 7 toques en esquina + PIN para acceder al menú de configuración
- **Comandos remotos vía WebSocket**: cambio de campaña, reinicio, captura de pantalla

### 3. API Backend (NestJS)

25 módulos, 155 endpoints REST documentados con Swagger:

| Módulo | Endpoints | Descripción |
|--------|-----------|-------------|
| Campañas | 27 | CRUD + códigos + momentos ganadores + horarios + salvapantallas |
| SMS/WhatsApp | 12 | Twilio + envíos + webhooks + balance + estadísticas |
| Talleres | 12 | Inscripciones + check-in + lista espera + notificaciones |
| Newsletters | 9 | CRUD + envío + tracking apertura/click |
| Tenants | 11 | CRUD + Club Infantil (hijos + config) |
| Empleados | 9 | CRUD + ranking + stats + participaciones |
| Participantes | 11 | CRUD + perfil + tags + verificación email |
| Segmentos | 7 | CRUD + participantes + count |
| Tags | 8 | CRUD + assign + participante-tags |

---

## Problemas reales que encontré (y cómo los resolví)

### 1. Generación de códigos sin colisiones

Necesitaba generar hasta 1.000 códigos alfanuméricos (formato `ABC-DEFG`) por campaña sin duplicados, incluso con peticiones concurrentes.

**Solución**: algoritmo de generación con validación de unicidad en base de datos. Si hay colisión, regenera automáticamente. En producción: 0 duplicados en 15.000+ códigos generados.

### 2. Wizard de 3 pasos con formularios dinámicos

Las campañas se crean con un wizard (datos → reglas → diseño). Los formularios son dinámicos: el tenant configura qué campos quiere (texto, número, select, fecha, euros) usando JSON Schema.

**Solución**: `jsonb` en PostgreSQL para almacenar la configuración del formulario. React Context para mantener el estado del wizard entre pasos con persistencia, y renderizado dinámico de campos según el schema.

### 3. WebSockets para tablets en punto de venta

Las tablets necesitan recibir comandos remotos (cambiar campaña, reiniciar, capturar pantalla) y enviar participaciones en tiempo real.

**Solución**: Socket.io Gateway en NestJS con autenticación JWT de terminal. Cada tablet se registra con su token y queda vinculada al tenant. El dashboard muestra conexiones activas y permite enviar comandos remotos.

```typescript
@WebSocketGateway({ cors: true, namespace: '/terminales' })
export class TerminalGateway {
  @SubscribeMessage('register')
  handleRegister(client: Socket, payload: { token: string }) {
    // Verificar JWT terminal, vincular al tenant, almacenar conexión
  }

  sendCommand(tenantId: string, terminalId: string, command: RemoteCommand) {
    // Enviar comando a terminal específica del tenant
    this.server.to(`terminal:${terminalId}`).emit('command', command);
  }
}
```

### 4. App kiosko que no se cierra

La app necesitaba arrancar automáticamente al encender el dispositivo, mantener la pantalla siempre encendida y no mostrar la barra de navegación.

**Solución**: `expo-keep-awake` para pantalla permanente, `withBootCompletedReceiver` para autostart en boot Android, y `expo-navigation-bar` para ocultar la barra de navegación. Modo kiosko real sin root.

### 5. Email marketing con tracking sin dependencias externas

El sistema de newsletters necesitaba saber qué emails se abrían y qué enlaces se clicaban, sin pagar por servicios externos como Mailchimp.

**Solución**: pixel de tracking 1x1 incrustado en cada email con token único. Al cargarse, el backend registra la apertura. Para los clicks, todos los enlaces pasan por un redirect controller que anota el evento antes de redirigir al destino.

### 6. SMS y WhatsApp con Twilio

Los negocios necesitaban comunicarse con sus clientes por SMS y WhatsApp para informar de premios, recordatorios y campañas.

**Solución**: integración con Twilio en un módulo NestJS dedicado (12 endpoints). Soporte para envíos individuales y masivos con estadísticas. Webhooks de Twilio procesados con validación de firma para confirmar el estado de entrega de cada mensaje.

### 7. Migraciones en producción sin downtime

Con 74 migraciones acumuladas y múltiples entidades relacionadas, gestionar el schema en producción era crítico.

**Solución**: `dataSource.runMigrations()` se ejecuta automáticamente al arrancar la API en producción. En desarrollo, TypeORM usa `synchronize: true`. Cada migración está versionada con timestamp. 0 errores de schema en 2 meses de producción.

---

## Despliegue en producción

Despliegue real en [atrapaclientes.es](https://atrapaclientes.es):

- **VPS Ubuntu 24.04** con Docker Compose (3 contenedores: API NestJS, SPA Nginx, PostgreSQL 17)
- **Coolify v4** para auto-deploy desde GitHub push
- **Traefik v3.6** como proxy inverso con SSL automático (Let's Encrypt)
- **Nginx** sirviendo la SPA con caché y compresión gzip
- **Volúmenes persistentes** para imágenes (salvapantallas, logos, fondos) y datos de PostgreSQL
- **Migraciones automáticas** al arrancar la API

```yaml
# docker-compose.prod.yml simplificado
services:
  api:
    build: ./Dockerfile.api  # Node 22 Alpine + pnpm + NestJS
    environment:
      - DATABASE_URL=postgresql://...
      - JWT_SECRET=...
      - TWILIO_ACCOUNT_SID=...
    depends_on:
      - db
  web:
    build: ./Dockerfile.web  # Multi-stage: Build + Nginx Alpine
    ports:
      - "80:80"
  db:
    image: postgres:17-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
```

---

## Números del proyecto

| Métrica | Valor |
|---|---|
| Endpoints API | 155 |
| Módulos NestJS | 25 |
| Migraciones TypeORM | 74 |
| Entidades de BD | 29 |
| Páginas React | 30 |
| Componentes | 42 |
| Modales | 12 |
| Hooks (custom + React Query) | 21 |
| Servicios API | 24 |
| Interfaces TypeScript | 45+ |
| Tests | 63 |
| Roles RBAC | 5 |
| Permisos granulares | 31 |
| Tiempo de desarrollo | 2 meses |
| Lighthouse Performance | 88/100 |
| Accesibilidad | 92/100 |

---

## Lo que aprendí

1. **NestJS merece la pena** en proyectos medianos-grandes. 25 módulos bien delimitados son mantenibles; un Express sin estructura no lo es.
2. **Multi-tenancy a nivel de fila** funciona muy bien para SaaS donde los tenants comparten schema. El `TenantContextGuard` elimina el riesgo de data leak.
3. **IDOR protection es obligatorio** desde el día 1. Registrar los intentos bloqueados en audit logs con IP da información valiosa sobre ataques.
4. **74 migraciones con ejecución automática** — `synchronize: true` solo en desarrollo. En producción, cada cambio de schema es una migración versionada.
5. **Monorepo con Turborepo y pnpm** — compartir tipos entre API, web y mobile elimina bugs de tipado entre capas.
6. **Coolify > PaaS caros** — para proyectos propios, un VPS con Coolify da control total por una fracción del coste de Railway o Heroku.
7. **Twilio para SMS/WhatsApp** — la integración es directa, los webhooks de estado de entrega son fiables y el balance se puede consultar via API.

---

## ¿Necesitas algo similar?

Si tu empresa necesita una aplicación SaaS, un sistema con WebSockets, integración de SMS/WhatsApp, email marketing o cualquier proyecto full-stack con arquitectura real, [hablemos](/blog/servicios/).

**Otros casos reales:**
- [Ecosistema IA para reuniones con Gemini y Supabase](/blog/caso-real-ia-reuniones-gemini-supabase/)
- [Módulo GPS de flota para Dolibarr ERP](/blog/caso-real-gps-flota-vehiculos-dolibarr/)
- [Módulo IoT de sensores ESP32 para Dolibarr](/blog/caso-real-iot-sensores-esp32-dolibarr/)]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Caso Real: SaaS IA que Transcribe Reuniones y Genera Tickets (FastAPI + Next.js + Multitenancy)]]></title>
      <link>https://francobosg.netlify.app/blog/caso-real-ia-reuniones-gemini-supabase/</link>
      <description><![CDATA[Cómo construí iECO: un SaaS multitenancy de IA para reuniones. Múltiples empresas con aislamiento total de datos, roles granulares, flujo de registro con aprobación, panel de gestión de tenants y servidor dedicado montado desde cero con Docker + Coolify + Traefik SSL.]]></description>
      <pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/caso-real-ia-reuniones-gemini-supabase/</guid>
      <category>Caso Real</category>
      <category>SaaS</category>
      <category>IA</category>
      <category>Python</category>
      <category>Gemini</category>
      <category>FastAPI</category>
      <category>Next.js</category>
      <category>Docker</category>
      <category>Coolify</category>
      <category>Multitenancy</category>
      <category>RBAC</category>
      <category>Arquitectura</category>
      <category>Automatización</category>
      <content:encoded><![CDATA[## TL;DR

Construí **iECO**: un SaaS multitenancy de IA para reuniones. La idea es simple — sube el audio de una reunión, la IA transcribe identificando a cada hablante, extrae oportunidades de negocio y genera tickets automáticos. Cada empresa que usa el sistema tiene sus datos completamente aislados.

El recorrido: empezó como un prototipo con Streamlit + Supabase (v1), lo migré a **FastAPI + Next.js 15 + PostgreSQL + Docker + Coolify** con auth JWT real (v2), y finalmente lo convertí en un **SaaS multitenancy completo** con roles granulares, flujo de registro con aprobación y panel de gestión de empresas (v3). Todo el servidor lo monté desde cero: instalación del SO, Docker, Coolify y Traefik SSL.

<video
  src="/videos/ieco.mp4"
  controls
  muted
  playsinline
  class="w-full rounded-xl my-6 shadow-lg"
  style="max-height: 480px; object-fit: cover;"
>
  Tu navegador no soporta vídeo HTML5.
</video>

---

## Actualización v2: por qué Streamlit se quedó corto

Cuando arranqué el proyecto, Streamlit era la opción obvia. Es Python puro, te da una interfaz funcional en horas, y para un prototipo de IA es imbatible. Funcionó. Pero con el tiempo apareció una lista de problemas que no tenían solución real dentro del ecosistema Streamlit:

**Limitaciones concretas que encontré:**

- **Sin estado local real**: cada interacción recarga la app completa. En una interfaz de transcripción donde el usuario sube un audio y espera, eso genera una UX horrible.
- **Autenticación JWT imposible**: Streamlit no tiene routing ni sesiones propias. Implementar auth segura con access/refresh tokens es un parche sobre parche.
- **Sin roles de usuario**: no hay forma limpia de separar vistas por rol (user, admin) sin hacks.
- **Timeout en audios largos**: el proceso de transcripción bloqueaba el hilo principal de Streamlit, cortando la conexión antes de terminar.
- **Personalización visual limitada**: glassmorphism funciona, pero hasta cierto punto. Cualquier layout complejo (sidebar, módulos, navegación) requiere CSS inyectado manualmente con `st.markdown`.
- **No escala**: al añadir más funcionalidades (panel de admin, dashboard de stats, gestión de usuarios), la app se volvía un solo archivo de 1000+ líneas imposible de mantener.

La decisión fue clara: **reescribir el frontend en Next.js 15 y convertir el backend en una API REST real con FastAPI**.

---

## El problema real

En entornos comerciales, las reuniones generan mucha información que se pierde. Alguien toma notas (mal), se olvida de apuntar un dato clave, y las oportunidades de negocio se pierden entre correos y libretas.

**Lo que se necesitaba:**
- Grabar la reunión y obtener una transcripción fiel
- Saber **quién dijo qué** (diarización)
- Extraer automáticamente las oportunidades comerciales
- Convertir esas oportunidades en tickets gestionables
- Auth real con roles, no acceso libre a todo
- Todo integrado, sin copiar/pegar entre herramientas

---

## La arquitectura v1: dos apps, un ecosistema (Streamlit + Supabase)

La primera versión separaba la lógica en dos apps Streamlit conectadas por Supabase Realtime:

```
┌───────────────────────┐         ┌───────────────────────┐
│   APP 1: Reuniones    │         │   APP 2: Tickets      │
│   ─────────────────   │         │   ─────────────────   │
│   · Grabación audio   │         │   · Panel helpdesk    │
│   · Transcripción     │────────▶│   · Filtros avanzados │
│   · Diarización       │Supabase │   · Priorización      │
│   · Análisis IA       │Realtime │   · Export CSV/Excel  │
│   · Asistente GPT     │         │   · Operaciones lote  │
└───────────────────────┘         └───────────────────────┘
         │                                    │
         └──────────┐    ┌────────────────────┘
                    ▼    ▼
              ┌──────────────┐
              │   Supabase   │
              │  PostgreSQL  │
              │  + Storage   │
              │  + Realtime  │
              └──────────────┘
```

Funcionó como MVP. Pero los límites de Streamlit aparecieron rápido (ver sección anterior).

---

## La arquitectura v2: FastAPI + Next.js 15 (arquitectura desacoplada real)

La reescritura completa siguió un principio claro: **backend como API REST pura, frontend como cliente independiente**.

```
┌─────────────────────────────────────────────────────────┐
│              Frontend — Next.js 15 + React 19           │
│  ┌──────────┐ ┌──────┐ ┌──────────────┐ ┌───────────┐  │
│  │Dashboard │ │Audio │ │Transcripción │ │ Chat IA   │  │
│  └──────────┘ └──────┘ └──────────────┘ └───────────┘  │
│  ┌──────────┐ ┌──────┐ ┌──────────────┐                 │
│  │ Tickets  │ │Admin │ │  Ajustes     │                 │
│  └──────────┘ └──────┘ └──────────────┘                 │
└─────────────────────┬───────────────────────────────────┘
                      │ JWT auth — API REST
┌─────────────────────▼───────────────────────────────────┐
│              Backend — FastAPI + Uvicorn                │
│  · Auth JWT (python-jose) + bcrypt                      │
│  · Endpoints: grabaciones, transcripción, tickets, chat │
│  · Transcripción asíncrona con BackgroundTasks          │
│  · Handler global CORS en errores 500                   │
│  · Multitenancy: aislamiento de datos por company_id    │
│  · Roles: superadmin / company_admin / company_user     │
└─────────────────────┬───────────────────────────────────┘
                      │
              ┌───────▼───────┐
              │  PostgreSQL   │
              │  (nativo)     │
              └───────────────┘
```

**Infraestructura de producción**: Docker Compose (backend + frontend en contenedores separados), Coolify para auto-deploy desde git, Traefik como proxy inverso con SSL automático. El servidor dedicado lo configuré yo desde cero: instalación del SO, Docker, Coolify y toda la infraestructura de red.

---

## El reto técnico más difícil: diarización con IA

Transcribir audio es relativamente fácil. Lo difícil es saber **quién dijo cada frase**. Los servicios comerciales cobran por esto. Yo lo resolví con prompting avanzado en Gemini 2.0 Flash.

### Cómo funciona

1. El audio se sube (MP3, WAV, M4A, FLAC, WebM, OGG) desde el frontend Next.js
2. El backend FastAPI lo recibe y lanza la transcripción en **background** (asíncrona)
3. Gemini 2.0 Flash procesa con un prompt específico para diarización
4. Gemini devuelve la transcripción con marcas de hablante
5. El frontend hace **polling cada 5 segundos** hasta recibir el resultado

```python
prompt = """
Transcribe este audio con las siguientes reglas:
1. Identifica cada hablante y asígnale un nombre consistente
2. Si se mencionan nombres, úsalos
3. Si no, usa "Hablante 1", "Hablante 2", etc.
4. Marca cada cambio de hablante con formato [Nombre]:
5. Mantén el texto literal, sin resumir
"""

response = model.generate_content([prompt, audio_file])
```

**Resultado**: diarización precisa en reuniones de 2-5 personas, con nombres correctos cuando se mencionan en la conversación.

### El problema del timeout (y cómo lo resolví en v2)

En la v1 con Streamlit, las transcripciones largas fallaban porque el proceso bloqueaba el hilo principal. En la v2, el proxy Traefik añadía otro problema: corta conexiones HTTP que superan los 60 segundos.

La solución fue un **modelo asíncrono con polling**:

```
1. POST /api/recordings/{id}/transcribe
   → Devuelve inmediatamente: { job_id, status: "processing" }
   → Gemini corre en background (ThreadPoolExecutor)

2. Frontend hace polling cada 5s:
   GET /api/transcription-jobs/{job_id}
   → { status: "processing" }               (sigue esperando)
   → { status: "completed", transcription } (listo)
   → { status: "error", error: "..." }      (falló)

3. Timeout máximo: 120 intentos × 5s = 10 minutos
```

Sin timeouts. Sin bloqueos. Sin recarga de página. El usuario ve una barra de progreso mientras espera.

### Visualización con colores por hablante

Cada hablante recibe un color único en la interfaz. No es solo estético — permite escanear visualmente quién habló más, quién intervino en qué punto, y encontrar fragmentos rápidamente.

---

## Análisis semántico: de texto a tickets

Aquí es donde la IA aporta valor real de negocio. No busco palabras clave — uso Gemini 2.0-Flash para análisis semántico.

**Entrada**: la transcripción completa de la reunión

**Lo que detecta la IA**:
- Oportunidades de venta ("nos interesa contratar...", "necesitamos un proveedor de...")
- Problemas reportados ("esto no funciona", "llevamos semanas esperando...")
- Acciones comprometidas ("te lo mando el lunes", "vamos a preparar un presupuesto")
- Seguimientos necesarios ("hay que hablar con el departamento de...")

**Salida**: tickets estructurados con título, descripción, prioridad y contexto original.

```python
analysis_prompt = """
Analiza esta transcripción y extrae oportunidades comerciales.
Para cada una, devuelve un JSON con:
- titulo: resumen en una línea
- descripcion: contexto relevante
- prioridad: alta/media/baja
- cita_original: la frase exacta que la justifica
"""
```

**Resultado real**: de una reunión de 25 minutos, el sistema extrajo 4 oportunidades con prioridad correcta en 3.5 segundos.

---

## Sincronización en tiempo real

El momento clave: cuando la app de reuniones genera tickets, la app de gestión los muestra **instantáneamente**. Sin polling, sin refresh manual.

**Implementación con Supabase Realtime:**

1. La app de reuniones inserta tickets en la tabla `tickets` de Supabase
2. La app de gestión escucha cambios en esa tabla vía Realtime
3. Cuando llega un INSERT, el ticket aparece automáticamente en el panel

Esto funciona porque Supabase usa WebSockets internamente. No tuve que montar un servidor WebSocket propio — la infraestructura ya estaba.

---

## El asistente conversacional

Después de transcribir, el usuario puede **preguntarle al asistente sobre la reunión**:

- "¿Qué dijo Juan sobre el presupuesto?"
- "Resume los puntos principales"
- "¿Se mencionó alguna fecha límite?"

El asistente usa Gemini 2.0 Flash con el contexto de la transcripción y un historial de los últimos 8 mensajes. No es un chatbot genérico — tiene la reunión completa como contexto, así que las respuestas son precisas. La selección de grabación a analizar se hace desde el propio chat.

---

## Auth JWT real: lo que Streamlit no podía hacer

En la v1 no había autenticación real. En la v2, implementé un sistema completo:

- **Registro y login** con contraseñas hasheadas con bcrypt
- **JWT** (python-jose) con expiración configurable
- **3 roles**: `superadmin`, `company_admin`, `company_user`
- **CORS multi-origen** configurado para dominios de producción + localhost
- **Handler global de excepciones**: FastAPI no añade headers CORS en errores 500 sin capturar — lo solucioné con `@app.exception_handler(Exception)` para garantizar que el CORS funcione incluso si explota un endpoint de Gemini

```python
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    return JSONResponse(
        status_code=500,
        content={"detail": "Error interno del servidor"},
        headers={"Access-Control-Allow-Origin": origin}
    )
```

---

## Multitenancy: múltiples empresas, datos completamente aislados

La v3 añade lo que le faltaba al sistema para ser un producto real: **arquitectura multitenancy**. No es simplemente filtrar por usuario — es aislar por empresa a nivel de BD con roles diferenciados.

### Los tres roles

| Rol | Etiqueta UI | Permisos |
|---|---|---|
| `superadmin` | superadmin | Acceso global: gestiona todas las empresas, todos los usuarios y todas las solicitudes. |
| `company_admin` | admin | Gestiona únicamente los usuarios y datos de su propia empresa. |
| `company_user` | usuario | Acceso estándar: graba, transcribe, chatea y ve sus propios tickets. |

### Aislamiento de datos

Cada empresa (tenant) tiene sus grabaciones y tickets completamente separados. La clave está en filtrar por `company_id` automáticamente en todas las queries del backend:

```python
# Ejemplo: listar grabaciones — solo las de la empresa del usuario autenticado
recordings = db.execute(
    "SELECT * FROM recordings WHERE company_id = %s ORDER BY created_at DESC",
    (current_user["company_id"],)
)
```

Un `company_admin` ve y gestiona únicamente lo de su empresa. El `superadmin` tiene visibilidad global. Un `company_user` no puede salirse de su contexto aunque lo intente.

### Flujo de registro con aprobación

El registro no es inmediato. Diseñé un flujo deliberado para que las empresas controlen quién entra:

```
1. Usuario rellena /register (nombre, email, empresa, contraseña)
2. Se crea una solicitud en estado "pending"
3. El company_admin o superadmin la ve en Admin → Solicitudes
4. Aprueba (asigna rol y empresa, crea la cuenta) o rechaza
5. La solicitud desaparece de la lista al instante
6. El usuario aprobado ya puede hacer login
```

Esto evita que cualquiera con el enlace de registro pueda acceder sin control.

### Panel de administración ampliado

El panel de admin pasa de tener dos pestañas a tres:

**Solicitudes** — lista de registros pendientes con contador en tiempo real en la pestaña. Un clic aprueba y crea el usuario. Otro clic rechaza y descarta.

**Usuarios** — búsqueda por nombre/email/empresa, filtro por empresa (solo superadmin), crear usuarios directamente, editar nombre/email/rol/empresa, activar/desactivar cuentas y eliminar. Excepción: `infra@iautomatiza.net` está protegida a nivel de backend y no se puede eliminar bajo ningún concepto.

**Empresas** *(solo superadmin)* — crear, editar y eliminar empresas (tenants). Cada empresa tiene nombre y slug único. Desde aquí el superadmin puede ampliar el sistema a nuevos clientes sin tocar código.

---

## Panel de gestión de tickets

El módulo de tickets funciona como un helpdesk/CRM integrado directamente en la misma app:

| Funcionalidad | Detalle |
|---|---|
| Filtros avanzados | Por prioridad, estado, fecha, grabación de origen |
| 8 temas configurables | Prioridad automática Alta/Media/Baja vía `keywords_dict.json` |
| Estados | `open`, `in_progress`, `closed` |
| Vista detalle | Con la cita original de la transcripción |
| Edición inline | Título, descripción y estado editables directamente |

---

## Stack técnico: v1 vs v2 vs v3

| Capa | v1 (Streamlit) | v2 (FastAPI + Next.js) | v3 (actual — Multitenancy) |
|---|---|---|---|
| Frontend | Streamlit + CSS glassmorphism | Next.js 15 + React 19 + TypeScript + Tailwind v4 + shadcn/ui | Igual + módulo Empresas en Admin |
| Backend | Python modular (scripts) | FastAPI + Uvicorn (API REST completa) | + filtrado automático por company_id |
| Auth | Sin auth real | JWT (python-jose) + bcrypt, 3 roles | Roles: superadmin / company_admin / company_user |
| Multi-empresa | No | No | Sí — aislamiento total de datos por tenant |
| Registro | Sin registro | Registro libre | Flujo pending → aprobación por admin |
| Base de datos | Supabase PostgreSQL + Storage + Realtime | PostgreSQL nativo | + tabla companies + company_id en todas las entidades |
| Despliegue | Streamlit Cloud | Docker Compose + Coolify + Traefik SSL | Servidor dedicado montado desde cero (SO + Docker + Coolify) |
| Transcripción | Síncrona (bloqueante) | Asíncrona con BackgroundTasks + polling | Igual |
| Formatos audio | MP3, WAV, M4A | MP3, WAV, M4A, FLAC, WebM, OGG | Igual |
| IA transcripción | Gemini 1.5 Pro | Gemini 2.0 Flash | Igual |
| Asistente IA | OpenAI GPT | Gemini 2.0 Flash (historial 8 msgs) | Igual |

---

## Números reales

| Métrica | v1 | v2 | v3 |
|---|---|---|---|
| Tiempo de transcripción | ~15s por 10 min de audio | ~15s por 10 min (async, sin bloquear UI) | Igual |
| Análisis semántico | 3-5 segundos | 3-5 segundos | Igual |
| Precisión diarización | >90% (2-5 hablantes) | >90% (2-5 hablantes) | Igual |
| Formatos de audio | MP3, WAV, M4A | MP3, WAV, M4A, FLAC, WebM, OGG | Igual |
| Timeout máximo | Sin control (bloqueante) | 10 minutos (polling cada 5s) | Igual |
| Auth | Sin auth | JWT con 3 roles | Roles multitenancy (superadmin/company_admin/company_user) |
| Multi-empresa | No | No | Sí — aislamiento completo por tenant |
| Registro usuarios | Sin registro | Registro libre | Flujo con aprobación manual |
| Tiempo de desarrollo | 1 mes | +1 mes (migración) | +2 semanas (multitenancy) |
| Despliegue | Streamlit Cloud | VPS propio (Docker + Coolify) | Servidor dedicado montado desde cero |

---

## Lo que aprendí

1. **Gemini para diarización funciona** — con buenos prompts, compite con servicios dedicados que cuestan 10x más.
2. **Streamlit es para MVPs, no para productos** — es la herramienta perfecta para validar una idea en horas, pero si el producto crece, la deuda técnica llega rápido. Sin routing real, sin auth JWT, sin estado local, sin componentización.
3. **FastAPI + Next.js es la combinación correcta** — API REST pura en Python (lo que ya conoces para IA) + frontend moderno en TypeScript. Cada uno hace lo suyo sin compromisos.
4. **La transcripción asíncrona es obligatoria en producción** — cualquier proxy (Traefik, Nginx, Cloudflare) tiene timeouts. Si tu proceso tarda más de 30-60 segundos, necesitas un modelo asíncrono con polling o webhooks.
5. **CORS en FastAPI requiere cuidado extra** — el middleware de CORS solo actúa en respuestas normales. En excepciones no capturadas, los headers desaparecen. Un handler global es imprescindible.
6. **Multitenancy no es solo filtrar por usuario** — es diseñar desde el principio con `company_id` en todas las entidades, filtrado automático en el backend y roles que respeten esa jerarquía. Hacerlo a posteriori es costoso.
7. **El flujo de registro con aprobación aporta control real** — en un sistema multiempresa, que cualquiera se registre libremente es un problema. El flujo pending → aprobación da a los administradores control total sobre quién entra y con qué rol.
8. **Montar tu propio servidor desde cero te da control total** — instalé el SO, Docker, Coolify y toda la infraestructura yo solo. Más trabajo inicial que un PaaS, pero cero dependencias externas, coste fijo mensual y libertad absoluta para configurar el stack. Coolify + Traefik hacen que el deploy y los certificados SSL sean casi automáticos una vez está montado.

---

## ¿Necesitas algo similar?

Si tu empresa necesita automatizar el procesamiento de reuniones, integrar IA en flujos de trabajo o construir sistemas que conecten varias aplicaciones en tiempo real, [hablemos](/blog/servicios/).

**Otros casos reales:**
- [Cómo construí un SaaS multitenant con NestJS, React y React Native](/blog/caso-real-saas-atrapaclientes-nestjs-react/)
- [Por qué elegí NestJS sobre Express para un SaaS con 18 entidades](/blog/por-que-nestjs-sobre-express-saas-2026/)]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Trabajo Remoto para Developers en España 2026: Guía Completa para Encontrarlo]]></title>
      <link>https://francobosg.netlify.app/blog/trabajo-remoto-developers-como-encontrar-2026/</link>
      <description><![CDATA[Cómo encontrar trabajo remoto como programador desde España en 2026: plataformas reales, salarios, cómo tributa, proceso de selección en empresas extranjeras y errores a evitar.]]></description>
      <pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/trabajo-remoto-developers-como-encontrar-2026/</guid>
      <category>Carrera</category>
      <category>Trabajo</category>
      <category>Remoto</category>
      <category>España</category>
      <category>Internacional</category>
      <content:encoded><![CDATA[El trabajo remoto para developers en 2026 ya no es un privilegio ni una rareza: es una opción de carrera real, con procesos de selección establecidos, frameworks legales funcionando y miles de developers españoles trabajando así. Esta guía te explica exactamente cómo encontrarlo, qué esperar y cómo no cometer los errores típicos.

> **Respuesta rápida:** Un senior con buen inglés puede multiplicar su sueldo por 1.5x-2x trabajando para empresa extranjera en remoto desde España. Las plataformas más efectivas son We Work Remotely, Remote OK y LinkedIn en inglés. La barrera real no es técnica: es el inglés y saber cómo presentarse en el mercado internacional.

---

## Por qué el trabajo remoto internacional vale tanto la pena en 2026

Los números son claros. Para un senior backend con 5 años de experiencia:

| Tipo de empresa | Sueldo bruto anual |
|----------------|-------------------|
| Consultora española | 50.000€ – 65.000€ |
| Startup española (serie A) | 60.000€ – 75.000€ |
| Empresa alemana / UK (remoto) | 80.000€ – 110.000€ |
| Startup americana (remoto) | 100.000€ – 150.000€ equiv. |

Eso manteniendo el coste de vida español. Sin mudarse. Sin dejar la familia. Solo cambiando dónde trabajan, muchos developers duplican ingresos.

Y no es solo dinero: las empresas remote-first suelen tener mejor cultura de ingeniería, procesos más maduros y más autonomía. Muchos developers que han dado ese salto no vuelven atrás.

---

## Tipos de trabajo remoto: cuál es el tuyo

### Opción 1: Empresa española en remoto

La más accesible. Sin barrera de idioma, sin complicaciones fiscales.

**Pros:** contrato laboral español directo, misma zona horaria, sin gestiones complejas
**Contras:** salarios del mercado español
**Para quién:** developers que prefieren simplicidad o que no tienen inglés sólido todavía

**Dónde buscar:**
- [Manfred](https://www.getmanfred.com/) — filtro "100% remoto"
- [InfoJobs](https://www.infojobs.net/) — filtro "teletrabajo"
- LinkedIn España — "trabajo en remoto"
- Nómada Digital / Remoters.net (directorios de empresas que contratan remoto en España)

### Opción 2: Empresa europea en remoto

Empresas alemanas, inglesas, holandesas, nórdicas y francesas. Pagan entre un 30-80% más que las españolas para el mismo perfil.

**Contrato:** normalmente autónomo (factura mensual) o EOR (Deel, Remote)
**Idioma:** inglés B2-C1 mínimo
**Overlap horario:** cómodo — misma zona horaria o máximo 2 horas de diferencia
**Para quién:** mids y seniors con inglés funcional

**Dónde buscar:**
- [EuropeRemotely](https://europeremotely.com/)
- [Remote.co](https://remote.co/)
- LinkedIn en inglés con ubicación "Europe" + "Remote"
- Job boards de comunidades como Startup Lisboa, Berlin Startup Jobs

### Opción 3: Empresa americana / global en remoto

El mayor potencial salarial. Empresas de Silicon Valley, New York y startups globales que pagan tarifas americanas aunque estés en Málaga.

**Contrato:** casi siempre autónomo o contratista mediante EOR
**Idioma:** inglés C1 hablado y escrito
**Overlap horario:** crítico — muchas requieren 4h de overlap con EST (14-18h CET)
**Para quién:** seniors con inglés sólido y perfil competitivo a nivel internacional

**Dónde buscar:**
- [We Work Remotely](https://weworkremotely.com/) — el board de referencia para remote internacional
- [Remote OK](https://remoteok.com/) — alto volumen, filtros por stack
- [Arc.dev](https://arc.dev/) — orientado a developers, screening incluido
- [Toptal](https://www.toptal.com/) — muy bien pagado, proceso de admisión exigente (5-8% de aceptación)
- [Turing](https://www.turing.com/) — similar a Toptal, perfil LATAM/Europa
- [Gun.io](https://www.gun.io/) — para senior engineers
- **Hacker News "Who's Hiring"** — primer post de cada mes, muchas startups americanas reales

---

## Cómo tributa el trabajo remoto desde España: lo básico

Este es el tema que más confunde. Hay dos escenarios:

### Escenario A: Empleado mediante Employer of Record (EOR)

Un EOR (Deel, Remote, Rippling, Omnipresent) actúa como tu empleador formal en España. La empresa extranjera paga al EOR, y el EOR te da un contrato de trabajo español normal.

```
Empresa americana → paga mensualmente al EOR
EOR (Deel/Remote) → te da contrato laboral español, ingresa tu sueldo
Tú → cotizas a la Seguridad Social española, declaras IRPF como empleado
```

**Ventajas:** tienes todos los derechos laborales españoles (paro, baja, vacaciones pagadas), la empresa gestiona las retenciones, no necesitas ser autónomo.

### Escenario B: Autónomo / Freelance internacional

Te das de alta como autónomo en España y facturas mensualmente a la empresa extranjera.

```
Empresa extranjera → paga tu factura mensual en EUR o USD
Tú (autónomo) → pagas cuota de autónomo (~300€/mes), IRPF trimestral, 
               presentas modelo 347 si facturas >3.005€ a la misma empresa
```

**Ventajas:** más control, puedes trabajar para varios clientes, más deducible
**Inconvenientes:** sin paro ni baja pagada por la empresa, gestión administrativa, cotización autónomo

**Regla de oro:** si ingresas menos de 50.000€/año de trabajo remoto, ser autónomo estándar suele ser suficiente. Por encima de esa cifra, valora hablar con un gestor sobre módulos estimación directa y optimización fiscal.

> **Aviso:** Este artículo es informativo, no asesoramiento fiscal. Consulta siempre con un gestor especializado en autónomos tech.

---

## Cómo posicionarte para el mercado de trabajo remoto internacional

### 1. Perfil de LinkedIn en inglés

Tu perfil de LinkedIn debe estar en inglés si buscas trabajo internacional. El titular es lo primero que ven:

```
❌ "Desarrollador Web con 5 años de experiencia"
✅ "Senior Full-Stack Developer | React · Node.js · TypeScript | Remote"
✅ "Backend Engineer | Python · FastAPI · PostgreSQL | Open to Remote (CET)"
```

Activa "Open to Work" con estas opciones:
- Tipo de trabajo: "Remote"
- Visible para: reclutadores

### 2. GitHub con READMEs en inglés

Los proyectos que quieras mostrar a empresas internacionales deben tener README en inglés con:
- Descripción del proyecto
- Cómo ejecutarlo
- Stack tecnológico
- Link al deploy

### 3. Portfolio o web personal

Una web simple con:
- Proyectos principales con links
- Stack y especialización clara
- Disponibilidad para remoto y zona horaria
- Inglés

Puedes verla como tu "landing page" profesional internacional.

### 4. Inglés técnico: la habilidad real más importante

No necesitas inglés de nativo. Necesitas poder:
- Participar en reuniones técnicas (stand-up diario, planning, code review)
- Escribir tickets y documentación claros
- Dar y recibir feedback técnico por escrito
- Explicar decisiones de arquitectura

**Plan práctico para mejorar inglés técnico:**
- Lee documentación oficial siempre en inglés (sin cambiarla a español)
- Mira conferencias técnicas en inglés (React Conf, PyCon, Node+JS)
- Practica conversaciones técnicas con IA (Claude, ChatGPT): "Explain me how you would architect this system"
- Escribe el README de tus proyectos en inglés desde ya

El artículo de [aprender inglés para developers](/blog/aprender-ingles-para-developers-2026/) tiene un plan más detallado y recursos específicos.

---

## Plataformas comparadas: cuál usar según tu nivel

| Plataforma | Nivel recomendado | Tipo oferta | Proceso |
|-----------|-----------------|-------------|---------|
| **Manfred** | Junior-Senior | Empresas españolas remote | Muy humano, en español |
| **Remote OK** | Mid-Senior | Internacional variado | Aplicación directa |
| **We Work Remotely** | Mid-Senior | Principalmente americanas | Aplicación directa |
| **Arc.dev** | Mid-Senior | Startups internacionales | Screening propio + matching |
| **Toptal** | Senior-Expert | Empresas top mundial | Proceso de admisión 3 fases, muy exigente |
| **Turing** | Mid-Senior | Empresas americanas | Test técnico + entrevista |
| **LinkedIn (inglés)** | Todos | Todo tipo | Varía por empresa |
| **HN Who's Hiring** | Mid-Senior | Startups early stage | Email directo al fundador |

---

## El proceso de selección en empresas remotas

Las empresas remote-first tienen procesos más estructurados y escritos que las presenciales. Esto es bueno: hay menos sorpresas.

### Fase 1: Aplicación escrita

Muchas empresas hacen preguntas en la aplicación que requieren respuestas largas. Estas respuestas importan mucho más que el CV.

```
Pregunta: "Tell us about a time you had to debug a difficult problem in production"

❌ Respuesta mala: "I fixed a bug that was causing errors for users"

✅ Respuesta buena:
"We had a memory leak in a Node.js microservice causing OOM crashes every 
48h in production. I used clinic.js to profile the heap and identified that 
WebSocket connections weren't being cleaned up on disconnect. After adding 
proper cleanup handlers, memory stabilized and we had zero crashes over the 
next 30 days. I also added monitoring alerts so we'd catch similar issues 
before they caused incidents."
```

**Regla:** respuestas escritas = muestra cómo piensas y comunicas. Cuida la gramática, sé específico con números y resultados.

### Fase 2: Prueba técnica take-home

Con más tiempo (4-8h) que las pruebas presenciales. Las empresas remotas saben que necesitas tiempo real para mostrar tu nivel.

**Lo que más importa:**
- Código limpio y organizado
- README con instrucciones claras para ejecutar
- Tests básicos (unitarios del happy path mínimo)
- Manejo de errores y edge cases básicos
- Commits con mensajes descriptivos

### Fase 3: Code review / presentación

Te piden que expliques las decisiones que tomaste. Prepárate para:
- Defender por qué elegiste esa arquitectura
- Decir qué cambiarías si tuvieras más tiempo
- Hablar sobre trade-offs (performance vs. legibilidad, etc.)

### Fase 4: System design (para seniors)

Diseñar un sistema desde cero: escalabilidad, base de datos, caché, autenticación, arquitectura de microservicios. Para prepararte: libro "System Design Interview" de Alex Xu + canal de YouTube "ByteByteGo".

### Fase 5: Entrevista de equipo / cultura

Reunión informal con el equipo. Pregunta tú también: cómo es el onboarding, cómo se toman decisiones técnicas, qué herramientas usan para comunicación asíncrona.

---

## Preguntas que debes hacer tú en el proceso

Las empresas remotas valoran a los candidatos que preguntan bien:

- "¿Qué overlap horario esperáis con el equipo principal?"
- "¿Cómo es el proceso de onboarding para empleados remotos?"
- "¿Cómo documentáis decisiones de arquitectura y por qué?"
- "¿Usáis comunicación asíncrona o hay muchas reuniones en directo?"
- "¿Cuál es la política de home office setup? ¿Hay budget para equipamiento?"

---

## Diferencia crítica: remote-first vs. remote-friendly vs. remote-tolerated

Esto importa más de lo que parece:

| Tipo | Qué significa | Señales |
|------|-------------|---------|
| **Remote-first** | La empresa está diseñada para remoto, todo por defecto distribuido | Sin oficina central o muy pequeña, comunicación asíncrona por defecto, documentación exhaustiva |
| **Remote-friendly** | Admite remoto pero la cultura es presencial | Oficina central, reuniones improvisadas, onboarding presencial |
| **Remote-tolerated** | Lo permiten pero no lo facilitan | "Puedes trabajar remoto pero es mejor venir cuando puedas" |

Las empresas remote-first son las que dan mejores experiencias. Pregunta directamente: "¿Cómo describirías vuestra cultura de trabajo distribuido?" Una empresa remote-first lo responde con entusiasmo y detalle.

---

## Herramientas que necesitas para trabajar en remoto

**Productividad y comunicación:**
- **Slack** — mensajería asíncrona principal
- **Loom** — videomensajes para explicar sin reunión
- **Notion / Confluence** — documentación del equipo
- **Linear / Jira** — gestión de tareas

**Setup técnico:**
- Conexión de fibra con respaldo (clave: tener un plan B de internet)
- Auriculares con cancelación de ruido para reuniones
- Segunda pantalla (rentabilidad altísima en productividad)
- [Variables de entorno bien configuradas](/blog/variables-de-entorno-nodejs-nextjs-guia-2026/) para manejar entornos local/staging/producción

---

## Errores comunes al buscar trabajo remoto

| Error | Consecuencia | Cómo evitarlo |
|-------|-------------|---------------|
| Aplicar a remote-friendly como si fuera remote-first | Frustración y conflictos | Pregunta explícitamente antes |
| CV/LinkedIn en español para empresas internacionales | Te filtran automáticamente | Perfil en inglés para búsqueda internacional |
| No mencionar zona horaria | Descarte si no encaja | Incluye "CET timezone, flexible overlap" |
| Miedo a negociar | Cobrar menos del 40-60% que podrías | Investiga rangos antes y contraoferta siempre |
| Esperar a tener inglés perfecto | No empezar nunca | Con B2 funcional ya puedes aplicar a empresas europeas |

---

## Timeline realista para conseguir trabajo remoto internacional

```
Si eres junior (0-2 años):
→ Primero consigue el primer empleo en España
→ Con 1-2 años de experiencia real, empieza a mirar remoto nacional
→ Con 2-3 años, empieza a mirar remoto europeo

Si eres mid-level (2-4 años):
→ Empezar a aplicar a empresas europeas ya tiene sentido
→ Mejora el inglés en paralelo (3-6 meses para pasar de B1 a B2)
→ Proceso: 2-4 meses de búsqueda activa

Si eres senior (5+ años):
→ El mercado internacional te quiere, el freno es casi siempre el inglés
→ Con inglés C1 y perfil bien presentado, 1-3 meses para primera oferta
→ Considera Toptal o Arc.dev para máximo salario
```

---

Para entender qué sueldo esperar en cada etapa de tu carrera — en empresa española y en remoto internacional — revisa la [guía completa de sueldos de desarrollador en España 2026](/blog/sueldo-desarrollador-web-espana-2026/).

Y si todavía estás buscando el primer empleo, empieza por la [guía para conseguir trabajo de programador sin experiencia en España](/blog/conseguir-trabajo-programador-sin-experiencia-espana-2026/) antes de apuntar al mercado internacional.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Caso Real: Sistema IoT con ESP32 y Sensores en Dolibarr]]></title>
      <link>https://francobosg.netlify.app/blog/caso-real-iot-sensores-esp32-dolibarr/</link>
      <description><![CDATA[Monitorización IoT en producción: sensores DIY y Ubibot, firmware ESP32/ESP8266, API REST segura y dashboards en tiempo real integrados en Dolibarr ERP.]]></description>
      <pubDate>Tue, 07 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/caso-real-iot-sensores-esp32-dolibarr/</guid>
      <category>Caso Real</category>
      <category>IoT</category>
      <category>Arduino</category>
      <category>ESP32</category>
      <category>PHP</category>
      <category>Dolibarr</category>
      <category>Arquitectura</category>
      <category>Hardware</category>
      <content:encoded><![CDATA[## TL;DR

La empresa necesitaba monitorizar temperatura y humedad en tiempo real en varios puntos. Diseñé un sistema híbrido: sensores DIY con ESP32 (coste < 10€/punto) para zonas con WiFi, y sensores Ubibot para zonas sin red. Firmware propio con control de errores, API REST segura y dashboards integrados en Dolibarr. **12+ meses en producción sin fallos críticos.**

<video
  src="/videos/sensor.mp4"
  controls
  muted
  playsinline
  class="w-full rounded-xl my-6 shadow-lg"
  style="max-height: 480px; object-fit: cover;"
>
  Tu navegador no soporta vídeo HTML5.
</video>

---

## El problema de la empresa

La empresa tenía requisitos de control ambiental en varias zonas pero:

- **Mediciones manuales** — alguien iba con un termómetro y apuntaba en un Excel
- **Sin alertas** — si la temperatura subía de noche, nadie se enteraba hasta la mañana
- **Sin histórico fiable** — el Excel tenía huecos, errores de lectura y datos perdidos
- **Soluciones comerciales caras** — un sistema de monitorización profesional cotizado en 3.000-5.000€

**Lo que me pidieron**: "Queremos ver la temperatura en la pantalla del ordenador y que nos avise si algo va mal. Y que no cueste una fortuna."

---

## Decisión 1: Sistema híbrido (DIY + comercial)

No todas las zonas eran iguales. Algunas tenían WiFi y enchufe cerca, otras no. En vez de forzar una solución única, diseñé un sistema híbrido:

### Sensores DIY (ESP32/ESP8266)
Para zonas con WiFi y alimentación:

| Componente | Precio | Función |
|---|---|---|
| ESP32 DevKit | 5€ | Microcontrolador con WiFi |
| DHT22 | 3€ | Temperatura + humedad (±0.5°C) |
| SHT31 | 6€ | Alta precisión (±0.3°C) para zonas críticas |
| DS18B20 | 2€ | Solo temperatura, impermeable |
| Fuente 5V USB | 3€ | Alimentación |
| **Total por punto** | **~10€** | |

### Sensores comerciales Ubibot
Para zonas sin WiFi o donde no se podía cablear:

- **Ubibot WS1**: sensor autónomo con SIM 4G integrada
- Coste: ~120€/unidad + datos móviles
- Ventaja: funciona sin infraestructura, batería de 6 meses

### La integración

```
Zona con WiFi:                    Zona sin WiFi:
┌──────────┐                      ┌──────────┐
│ ESP32 +  │──WiFi──▶ API REST    │ Ubibot   │──4G──▶ Ubibot Cloud
│ DHT22    │         propia       │ WS1      │         │
└──────────┘           │          └──────────┘         │
                       │                               │
                       ▼                               ▼
                  ┌──────────────────────────────────────┐
                  │         Dolibarr ERP — Módulo IoT     │
                  │  · Dashboard tiempo real              │
                  │  · Alertas automáticas                │
                  │  · Histórico + gráficas               │
                  └──────────────────────────────────────┘
```

Las dos fuentes de datos (propia y Ubibot API) se unifican en Dolibarr. El usuario ve todos los sensores en el mismo dashboard, sin saber cuál es DIY y cuál comercial.

---

## Decisión 2: Firmware probusto (no un sketch de ejemplo)

Los tutoriales de Arduino muestran un `loop()` de 10 líneas que lee un sensor y lo imprime por serial. En producción, eso falla en 48 horas.

### Lo que puede salir mal (y sale)

- WiFi se desconecta → el ESP se queda enviando al vacío
- El sensor devuelve `NaN` → se envía basura
- El servidor no responde → el HTTP request se cuelga
- El ESP pierde alimentación → al reiniciar, no reconecta

### Mi firmware real

```cpp
#include <WiFi.h>
#include <HTTPClient.h>
#include <DHT.h>

#define DHT_PIN 4
#define SENSOR_TYPE DHT22
#define READ_INTERVAL 60000   // 1 lectura/minuto
#define MAX_RETRIES 3
#define WIFI_TIMEOUT 10000

DHT dht(DHT_PIN, SENSOR_TYPE);

void setup() {
  Serial.begin(115200);
  dht.begin();
  connectWiFi();
}

void loop() {
  // Verificar WiFi antes de leer
  if (WiFi.status() != WL_CONNECTED) {
    connectWiFi();
  }

  float temp = dht.readTemperature();
  float hum = dht.readHumidity();

  // Validar lectura (descartar NaN y valores imposibles)
  if (isnan(temp) || isnan(hum) || temp < -40 || temp > 80) {
    Serial.println("Lectura inválida, reintentando...");
    delay(2000);
    return;
  }

  // Enviar con reintentos
  bool sent = false;
  for (int i = 0; i < MAX_RETRIES && !sent; i++) {
    sent = sendData(temp, hum);
    if (!sent) delay(5000);
  }

  delay(READ_INTERVAL);
}

void connectWiFi() {
  WiFi.begin(SSID, PASSWORD);
  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED) {
    if (millis() - start > WIFI_TIMEOUT) {
      ESP.restart();  // Reinicio forzado si no conecta
      return;
    }
    delay(500);
  }
}

bool sendData(float temp, float hum) {
  HTTPClient http;
  http.setTimeout(10000);
  http.begin(API_URL);
  http.addHeader("Authorization", "Bearer " + String(API_TOKEN));
  http.addHeader("Content-Type", "application/json");

  String payload = "{\"temperature\":" + String(temp, 1) 
                 + ",\"humidity\":" + String(hum, 1) 
                 + ",\"device_id\":\"" + DEVICE_ID + "\"}";

  int code = http.POST(payload);
  http.end();
  return (code == 200 || code == 201);
}
```

**Diferencias con un sketch tutorial:**
- Reconexión WiFi automática con timeout
- Reinicio del ESP si no reconecta (watchdog casero)
- Validación de lecturas (descarta NaN y valores fuera de rango)
- Reintentos con backoff en el envío HTTP
- Autenticación JWT en cada petición
- Timeout en las peticiones HTTP (evita cuelgues)

---

## Decisión 3: API REST segura

El ESP32 envía datos por HTTP. Esa API necesita:

1. **Autenticación**: token JWT por dispositivo (un token robado no da acceso a nada más)
2. **Validación server-side**: rango de temperatura, frecuencia de envío, device_id registrado
3. **HTTPS obligatorio**: SSL/TLS en el servidor para cifrar los datos en tránsito
4. **Rate limiting**: máximo 1 lectura/minuto por dispositivo (evita floods por bug en firmware)

```php
// Endpoint PHP en Dolibarr
function receiveSensorData($request) {
    // 1. Verificar token JWT
    $device = $this->validateDeviceToken($request->getHeader('Authorization'));
    if (!$device) return $this->unauthorized();

    // 2. Validar datos
    $temp = floatval($request->getParam('temperature'));
    $hum = floatval($request->getParam('humidity'));
    if ($temp < -40 || $temp > 80 || $hum < 0 || $hum > 100) {
        return $this->badRequest('Valores fuera de rango');
    }

    // 3. Guardar en BD
    $this->db->insert('iot_readings', [
        'device_id' => $device->id,
        'temperature' => $temp,
        'humidity' => $hum,
        'timestamp' => date('Y-m-d H:i:s'),
    ]);

    // 4. Verificar alertas
    $this->checkAlerts($device, $temp, $hum);

    return $this->ok();
}
```

---

## Decisión 4: Integración Ubibot

Los sensores Ubibot envían datos a su nube. Para integrarlos en Dolibarr sin depender de su interfaz web:

1. **CRON job** cada 5 minutos consulta la API de Ubibot
2. Descarga las últimas lecturas de cada sensor
3. Las normaliza al mismo formato que los sensores DIY
4. Las guarda en la misma tabla `iot_readings`

Resultado: en el dashboard de Dolibarr, un sensor Ubibot y un ESP32 se ven exactamente igual. **El usuario no necesita saber la diferencia.**

---

## Dashboard en Dolibarr

| Funcionalidad | Detalle |
|---|---|
| Vista en tiempo real | Temperatura y humedad actuales de todos los puntos |
| Gráficas históricas | Chart.js con rango de fechas seleccionable |
| Alertas automáticas | Email cuando temperatura > umbral configurado |
| Estado de sensores | Verde (OK), naranja (lectura antigua), rojo (sin datos) |
| Exportación | CSV para auditorías y reporting |
| Configuración | Umbrales, intervalos y destinatarios de alertas por sensor |

---

## Lo que consiguió la empresa

| Antes | Después |
|---|---|
| Mediciones manuales con termómetro | Monitorización automática 24/7 |
| Excel con huecos y errores | Histórico continuo y fiable |
| Sin alertas | Notificación inmediata por email |
| Presupuesto rechazado (3.000-5.000€) | Coste total: ~200€ (hardware + servidor existente) |
| Datos en papel | Dashboard en tiempo real integrado en el ERP |
| 1 zona monitorizada (la fácil) | Todas las zonas cubiertas (WiFi + 4G) |

**Ahorro**: el sistema DIY costó un **95% menos** que la solución comercial cotizada. Y lleva 12+ meses funcionando.

---

## Lo que aprendí

1. **Híbrido > purista** — usar solo DIY o solo comercial habría dejado zonas sin cubrir o habría costado 10x más.
2. **El firmware es lo que separa un prototipo de un producto** — reconexión, validación, reintentos y watchdog son obligatorios.
3. **JWT por dispositivo** — si roban un token (acceso físico al ESP), solo comprometes ese sensor.
4. **Los sensores mienten** — el DHT22 ocasionalmente devuelve NaN o valores absurdos. Sin validación, contaminas la BD.
5. **CRON + API externa funciona bien** para integrar servicios de terceros sin acoplamiento.

---

## ¿Tu empresa necesita monitorización IoT?

Si necesitas controlar temperatura, humedad, o cualquier variable ambiental integrada con tu sistema de gestión, [hablemos](/blog/servicios/).

**Más casos reales:**
- [Cómo construí un SaaS multitenant con NestJS y React](/blog/caso-real-saas-atrapaclientes-nestjs-react/)
- [Sistema GPS de flota con Traccar y Leaflet](/blog/caso-real-gps-flota-vehiculos-dolibarr/)
- [Módulo de comisiones automáticas para Dolibarr ERP](/blog/caso-real-modulo-comisiones-dolibarr/)
- [Ecosistema IA para reuniones con Gemini y Supabase](/blog/caso-real-ia-reuniones-gemini-supabase/)]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Caso Real: Sistema GPS de Flota con Traccar y Dolibarr]]></title>
      <link>https://francobosg.netlify.app/blog/caso-real-gps-flota-vehiculos-dolibarr/</link>
      <description><![CDATA[Módulo GPS para Dolibarr ERP: hardware SinoTrack, Traccar open source, mapas en tiempo real con Leaflet y optimización de rutas con IA.]]></description>
      <pubDate>Mon, 06 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/caso-real-gps-flota-vehiculos-dolibarr/</guid>
      <category>Caso Real</category>
      <category>PHP</category>
      <category>Dolibarr</category>
      <category>IoT</category>
      <category>GPS</category>
      <category>Leaflet</category>
      <category>Docker</category>
      <category>Arquitectura</category>
      <content:encoded><![CDATA[## TL;DR

La empresa necesitaba rastrear su flota de vehículos sin depender de plataformas SaaS caras. Seleccioné el hardware GPS, lo instalé físicamente en los vehículos, migré de la plataforma propietaria a Traccar (open source), y desarrollé un módulo completo en Dolibarr con mapas en tiempo real, histórico de rutas y optimización con IA. **Resultado: control total de la flota con coste recurrente cero.**

<video
  src="/videos/gps.mp4"
  controls
  muted
  playsinline
  class="w-full rounded-xl my-6 shadow-lg"
  style="max-height: 480px; object-fit: cover;"
>
  Tu navegador no soporta vídeo HTML5.
</video>

---

## El problema de la empresa

La empresa tenía vehículos en ruta pero cero visibilidad:

- **No sabían dónde estaban los vehículos** en tiempo real
- Las rutas no se optimizaban — los conductores elegían su camino
- No había histórico de recorridos para auditorías
- Las soluciones SaaS del mercado costaban **15-30€/vehículo/mes** y los datos quedaban en servidores ajenos

**Lo que me pidieron**: "Necesitamos ver dónde están nuestros coches y que sea nuestro, no de otro."

---

## Fase 1: Selección del hardware GPS

Investigué 8 dispositivos GPS 4G. Requisitos:

| Requisito | Por qué |
|---|---|
| Compatible con Traccar | Para no depender de plataformas propietarias |
| Conectividad 4G/LTE | Las redes 2G/3G están cerrando |
| Configuración por SMS | Para modificar parámetros sin acceso físico |
| Alimentación por vehículo | Sin baterías que cambiar |
| Precio < 40€/unidad | Escalable a toda la flota |

**Elegí el SinoTrack ST-901 4G**: 25€/unidad, LTE, compatible con Traccar, configuración por comandos SMS/AT, alimentación 12V-80V directa del vehículo.

### Instalación física

No es solo comprar el GPS y enchufarlo. Instalé cada dispositivo personalmente:

1. **Conexión a alimentación** del vehículo (12V directo desde fusiblera)
2. **Ocultación del hardware** bajo el salpicadero (antirrobo)
3. **Verificación de señal GPS** en frío y en movimiento
4. **Configuración por SMS**: intervalo de reporte (30s), servidor de destino, APN del operador

```
# Comandos SMS reales de configuración del ST-901
804{password} {ip_traccar} {puerto}     # Servidor de destino
805{password} 30                         # Intervalo de reporte (30 segundos)
TIMER{password} 30                       # Frecuencia de envío
```

---

## Fase 2: Migración de plataforma propietaria a Traccar

El GPS viene configurado de fábrica para enviar datos a los servidores de SinoTrack. Su plataforma web funciona pero:

- **Coste mensual** por dispositivo
- **Cero personalización** del dashboard
- **Datos en sus servidores** (China) — problema de RGPD
- **API limitada** — no se puede integrar con Dolibarr

### La solución: Traccar como hub intermedio

```
┌──────────┐     ┌─────────────┐     ┌──────────────┐     ┌──────────┐
│ GPS      │────▶│  Traccar    │────▶│  API REST    │────▶│ Dolibarr │
│ ST-901   │4G   │  (propio)   │     │  propia      │     │  Módulo  │
│ en coche │     │  Docker     │     │  PHP         │     │  GPS     │
└──────────┘     └─────────────┘     └──────────────┘     └──────────┘
```

**Traccar** es open source, soporta 200+ protocolos de GPS y tiene API REST. Lo desplegué con Docker en el mismo servidor Linux de la empresa:

```yaml
# docker-compose.yml para Traccar
services:
  traccar:
    image: traccar/traccar:latest
    ports:
      - "8082:8082"   # Web UI
      - "5013:5013"   # Protocolo SinoTrack
    volumes:
      - ./traccar.xml:/opt/traccar/conf/traccar.xml
      - traccar-data:/opt/traccar/data
```

Redirigí los GPS del servidor SinoTrack a mi servidor Traccar cambiando la IP de destino por SMS. **En 10 minutos, todos los GPS reportaban a mi infraestructura.**

---

## Fase 3: Módulo Dolibarr con mapas en tiempo real

Con los datos fluyendo a Traccar, desarrollé un módulo completo para Dolibarr que consume la API de Traccar y añade lógica de negocio.

### Mapas interactivos con Leaflet.js

```javascript
// Mapa de la flota en tiempo real
const map = L.map('fleet-map').setView([40.4168, -3.7038], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);

// Marcador por cada vehículo activo
vehicles.forEach(v => {
  const marker = L.marker([v.lat, v.lng], { icon: vehicleIcon })
    .bindPopup(`
      <b>${v.plate}</b><br>
      Velocidad: ${v.speed} km/h<br>
      Última actualización: ${v.lastUpdate}
    `)
    .addTo(map);
});
```

**¿Por qué Leaflet y no Google Maps?** Coste cero. OpenStreetMap + Leaflet no tiene límite de peticiones ni requiere API key de pago. Para una flota de 10-50 vehículos, es la solución correcta.

### Funcionalidades del módulo

| Funcionalidad | Implementación |
|---|---|
| **Tracking en tiempo real** | Polling a Traccar API cada 30s, actualización de marcadores en mapa |
| **Histórico de rutas** | Consultas a BD con filtro de fechas, polylines en Leaflet |
| **Geocercas** | Polígonos definidos por el usuario, alertas al entrar/salir |
| **Alertas de velocidad** | Threshold configurable por vehículo |
| **Paradas no autorizadas** | Detección de parada > 15min fuera de zonas permitidas |
| **Dashboards** | Chart.js con km recorridos, tiempo en ruta, paradas por día |
| **Comparación de rutas** | Ruta planificada vs ruta real superpuestas en mapa |

### Optimización de rutas con IA

Implementé un algoritmo que analiza el histórico de rutas y sugiere optimizaciones:

- Detecta rutas repetitivas con desvíos innecesarios
- Sugiere el orden óptimo de paradas (variante del TSP)
- Calcula ahorro estimado en km y combustible

---

## Retos técnicos reales

### 1. Volumen de datos GPS

Un GPS reportando cada 30 segundos genera **2.880 registros/día** por vehículo. Con 10 vehículos, son 28.800 registros diarios. En un mes, casi 1 millón.

**Solución**: 
- Índices compuestos en MySQL (`device_id`, `timestamp`)
- CRON job nocturno que consolida posiciones cercanas (< 5m de diferencia)
- Particionado de tablas por mes para consultas históricas rápidas

### 2. GPS que deja de reportar

Los GPS a veces pierden señal (garaje subterráneo, zona sin cobertura). El sistema necesita distinguir entre "vehículo parado" y "GPS sin señal".

**Solución**: timeout configurable. Si no hay datos en 5 minutos, el marcador cambia a gris con icono de "sin señal". Si el vehículo reporta velocidad 0, se marca como "parado" con color naranja.

### 3. Configuración remota del GPS por SMS

Los comandos SMS al GPS tienen sintaxis estricta y no hay feedback visual. Un carácter mal y el GPS no responde.

**Solución**: desarrollé un panel en Dolibarr que genera los comandos SMS correctos automáticamente. El usuario solo elige "cambiar intervalo a 60s" y el sistema compone el SMS con la sintaxis correcta del ST-901.

---

## Lo que consiguió la empresa

| Antes | Después |
|---|---|
| Cero visibilidad de la flota | Tracking en tiempo real de todos los vehículos |
| Rutas sin optimizar | Reducción del 15% en km recorridos |
| Sin histórico | 6 meses de recorridos consultables |
| SaaS de terceros (15€/vehículo/mes) | Coste recurrente: 0€ (solo datos móviles del GPS) |
| Datos en servidores externos | Control total — datos en servidor propio |
| Sin alertas | Alertas de velocidad, geocercas y paradas anómalas |

**ROI**: el coste del hardware (25€/GPS) + desarrollo se amortizó en 2 meses comparado con la suscripción SaaS anterior.

---

## Stack técnico

- **Hardware**: SinoTrack ST-901 4G
- **Plataforma GPS**: Traccar (open source, Docker)
- **Backend**: PHP + MySQL (módulo Dolibarr)
- **Mapas**: Leaflet.js + OpenStreetMap
- **Dashboards**: Chart.js + D3.js
- **Infraestructura**: Linux, Apache, SSL/TLS, Docker
- **Optimización**: Algoritmos de IA para rutas

---

## Lo que aprendí

1. **Open source > SaaS** para tracking GPS. Traccar es maduro, estable y gratis. Las plataformas propietarias cobran por algo que puedes tener por cero.
2. **La instalación física importa** — un GPS mal instalado da lecturas erráticas. Hay que verificar señal en frío y en movimiento.
3. **El volumen de datos GPS crece rápido** — sin consolidación y limpieza automática, la BD se infla en semanas.
4. **Leaflet + OpenStreetMap** es perfecto para flotas empresariales. Google Maps no aporta nada por el sobrecoste.
5. **Dolibarr es más extensible de lo que parece** — con un módulo bien diseñado puedes integrar casi cualquier funcionalidad.

---

## ¿Tu empresa necesita algo similar?

Si necesitas rastreo de flota, integración IoT con tu ERP, o cualquier desarrollo que conecte hardware con software, [cuéntame tu proyecto](/blog/servicios/).

**Más casos reales:**
- [Cómo construí un SaaS multitenant con NestJS y React](/blog/caso-real-saas-atrapaclientes-nestjs-react/)
- [Ecosistema IA para reuniones con Gemini y Supabase](/blog/caso-real-ia-reuniones-gemini-supabase/)
- [Sistema IoT de sensores con ESP32 integrado en Dolibarr](/blog/caso-real-iot-sensores-esp32-dolibarr/)
- [Módulo de comisiones automáticas para Dolibarr ERP](/blog/caso-real-modulo-comisiones-dolibarr/)]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Caso Real: Módulo de Comisiones en Dolibarr (PHP + Charts)]]></title>
      <link>https://francobosg.netlify.app/blog/caso-real-modulo-comisiones-dolibarr/</link>
      <description><![CDATA[Módulo de comisiones para Dolibarr ERP: cálculo automático sobre facturas, múltiples reglas, historial de pagos y dashboards con KPIs en tiempo real.]]></description>
      <pubDate>Sun, 05 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/caso-real-modulo-comisiones-dolibarr/</guid>
      <category>Caso Real</category>
      <category>PHP</category>
      <category>MySQL</category>
      <category>Dolibarr</category>
      <category>ERP</category>
      <category>Automatización</category>
      <category>Arquitectura</category>
      <content:encoded><![CDATA[## TL;DR

La empresa perdía 2 días al mes calculando comisiones en Excel, con errores y disputas. Desarrollé un módulo para Dolibarr que calcula comisiones automáticamente desde facturas y pedidos, soporta múltiples reglas, mantiene historial completo y muestra dashboards con KPIs. **Resultado: de 2 días a 5 minutos, cero errores, cero disputas.**

<video
  src="/videos/comisiones.mp4"
  controls
  muted
  playsinline
  class="w-full rounded-xl my-6 shadow-lg"
  style="max-height: 480px; object-fit: cover;"
>
  Tu navegador no soporta vídeo HTML5.
</video>

---

## El problema de la empresa

Cada final de mes, la misma pesadilla:

1. **Descargar facturas** del mes en Dolibarr
2. **Cruzar con Excel** quién vendió qué, a qué cliente, con qué porcentaje
3. **Aplicar reglas** — que cambiaban según el producto, el cliente y el volumen
4. **Revisar manualmente** porque siempre había errores
5. **Discutir con los comerciales** que decían "a mí me corresponde más"

**Tiempo**: 2 días completos de una persona de administración.
**Errores**: al menos 2-3 comisiones mal calculadas por mes.
**Conflictos**: disputas mensuales con el equipo comercial.

**Lo que me pidieron**: "Esto tiene que ser automático. Que se calcule solo y que no haya discusiones."

---

## Diseño del módulo

### Modelo de datos

```sql
-- Reglas de comisión configurables
CREATE TABLE commission_rules (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100),
    salesperson_id INT,         -- NULL = aplica a todos
    product_category_id INT,    -- NULL = todos los productos
    client_id INT,              -- NULL = todos los clientes
    percentage DECIMAL(5,2),
    min_amount DECIMAL(10,2),   -- Umbral mínimo para aplicar
    type ENUM('fixed', 'tiered', 'product', 'client'),
    active TINYINT DEFAULT 1,
    created_at DATETIME,
    INDEX idx_salesperson (salesperson_id),
    INDEX idx_active (active)
);

-- Comisiones calculadas (inmutables una vez generadas)
CREATE TABLE commissions (
    id INT PRIMARY KEY AUTO_INCREMENT,
    rule_id INT,
    salesperson_id INT,
    invoice_id INT,
    order_id INT,
    base_amount DECIMAL(10,2),      -- Monto de la factura
    commission_amount DECIMAL(10,2), -- Comisión calculada
    percentage_applied DECIMAL(5,2),
    period VARCHAR(7),               -- '2026-04'
    status ENUM('pending', 'approved', 'paid'),
    paid_date DATE,
    created_at DATETIME,
    INDEX idx_period (period),
    INDEX idx_salesperson_period (salesperson_id, period)
);

-- Historial de cambios (auditoría completa)
CREATE TABLE commission_history (
    id INT PRIMARY KEY AUTO_INCREMENT,
    commission_id INT,
    action ENUM('created', 'approved', 'paid', 'modified'),
    old_value TEXT,
    new_value TEXT,
    user_id INT,
    created_at DATETIME
);
```

Tres tablas. Las reglas son configurables, las comisiones son inmutables una vez generadas (se puede auditar cualquier mes pasado), y el historial guarda cada cambio con quién lo hizo y cuándo.

---

## Tipos de comisión que implementé

### 1. Comisión fija por porcentaje

La más simple: el comercial se lleva un % de cada factura que cierra.

```php
// Ejemplo: 5% de cada factura
$commission = $invoice->total_ht * ($rule->percentage / 100);
```

### 2. Comisión escalonada (tiered)

El porcentaje aumenta con el volumen de ventas. Esto incentiva al comercial a vender más.

```php
// Ejemplo de tramos:
// 0 - 5.000€   → 3%
// 5.001 - 15.000€ → 5%
// > 15.000€     → 8%

function calculateTieredCommission($totalSales, $tiers) {
    $commission = 0;
    $remaining = $totalSales;

    foreach ($tiers as $tier) {
        $tierAmount = min($remaining, $tier['max'] - $tier['min']);
        if ($tierAmount <= 0) break;

        $commission += $tierAmount * ($tier['percentage'] / 100);
        $remaining -= $tierAmount;
    }

    return $commission;
}
```

### 3. Comisión por producto

Diferentes productos tienen diferentes márgenes. Un producto de alto margen paga más comisión.

### 4. Comisión por cliente

Clientes nuevos pueden tener un bonus de captación. Clientes existentes tienen un porcentaje estándar.

### La combinación

Las reglas se evalúan en orden de prioridad. Si hay una regla específica (producto + cliente + comercial), se aplica esa. Si no, se busca la regla más general. **Nunca se aplican dos reglas al mismo concepto.**

---

## Integración con Dolibarr (sin romper nada)

Dolibarr tiene su propia estructura de módulos. No puedes parchear archivos core — tienes que hacerlo "a la Dolibarr":

1. **Directorio del módulo**: `custom/commissions/`
2. **Descriptor del módulo**: declara menús, permisos, tablas
3. **Modelos de datos**: clases PHP que extienden `CommonObject`
4. **Páginas**: PHP con la UI integrada en el look de Dolibarr
5. **API REST**: endpoints adicionales para integraciones externas

```php
// Clase principal del módulo
class Commission extends CommonObject {
    public $table_element = 'commissions';
    
    public function calculate($salesperson_id, $period) {
        // 1. Obtener facturas del período
        $invoices = $this->getInvoicesByPeriod($salesperson_id, $period);
        
        // 2. Obtener reglas aplicables
        $rules = $this->getApplicableRules($salesperson_id);
        
        // 3. Calcular comisión por cada factura
        $total = 0;
        foreach ($invoices as $invoice) {
            $rule = $this->findBestRule($rules, $invoice);
            $amount = $this->applyRule($rule, $invoice);
            
            $this->saveCommission($salesperson_id, $invoice, $rule, $amount);
            $total += $amount;
        }
        
        return $total;
    }
}
```

**Clave**: el módulo solo lee facturas y pedidos de Dolibarr. No modifica nada existente. Si desactivas el módulo, Dolibarr funciona exactamente igual que antes.

---

## Dashboards con KPIs

El equipo de dirección quería ver de un vistazo cómo iba el mes. Desarrollé dashboards con Chart.js:

### KPIs principales

- **Comisiones del mes**: total acumulado vs mes anterior
- **Top comerciales**: ranking por comisiones generadas
- **Comisiones pendientes de pago**: cuánto se debe al equipo
- **Evolución mensual**: gráfica de 12 meses con tendencia
- **Desglose por tipo**: fija, escalonada, por producto, por cliente

### Tabla de detalle

Cada comercial puede ver su desglose:

| Factura | Cliente | Producto | Base | % | Comisión | Estado |
|---|---|---|---|---|---|---|
| FA-2026-001 | Empresa A | Servicio Premium | 3.500€ | 5% | 175€ | Pagada |
| FA-2026-002 | Empresa B | Producto Estándar | 1.200€ | 3% | 36€ | Pendiente |

**Transparencia total**: el comercial ve exactamente de dónde viene cada céntimo de su comisión. Cero disputas.

---

## API REST para integraciones

Además de la interfaz web, el módulo expone endpoints REST:

```
GET  /api/commissions?period=2026-04          → Listar comisiones del mes
GET  /api/commissions/salesperson/{id}         → Comisiones de un comercial
POST /api/commissions/calculate?period=2026-04 → Lanzar cálculo del mes
PUT  /api/commissions/{id}/approve             → Aprobar comisión
PUT  /api/commissions/{id}/pay                 → Marcar como pagada
GET  /api/commissions/stats?year=2026          → KPIs anuales
```

Todos los endpoints requieren autenticación con token y permisos RBAC de Dolibarr.

---

## Retos técnicos

### 1. No alterar datos históricos

Las comisiones ya pagadas no se pueden recalcular. Si cambian las reglas, solo aplican a partir del mes siguiente. Esto parece simple pero requiere inmutabilidad en el modelo de datos.

**Solución**: la tabla `commissions` tiene status `paid` que congela el registro. Las reglas tienen fecha de vigencia. El cálculo siempre usa las reglas vigentes en el período que se calcula.

### 2. Reglas con conflicto de prioridad

¿Qué pasa si hay una regla "5% para todos" y otra "8% para el producto X"? ¿Se aplican las dos?

**Solución**: sistema de prioridad — la regla más específica gana. Orden: `comercial + producto + cliente` > `comercial + producto` > `comercial` > `general`. Nunca se aplican dos reglas al mismo concepto.

### 3. Performance con muchas facturas

Un cálculo mensual puede tocar 500+ facturas y 20 comerciales. Con queries mal optimizadas, tarda minutos.

**Solución**: índices compuestos en MySQL, precarga de reglas en memoria (son pocas), y batch insert para las comisiones generadas.

---

## Lo que consiguió la empresa

| Antes | Después |
|---|---|
| 2 días de cálculo manual | 5 minutos (click → resultado) |
| 2-3 errores por mes | 0 errores |
| Disputas mensuales | Cero disputas (transparencia total) |
| Sin histórico | Auditoría completa de cada comisión |
| Excel compartido | Dashboard con KPIs en tiempo real |
| Sin API | Endpoints REST para integraciones |

**Impacto real**: la persona de administración que dedicaba 2 días al mes ahora dedica 5 minutos a revisar y aprobar. El equipo comercial confía en los números porque pueden ver el desglose.

---

## Stack técnico

- **Backend**: PHP 8.x (módulo Dolibarr)
- **Base de datos**: MySQL con índices optimizados
- **Dashboards**: Chart.js para gráficas interactivas
- **API**: REST con autenticación por token
- **Seguridad**: SSL/TLS, tokens JWT, permisos RBAC de Dolibarr
- **Infraestructura**: Linux, Apache

---

## Lo que aprendí

1. **La inmutabilidad protege** — registros de comisiones pagadas nunca se modifican. Si hay corrección, se crea un nuevo registro. Esto elimina el "alguien cambió mi comisión".
2. **Las reglas de negocio parecen simples hasta que no lo son** — "5% por factura" se convierte en escalonada + por producto + por cliente + con umbral mínimo.
3. **La transparencia elimina conflictos** — cuando el comercial puede ver el desglose exact de su comisión, las disputas desaparecen.
4. **Dolibarr es extensible** — su sistema de módulos permite añadir funcionalidad compleja sin tocar el core.
5. **Charts.js es suficiente** para dashboards empresariales. No necesitas D3.js ni Recharts para KPIs básicos.

---

## ¿Tu empresa necesita automatizar procesos?

Si pierdes tiempo con Excels, cálculos manuales o procesos que deberían ser automáticos, [cuéntame qué necesitas](/blog/servicios/).

**Más casos reales:**
- [Cómo construí un SaaS multitenant con NestJS y React](/blog/caso-real-saas-atrapaclientes-nestjs-react/)
- [Sistema GPS de flota con Traccar y Leaflet](/blog/caso-real-gps-flota-vehiculos-dolibarr/)
- [Sistema IoT de sensores con ESP32 integrado en Dolibarr](/blog/caso-real-iot-sensores-esp32-dolibarr/)
- [Ecosistema IA para reuniones con Gemini y Supabase](/blog/caso-real-ia-reuniones-gemini-supabase/)]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[10 Ideas de Proyectos Finales para DAW y DAM que Puedes Hacer en 2026]]></title>
      <link>https://francobosg.netlify.app/blog/ideas-proyecto-final-daw-dam-2026/</link>
      <description><![CDATA[Ideas de TFG para DAW y DAM con stack moderno, nivel de dificultad y tiempo estimado. Proyectos que impresionan al tribunal y te sirven de portfolio.]]></description>
      <pubDate>Sat, 04 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/ideas-proyecto-final-daw-dam-2026/</guid>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Si estás leyendo esto es porque probablemente te toque el **proyecto final** pronto y estás entre la espada y la pared: necesitas una idea que sea lo bastante ambiciosa para impresionar al tribunal, pero lo bastante realista para no morir en el intento.

Tranqui. Aquí tienes 10 ideas probadas, con el stack tecnológico, nivel de dificultad y tiempo estimado. Todas pensadas para que **te sirvan de portfolio** después de entregar.

> **TL;DR — Las 3 que más impresionan a los tribunales:**
> 1. Dashboard de analíticas con gráficos en tiempo real (muestra que sabes manejar datos)
> 2. App de gestión con roles y permisos (demuestra que entiendes seguridad)
> 3. Marketplace o plataforma con pasarela de pago (lo más cercano a producción real)

---

## Antes de elegir: las 3 reglas de oro

1. **Resuelve un problema real**. No hagas "otro clon de Twitter". Piensa en algo que tú, tu familia o tu pueblo necesite.
2. **Documenta desde el día 1**. El 50% de la nota suele ser la memoria. Si dejas la documentación para el final, te va a salir una chapuza.
3. **Despliega en internet**. Un proyecto solo en localhost es un ejercicio. Un proyecto desplegado es un portfolio. Usa [Vercel](https://vercel.com), [Railway](https://railway.app) o [Render](https://render.com) — todos tienen planes gratuitos.

---

## Las 10 ideas (ordenadas de menos a más difícil)

### 1. Portfolio personal con blog integrado

**Para:** DAW | **Dificultad:** ⭐ | **Tiempo:** 1-2 semanas

No es la idea más original, pero es la más práctica: te queda un portfolio real que puedes usar para buscar trabajo.

**Stack recomendado:**
- Frontend: Astro + Tailwind CSS
- Blog: Markdown con content collections
- Deploy: Netlify o Vercel (gratis)

```html
<!-- Ejemplo: estructura básica responsive con Tailwind -->
<section class="max-w-4xl mx-auto px-4 py-12">
  <h1 class="text-4xl font-bold text-gray-900">
    Hola, soy [Tu Nombre] 👋
  </h1>
  <p class="text-lg text-gray-600 mt-4">
    <!-- Describe en 1 frase qué haces y qué buscas -->
    Desarrollador web junior especializado en React y Node.js.
    Busco mi primera oportunidad profesional.
  </p>
  <!-- Sección de proyectos con grid responsive -->
  <div class="grid md:grid-cols-2 gap-6 mt-8">
    <!-- Cada proyecto en una card -->
  </div>
</section>
```

**Para subir nota:** Añade un modo oscuro, un formulario de contacto funcional y analíticas con [Plausible](https://plausible.io) (open-source).

---

### 2. App de lista de tareas con autenticación

**Para:** DAW / DAM | **Dificultad:** ⭐⭐ | **Tiempo:** 2-3 semanas

Sí, es un clásico. Pero si la haces bien — con login real, base de datos y despliegue — demuestra que dominas el CRUD completo.

**Stack recomendado (DAW):**
- Frontend: React + Vite
- Backend: Node.js + Express
- BBDD: PostgreSQL (Supabase gratis)
- Auth: JWT con bcrypt

**Stack recomendado (DAM):**
- Kotlin + Jetpack Compose
- Room (SQLite local) o Firebase
- Auth: Firebase Authentication

```javascript
// Ejemplo: middleware de autenticación con JWT (Node.js)
// Este middleware verifica el token en cada petición protegida

import jwt from "jsonwebtoken";

function authMiddleware(req, res, next) {
  // 1. Extraer el token del header Authorization
  const header = req.headers.authorization;
  if (!header) {
    return res.status(401).json({ error: "Token no proporcionado" });
  }

  // 2. El formato es "Bearer <token>", así que separamos
  const token = header.split(" ")[1];

  try {
    // 3. Verificar que el token es válido y no ha expirado
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    // 4. Guardar los datos del usuario en la request
    req.userId = decoded.userId;
    next(); // ← continuar al siguiente middleware/ruta
  } catch (err) {
    return res.status(403).json({ error: "Token inválido o expirado" });
  }
}
```

**Errores típicos que te darán un 0 en el examen:**
- Guardar contraseñas en texto plano (siempre usa `bcrypt.hash()`)
- No validar inputs en el backend (solo validar en frontend no sirve)
- Usar `localStorage` para guardar el JWT en vez de una cookie `httpOnly`

---

### 3. Gestor de inventario para un negocio local

**Para:** DAW / DAM | **Dificultad:** ⭐⭐ | **Tiempo:** 2-3 semanas

Pregunta en la panadería, la ferretería o el bar de tu barrio si necesitan un sistema para controlar su stock. Proyecto real = nota alta + historia para la entrevista de trabajo.

**Funcionalidades mínimas:**
- CRUD de productos (nombre, precio, cantidad, categoría)
- Alertas de stock bajo
- Historial de movimientos (entradas/salidas)
- Exportar a CSV

```sql
-- Ejemplo: esquema de base de datos (PostgreSQL)
-- 3 tablas básicas que cubren todo

CREATE TABLE categorias (
  id SERIAL PRIMARY KEY,
  nombre VARCHAR(100) NOT NULL UNIQUE
);

CREATE TABLE productos (
  id SERIAL PRIMARY KEY,
  nombre VARCHAR(200) NOT NULL,
  precio DECIMAL(10,2) NOT NULL CHECK (precio >= 0),
  cantidad INTEGER NOT NULL DEFAULT 0 CHECK (cantidad >= 0),
  stock_minimo INTEGER NOT NULL DEFAULT 5,
  categoria_id INTEGER REFERENCES categorias(id),
  created_at TIMESTAMP DEFAULT NOW()
);

-- Cada vez que entra o sale stock, se registra aquí
CREATE TABLE movimientos (
  id SERIAL PRIMARY KEY,
  producto_id INTEGER REFERENCES productos(id),
  tipo VARCHAR(10) CHECK (tipo IN ('entrada', 'salida')),
  cantidad INTEGER NOT NULL CHECK (cantidad > 0),
  nota TEXT,  -- "Pedido proveedor X" o "Venta mostrador"
  created_at TIMESTAMP DEFAULT NOW()
);
```

---

### 4. Plataforma de reservas (citas, aulas, pistas deportivas)

**Para:** DAW | **Dificultad:** ⭐⭐⭐ | **Tiempo:** 3-4 semanas

Cualquier sistema de reservas impresiona porque resuelve un problema complejo: **evitar conflictos de horarios**.

**Stack recomendado:**
- Next.js (frontend + API routes)
- Prisma + PostgreSQL
- Calendario visual con FullCalendar.js
- Notificaciones por email con Resend (gratis)

**El truco para impresionar:** Añade una vista de calendario interactiva donde el usuario pueda ver qué slots están libres y reservar con un clic. Los tribunales valoran mucho el UX.

---

### 5. Dashboard de analíticas con gráficos en tiempo real

**Para:** DAW | **Dificultad:** ⭐⭐⭐ | **Tiempo:** 3-4 semanas

Conecta a una API pública (meteorología, criptomonedas, datos del INE) y muestra los datos en gráficos bonitos. Esto demuestra que sabes consumir APIs, transformar datos y presentarlos.

**Stack recomendado:**
- React + Vite (o Next.js)
- Chart.js o Recharts para gráficos
- API pública: [OpenWeather](https://openweathermap.org/api), [CoinGecko](https://www.coingecko.com/api), [datos.gob.es](https://datos.gob.es)
- WebSocket para datos en "tiempo real" (optional, sube mucha nota)

```javascript
// Ejemplo: hook personalizado para consumir API con React
// Gestiona loading, error y refetch automático

import { useState, useEffect } from "react";

function useApiData(url, intervaloMs = 60000) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Función que hace el fetch
    async function fetchData() {
      try {
        setLoading(true);
        const res = await fetch(url);
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const json = await res.json();
        setData(json);
        setError(null);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    fetchData(); // Primera carga inmediata

    // Refetch automático cada X milisegundos
    const interval = setInterval(fetchData, intervaloMs);
    return () => clearInterval(interval); // Limpiar al desmontar
  }, [url, intervaloMs]);

  return { data, loading, error };
}

// Uso: const { data, loading } = useApiData("/api/ventas", 30000);
```

---

### 6. App de gestión con roles y permisos

**Para:** DAW / DAM | **Dificultad:** ⭐⭐⭐ | **Tiempo:** 3-4 semanas

Una app donde hay Admin, Editor y Usuario. Cada rol ve cosas diferentes y puede hacer acciones diferentes. Esto demuestra que entiendes **seguridad y autorización**, que es lo que las empresas más valoran.

**Ejemplo de permisos:**

| Acción | Admin | Editor | Usuario |
|---|---|---|---|
| Ver contenido | ✅ | ✅ | ✅ |
| Crear contenido | ✅ | ✅ | ❌ |
| Editar cualquier contenido | ✅ | Solo suyo | ❌ |
| Gestionar usuarios | ✅ | ❌ | ❌ |
| Ver panel de admin | ✅ | ❌ | ❌ |

**Errores típicos que te darán un 0:**
- Controlar los permisos solo en el frontend (un `fetch` desde la consola del navegador se salta todo)
- No verificar el rol en CADA endpoint del backend
- Usar un solo middleware genérico en vez de permisos granulares

---

### 7. Red social / foro temático

**Para:** DAW | **Dificultad:** ⭐⭐⭐⭐ | **Tiempo:** 4-5 semanas

No intentes hacer "otro Instagram". Haz un foro de nicho: para los estudiantes de tu instituto, para intercambiar apuntes, para organizar quedadas de estudio.

**Funcionalidades mínimas:**
- Registro/login (OAuth con Google es fácil y queda profesional)
- Crear hilos y responder con comentarios
- Subir imágenes (Cloudinary gratis)
- Sistema de likes/votos
- Búsqueda por texto

**Stack:** Next.js + Prisma + Supabase + Cloudinary + NextAuth.js

---

### 8. E-commerce / Marketplace con pasarela de pago

**Para:** DAW | **Dificultad:** ⭐⭐⭐⭐ | **Tiempo:** 4-5 semanas

El proyecto que más impresiona a los tribunales porque es lo más cercano a una app real de producción.

**Stack recomendado:**
- Next.js (App Router)
- Stripe para pagos (tiene modo test gratuito)
- Prisma + PostgreSQL
- Cloudinary para imágenes de productos
- Email transaccional con Resend

```javascript
// Ejemplo: crear sesión de pago con Stripe (API route Next.js)
// El usuario clicka "Comprar" → se redirige a Stripe → vuelve a tu web

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function POST(req) {
  const { items } = await req.json();

  // Crear la sesión de checkout de Stripe
  const session = await stripe.checkout.sessions.create({
    payment_method_types: ["card"],
    // Transformar tus items al formato que espera Stripe
    line_items: items.map((item) => ({
      price_data: {
        currency: "eur",
        product_data: { name: item.nombre },
        unit_amount: Math.round(item.precio * 100), // Stripe usa céntimos
      },
      quantity: item.cantidad,
    })),
    mode: "payment",
    // A dónde vuelve el usuario después de pagar (o cancelar)
    success_url: `${process.env.URL}/pedido/confirmado`,
    cancel_url: `${process.env.URL}/carrito`,
  });

  // Devolver la URL de Stripe para redirigir al usuario
  return Response.json({ url: session.url });
}
```

**Nota importante:** No necesitas procesar pagos reales. Stripe tiene un [modo test](https://stripe.com/docs/testing) con tarjetas ficticias. El tribunal va a alucinar igual.

---

### 9. App de gestión de FCT / prácticas

**Para:** DAW / DAM | **Dificultad:** ⭐⭐⭐ | **Tiempo:** 3-4 semanas

Un sistema donde los alumnos registran sus horas de prácticas, los tutores las validan y se genera un informe PDF automáticamente. **Tus propios compañeros querrán usarla.**

**Funcionalidades:**
- Login con 3 roles: alumno, tutor centro, tutor empresa
- Registro diario de actividades y horas
- Validación por parte del tutor
- Generación de PDF con resumen mensual (usa [jsPDF](https://github.com/parallax/jsPDF))
- Dashboard con horas acumuladas vs horas objetivo

---

### 10. Plataforma de aprendizaje con quiz interactivo

**Para:** DAW / DAM | **Dificultad:** ⭐⭐⭐⭐ | **Tiempo:** 4-5 semanas

Una web donde los usuarios pueden hacer tests sobre programación, ver sus resultados, y comparar con otros. Si la haces bien, tus compañeros la usarán para repasar exámenes.

**Stack recomendado:**
- Frontend: React + Vite (o Next.js)
- Backend: Node.js + Express (o API routes de Next.js)
- BBDD: PostgreSQL con Prisma
- Gamificación: sistema de puntos, racha diaria, ranking

```javascript
// Ejemplo: lógica básica del quiz (React)
// Estado que controla pregunta actual, puntuación y si ha terminado

const [preguntaActual, setPreguntaActual] = useState(0);
const [puntos, setPuntos] = useState(0);
const [terminado, setTerminado] = useState(false);

function responder(indiceRespuesta) {
  // 1. Comprobar si la respuesta es correcta
  const esCorrecta =
    indiceRespuesta === preguntas[preguntaActual].correcta;

  // 2. Sumar puntos si acertó
  if (esCorrecta) setPuntos((prev) => prev + 10);

  // 3. Pasar a la siguiente pregunta o terminar
  if (preguntaActual + 1 < preguntas.length) {
    setPreguntaActual((prev) => prev + 1);
  } else {
    setTerminado(true);
    // Aquí guardarías la puntuación en la BBDD
  }
}
```

---

## Cómo documentar tu TFG sin morir en el intento

La documentación es el **50% de la nota** y el 90% de los suspensos son por una memoria mediocre. Aquí tienes la estructura mínima:

1. **Introducción** — Qué problema resuelves y para quién
2. **Análisis de requisitos** — Lista de funcionalidades (usa MoSCoW: Must/Should/Could/Won't)
3. **Diseño** — Diagrama de la base de datos + wireframes (usa [Excalidraw](https://excalidraw.com), gratis)
4. **Tecnologías** — Por qué elegiste cada herramienta (no vale "porque es la que conozco")
5. **Implementación** — Código relevante explicado, NO todo el código
6. **Pruebas** — Screenshots de la app funcionando + tests si los tienes
7. **Conclusiones** — Qué aprendiste y qué mejorarías
8. **Bibliografía** — Fuentes, documentación oficial, tutoriales usados

**Herramientas para la memoria:**
- [Notion](https://notion.so) o Google Docs para escribir
- [Excalidraw](https://excalidraw.com) para diagramas
- [dbdiagram.io](https://dbdiagram.io) para el esquema de BBDD
- [Figma](https://figma.com) para wireframes (plan gratuito)

---

## Cómo desplegarlo gratis (y subir nota)

| Servicio | Qué despliegas | Plan gratuito |
|---|---|---|
| [Vercel](https://vercel.com) | Frontend (React, Next.js, Astro) | Sí, ilimitado |
| [Netlify](https://netlify.com) | Frontend estático | Sí, 100GB/mes |
| [Railway](https://railway.app) | Backend (Node, Python, Java) | $5 gratis/mes |
| [Render](https://render.com) | Backend + BBDD | Sí, con limitaciones |
| [Supabase](https://supabase.com) | PostgreSQL + Auth + Storage | Sí, 500MB |
| [PlanetScale](https://planetscale.com) | MySQL serverless | Plan hobby gratis |
| [Cloudinary](https://cloudinary.com) | Imágenes y archivos | 25GB gratis |

---

## Los errores que más veo en proyectos finales

Después de ver decenas de TFGs (como desarrollador y como mentor), estos son los errores que más repiten los alumnos:

1. **Empezar por el frontend** — Primero diseña la base de datos y las rutas de la API. El frontend es lo último
2. **No usar Git desde el principio** — El tribunal quiere ver un historial de commits. Si subes todo de golpe al final, se nota y baja nota. Usa [los comandos esenciales de Git](/blog/comandos-git-esenciales-2026/)
3. **Copiar código sin entenderlo** — Van a preguntarte "¿por qué usaste esto aquí?". Si no sabes responder, es un 0
4. **Cero gestión de errores** — Si tu app crashea al meter datos raros, es suspense directo. Valida inputs siempre
5. **Memoria de 10 páginas** — La memoria debería tener 40-60 páginas mínimo. Si es más corta, falta contenido

---

## Consejo final: úsalo como portfolio

Tu proyecto final es **tu carta de presentación** para buscar trabajo. Cuando lo termines:

1. Despliégalo en internet (enlaces arriba)
2. Sube el código a GitHub con un README profesional
3. Añádelo a tu [portfolio personal](/blog/como-hice-mi-portfolio-vite-tailwind/)
4. Menciónalo en tu perfil de LinkedIn

Un proyecto real, desplegado y bien documentado vale más que 10 certificados de Udemy.

---

*¿Te has atascado con tu proyecto final o necesitas ayuda con la documentación? Échale un ojo a mi sección de [herramientas para developers](/blog/herramientas/) o genera un [prompt de debugging](/blog/herramientas/generador-prompts/) para que una IA te ayude.*

*Y si quieres más consejos sobre tu carrera en tech, aquí tienes la [guía completa para estudiantes de DAW, DAM y ASIR](/blog/guia-estudiantes-daw-dam-smr-2026/).*]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cómo Conectar Java con MySQL (JDBC) — Tutorial Paso a Paso 2026]]></title>
      <link>https://francobosg.netlify.app/blog/conectar-java-mysql-jdbc-tutorial-2026/</link>
      <description><![CDATA[Conexión Java a MySQL con JDBC explicada línea por línea. Incluye CRUD completo, PreparedStatement, connection pool y los errores que te van a saltar.]]></description>
      <pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/conectar-java-mysql-jdbc-tutorial-2026/</guid>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Este es el tutorial que me habría ahorrado 4 horas de errores incomprensibles cuando estaba en DAM. Vamos a conectar Java con MySQL usando JDBC, paso a paso, con cada línea de código **explicada**.

No te voy a soltar la teoría de libro. Vamos directo al código que funciona, con los errores que te van a saltar y cómo solucionarlos.

> **TL;DR — Lo que vas a aprender:**
> 1. Configurar el proyecto con Maven y el conector MySQL
> 2. Crear la conexión JDBC correctamente (sin fugas de memoria)
> 3. CRUD completo con PreparedStatement (seguro contra SQL injection)
> 4. Connection pool con HikariCP (lo que usan las empresas)
> 5. Los 5 errores que te van a saltar y cómo arreglarlos

---

## Paso 0: Lo que necesitas instalado

Antes de tocar código, verifica que tienes esto:

| Herramienta | Versión mínima | Comprobar con |
|---|---|---|
| Java JDK | 17+ | `java --version` |
| Maven | 3.9+ | `mvn --version` |
| MySQL Server | 8.0+ | `mysql --version` |

Si usas IntelliJ IDEA o Eclipse, Maven ya viene integrado.

---

## Paso 1: Crear el proyecto Maven

Abre la terminal y crea un proyecto nuevo:

```bash
# Crear proyecto Maven con el archetype quickstart
mvn archetype:generate \
  -DgroupId=com.ejemplo \
  -DartifactId=java-mysql-demo \
  -DarchetypeArtifactId=maven-archetype-quickstart \
  -DarchetypeVersion=1.5 \
  -DinteractiveMode=false

cd java-mysql-demo
```

### Añadir el conector MySQL

Abre `pom.xml` y añade la dependencia dentro de `<dependencies>`:

```xml
<!-- pom.xml — añadir dentro de <dependencies> -->
<dependency>
    <!-- Este es el conector oficial de MySQL para Java -->
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>9.2.0</version>
</dependency>
```

Luego ejecuta `mvn install` para descargar la dependencia.

---

## Paso 2: Crear la base de datos

Entra en MySQL y crea la base de datos de ejemplo:

```sql
-- Crear la base de datos
CREATE DATABASE IF NOT EXISTS tienda_daw;
USE tienda_daw;

-- Crear tabla de productos
CREATE TABLE productos (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(200) NOT NULL,
    precio DECIMAL(10,2) NOT NULL CHECK (precio >= 0),
    cantidad INT NOT NULL DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Insertar datos de ejemplo para probar
INSERT INTO productos (nombre, precio, cantidad) VALUES
    ('Teclado mecánico', 79.99, 15),
    ('Monitor 27"', 299.00, 8),
    ('Ratón gaming', 49.50, 25);
```

---

## Paso 3: La conexión JDBC (bien hecha)

Aquí es donde el 80% de los estudiantes meten la pata. Vamos a hacerlo bien desde el principio:

```java
// ConexionDB.java — Clase que gestiona la conexión a MySQL
// IMPORTANTE: nunca hardcodees las credenciales en producción

package com.ejemplo;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class ConexionDB {

    // Datos de conexión (en producción, usa variables de entorno)
    private static final String URL = "jdbc:mysql://localhost:3306/tienda_daw";
    private static final String USER = "root";
    private static final String PASSWORD = "tu_password_aqui";

    /**
     * Obtiene una conexión a la base de datos MySQL.
     *
     * Parámetros de la URL JDBC:
     * - localhost:3306 → host y puerto de MySQL
     * - tienda_daw → nombre de la base de datos
     * - useSSL=false → no usar SSL en desarrollo local
     * - serverTimezone=UTC → evita errores de zona horaria
     */
    public static Connection getConnection() throws SQLException {
        return DriverManager.getConnection(
            URL + "?useSSL=false&serverTimezone=UTC",
            USER,
            PASSWORD
        );
    }

    /**
     * Comprueba que la conexión funciona.
     * Ejecuta esto primero para verificar que todo está bien.
     */
    public static void main(String[] args) {
        // try-with-resources: cierra la conexión automáticamente
        try (Connection conn = getConnection()) {
            if (conn != null && !conn.isClosed()) {
                System.out.println("✅ Conexión exitosa a MySQL!");
                System.out.println("   Base de datos: " + conn.getCatalog());
            }
        } catch (SQLException e) {
            System.err.println("❌ Error de conexión: " + e.getMessage());
            // Los códigos de error más comunes:
            // 08001 → MySQL no está corriendo
            // 28000 → usuario/contraseña incorrectos
            // 42000 → la base de datos no existe
            System.err.println("   Código SQL: " + e.getSQLState());
        }
    }
}
```

Ejecuta esta clase primero. Si ves `✅ Conexión exitosa`, puedes seguir. Si no, revisa la sección de errores al final.

---

## Paso 4: CRUD completo con PreparedStatement

Ahora sí — las 4 operaciones que necesitas para cualquier proyecto:

```java
// ProductoDAO.java — Data Access Object para la tabla productos
// DAO es el patrón que separa la lógica de BBDD del resto de la app

package com.ejemplo;

import java.sql.*;
import java.util.ArrayList;
import java.util.List;

public class ProductoDAO {

    // ─── CREATE ───────────────────────────────────────────────
    // Inserta un producto nuevo y devuelve el ID generado
    public int insertar(String nombre, double precio, int cantidad)
            throws SQLException {

        // ⚠️ SIEMPRE usar ? en vez de concatenar strings
        // Esto previene SQL Injection (seguridad obligatoria)
        String sql = "INSERT INTO productos (nombre, precio, cantidad) "
                   + "VALUES (?, ?, ?)";

        try (Connection conn = ConexionDB.getConnection();
             // RETURN_GENERATED_KEYS → para obtener el ID auto-generado
             PreparedStatement ps = conn.prepareStatement(sql,
                 Statement.RETURN_GENERATED_KEYS)) {

            // Asignar valores a cada ? (índice empieza en 1, no en 0)
            ps.setString(1, nombre);     // ? número 1 = nombre
            ps.setDouble(2, precio);     // ? número 2 = precio
            ps.setInt(3, cantidad);      // ? número 3 = cantidad

            // executeUpdate() para INSERT, UPDATE, DELETE
            // executeQuery() es para SELECT
            int filas = ps.executeUpdate();

            // Obtener el ID que MySQL generó automáticamente
            if (filas > 0) {
                ResultSet rs = ps.getGeneratedKeys();
                if (rs.next()) {
                    return rs.getInt(1); // El ID generado
                }
            }
            return -1;
        }
        // La conexión se cierra automáticamente gracias al try-with-resources
    }

    // ─── READ (todos) ─────────────────────────────────────────
    public List<String> listarTodos() throws SQLException {
        String sql = "SELECT id, nombre, precio, cantidad FROM productos "
                   + "ORDER BY nombre";
        List<String> productos = new ArrayList<>();

        try (Connection conn = ConexionDB.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql);
             ResultSet rs = ps.executeQuery()) {

            // Recorrer cada fila del resultado
            while (rs.next()) {
                // Acceder a las columnas por nombre (más legible que por índice)
                String linea = String.format(
                    "[%d] %s — %.2f€ (stock: %d)",
                    rs.getInt("id"),
                    rs.getString("nombre"),
                    rs.getDouble("precio"),
                    rs.getInt("cantidad")
                );
                productos.add(linea);
            }
        }
        return productos;
    }

    // ─── READ (uno por ID) ────────────────────────────────────
    public String buscarPorId(int id) throws SQLException {
        String sql = "SELECT * FROM productos WHERE id = ?";

        try (Connection conn = ConexionDB.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {

            ps.setInt(1, id);

            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return String.format(
                        "%s — %.2f€ (stock: %d)",
                        rs.getString("nombre"),
                        rs.getDouble("precio"),
                        rs.getInt("cantidad")
                    );
                }
                return null; // No encontrado
            }
        }
    }

    // ─── UPDATE ───────────────────────────────────────────────
    public boolean actualizar(int id, String nombre, double precio,
                              int cantidad) throws SQLException {
        String sql = "UPDATE productos SET nombre = ?, precio = ?, "
                   + "cantidad = ? WHERE id = ?";

        try (Connection conn = ConexionDB.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {

            ps.setString(1, nombre);
            ps.setDouble(2, precio);
            ps.setInt(3, cantidad);
            ps.setInt(4, id);  // El WHERE va al final

            // executeUpdate() devuelve el número de filas afectadas
            return ps.executeUpdate() > 0;
        }
    }

    // ─── DELETE ───────────────────────────────────────────────
    public boolean eliminar(int id) throws SQLException {
        String sql = "DELETE FROM productos WHERE id = ?";

        try (Connection conn = ConexionDB.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {

            ps.setInt(1, id);
            return ps.executeUpdate() > 0;
        }
    }
}
```

### Probarlo todo junto

```java
// Main.java — Programa principal para probar el CRUD

package com.ejemplo;

public class Main {
    public static void main(String[] args) {
        ProductoDAO dao = new ProductoDAO();

        try {
            // 1. Insertar
            int id = dao.insertar("Webcam 4K", 89.99, 12);
            System.out.println("✅ Producto creado con ID: " + id);

            // 2. Listar todos
            System.out.println("\n📋 Todos los productos:");
            dao.listarTodos().forEach(System.out::println);

            // 3. Buscar uno
            System.out.println("\n🔍 Producto ID " + id + ": "
                             + dao.buscarPorId(id));

            // 4. Actualizar
            dao.actualizar(id, "Webcam 4K Pro", 119.99, 10);
            System.out.println("\n✏️ Actualizado: " + dao.buscarPorId(id));

            // 5. Eliminar
            dao.eliminar(id);
            System.out.println("\n🗑️ Eliminado. Existe? "
                             + (dao.buscarPorId(id) == null ? "No" : "Sí"));

        } catch (Exception e) {
            System.err.println("Error: " + e.getMessage());
            e.printStackTrace();
        }
    }
}
```

---

## Paso 5: Connection Pool con HikariCP (nivel pro)

En un proyecto real, **nunca** abres una conexión nueva para cada consulta. Usas un *connection pool* que reutiliza conexiones. HikariCP es el estándar en el mundo Java.

Añade la dependencia en `pom.xml`:

```xml
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>6.2.1</version>
</dependency>
```

Y reemplaza la clase `ConexionDB`:

```java
// ConexionDB.java — Versión con connection pool (producción)
// HikariCP es el pool más rápido y usado en Java empresarial

package com.ejemplo;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.SQLException;

public class ConexionDB {
    private static final HikariDataSource ds;

    // Bloque estático: se ejecuta UNA sola vez al cargar la clase
    static {
        HikariConfig config = new HikariConfig();

        // URL de conexión (igual que antes)
        config.setJdbcUrl("jdbc:mysql://localhost:3306/tienda_daw"
                        + "?useSSL=false&serverTimezone=UTC");
        config.setUsername("root");
        config.setPassword("tu_password_aqui");

        // Configuración del pool
        config.setMaximumPoolSize(10);     // Máx 10 conexiones simultáneas
        config.setMinimumIdle(2);          // Mínimo 2 conexiones listas
        config.setIdleTimeout(300000);     // 5 min sin uso → cerrar
        config.setConnectionTimeout(10000); // 10s máx esperando conexión

        ds = new HikariDataSource(config);
    }

    public static Connection getConnection() throws SQLException {
        return ds.getConnection(); // Devuelve una conexión del pool
    }

    // Cerrar el pool al apagar la app (importante en servidores)
    public static void close() {
        if (ds != null && !ds.isClosed()) {
            ds.close();
        }
    }
}
```

El `ProductoDAO` no cambia nada — sigue usando `ConexionDB.getConnection()`. Esa es la gracia del patrón DAO.

---

## Errores típicos que te darán el 0 en el examen

### 1. `Communications link failure`

**Causa:** MySQL no está corriendo o la URL está mal.

```bash
# Comprobar si MySQL está activo
# Linux/Mac:
sudo systemctl status mysql

# Windows:
net start MySQL80
```

### 2. `Access denied for user 'root'@'localhost'`

**Causa:** Contraseña incorrecta.

```sql
-- Cambiar la contraseña de root en MySQL
ALTER USER 'root'@'localhost' IDENTIFIED BY 'nueva_password';
FLUSH PRIVILEGES;
```

### 3. `No suitable driver found`

**Causa:** Falta el conector MySQL en las dependencias.

```bash
# Verificar que Maven descargó la dependencia
mvn dependency:tree | grep mysql
# Debería mostrar: com.mysql:mysql-connector-j:9.2.0
```

### 4. SQL Injection (esto es suspense directo)

```java
// ❌ NUNCA hagas esto (vulnerable a SQL Injection)
String sql = "SELECT * FROM productos WHERE nombre = '" + nombre + "'";
// Si alguien pone: ' OR 1=1; DROP TABLE productos; --
// Se ejecuta la sentencia maliciosa

// ✅ SIEMPRE usa PreparedStatement
String sql = "SELECT * FROM productos WHERE nombre = ?";
ps.setString(1, nombre); // Escapa automáticamente los caracteres peligrosos
```

### 5. Fugas de conexiones (Connection Leak)

```java
// ❌ Si falla algo antes del close(), la conexión se queda abierta
Connection conn = ConexionDB.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery();
// ... si aquí lanza una excepción, nunca se cierra
conn.close();

// ✅ try-with-resources cierra TODO automáticamente, incluso si falla
try (Connection conn = ConexionDB.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql);
     ResultSet rs = ps.executeQuery()) {
    // Si falla aquí, conn, ps y rs se cierran automáticamente
}
```

---

## Resumen rápido

| Concepto | Qué usar | Por qué |
|---|---|---|
| Conexión | `DriverManager` o `HikariCP` | DriverManager para aprender, HikariCP para producción |
| Consultas | `PreparedStatement` | Previene SQL injection, obligatorio |
| Recursos | `try-with-resources` | Cierra conexiones automáticamente |
| Patrón | DAO | Separa lógica de BBDD del resto |

---

*Si te has atascado con la conexión a base de datos o tu proyecto de DAW/DAM, prueba el [generador de prompts para debugging](/blog/herramientas/generador-prompts/) — pega el error y te crea el prompt perfecto para que una IA te lo solucione.*

*¿Buscas ideas para tu proyecto final? Aquí tienes [10 ideas de TFG para DAW y DAM](/blog/ideas-proyecto-final-daw-dam-2026/) con stack y tiempo estimado.*]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Stripe Webhooks con Next.js: Implementación Real Paso a Paso (2026)]]></title>
      <link>https://francobosg.netlify.app/blog/stripe-webhooks-nextjs-implementacion-2026/</link>
      <description><![CDATA[Cómo implementar Stripe Webhooks en Next.js correctamente: verificación de firma, manejo de eventos, idempotencia y testing en local con Stripe CLI.]]></description>
      <pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/stripe-webhooks-nextjs-implementacion-2026/</guid>
      <category>Next.js</category>
      <category>Backend</category>
      <category>Seguridad</category>
      <content:encoded><![CDATA[Stripe Webhooks es donde la mayoría de integraciones de pago fallan en producción. La verificación de firma, el raw body, la idempotencia — hay varios puntos donde meter la pata. Esta guía los cubre todos.

## Por qué los webhooks de Stripe son especiales

A diferencia de una petición normal, Stripe envía el evento firmado criptográficamente. Tu servidor debe:

1. **Recibir el body sin parsear** (raw Buffer, no JSON)
2. **Verificar la firma** con tu webhook secret
3. **Responder 200 rápido** (en menos de 30s) aunque proceses en background
4. **Ser idempotente** — el mismo evento puede llegar dos veces

Si fallas en cualquiera de estos puntos, los pagos quedan sin procesar sin que el usuario lo sepa.

---

## Setup en Next.js (App Router)

### Instalar Stripe

```bash
npm install stripe
```

### Configurar variables de entorno

```bash
# .env.local
STRIPE_SECRET_KEY=sk_test_...          # Del dashboard de Stripe → API Keys
STRIPE_WEBHOOK_SECRET=whsec_...        # Del dashboard → Webhooks → tu endpoint
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
```

### Crear el endpoint del webhook

```typescript
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// ⚠️ Crítico: deshabilita el body parsing de Next.js para esta ruta
export const runtime = 'nodejs'; // necesario para leer el raw body

export async function POST(request: NextRequest) {
  const body = await request.text(); // raw string, no JSON
  const signature = request.headers.get('stripe-signature');

  if (!signature) {
    return NextResponse.json({ error: 'No signature' }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // Procesa el evento
  try {
    await procesarEvento(event);
  } catch (err) {
    console.error(`Error procesando evento ${event.type}:`, err);
    // Devuelve 500 para que Stripe reintente
    return NextResponse.json({ error: 'Processing failed' }, { status: 500 });
  }

  return NextResponse.json({ received: true });
}
```

### Procesar eventos con idempotencia

```typescript
// lib/stripe-events.ts
import { prisma } from '@/lib/prisma';
import Stripe from 'stripe';

export async function procesarEvento(event: Stripe.Event) {
  // Idempotencia: si ya procesamos este evento, ignorar
  const eventoExistente = await prisma.stripeEvent.findUnique({
    where: { stripeEventId: event.id },
  });

  if (eventoExistente) {
    console.log(`Evento ${event.id} ya procesado, ignorando`);
    return;
  }

  // Registra el evento ANTES de procesarlo (evita race conditions)
  await prisma.stripeEvent.create({
    data: {
      stripeEventId: event.id,
      type: event.type,
      processedAt: new Date(),
    },
  });

  // Procesa según el tipo de evento
  switch (event.type) {
    case 'checkout.session.completed':
      await onCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
      break;

    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      await onSubscriptionChanged(event.data.object as Stripe.Subscription);
      break;

    case 'customer.subscription.deleted':
      await onSubscriptionCancelled(event.data.object as Stripe.Subscription);
      break;

    case 'invoice.payment_failed':
      await onPaymentFailed(event.data.object as Stripe.Invoice);
      break;

    default:
      console.log(`Evento no manejado: ${event.type}`);
  }
}
```

### Handlers de eventos reales

```typescript
async function onCheckoutCompleted(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId;
  
  if (!userId) {
    throw new Error(`Checkout sin userId en metadata: ${session.id}`);
  }

  await prisma.user.update({
    where: { id: userId },
    data: {
      plan: 'pro',
      stripeCustomerId: session.customer as string,
      stripeSubscriptionId: session.subscription as string,
    },
  });

  // Enviar email de bienvenida al plan pro
  // await enviarEmailBienvenidaPro(session.customer_details?.email);
  
  console.log(`Usuario ${userId} actualizado a plan pro`);
}

async function onSubscriptionCancelled(subscription: Stripe.Subscription) {
  await prisma.user.updateMany({
    where: { stripeSubscriptionId: subscription.id },
    data: {
      plan: 'free',
      stripeSubscriptionId: null,
    },
  });
}

async function onPaymentFailed(invoice: Stripe.Invoice) {
  const customerId = invoice.customer as string;
  
  // Buscar usuario por customerId de Stripe
  const usuario = await prisma.user.findFirst({
    where: { stripeCustomerId: customerId },
  });

  if (usuario) {
    // Notificar al usuario que actualice su método de pago
    // await enviarEmailPagoFallido(usuario.email);
    console.log(`Pago fallido para usuario ${usuario.id}`);
  }
}
```

---

## Schema de Prisma para idempotencia

```prisma
// schema.prisma
model StripeEvent {
  id            String   @id @default(cuid())
  stripeEventId String   @unique
  type          String
  processedAt   DateTime
  createdAt     DateTime @default(now())
}

model User {
  id                   String  @id @default(cuid())
  email                String  @unique
  plan                 String  @default("free")
  stripeCustomerId     String? @unique
  stripeSubscriptionId String? @unique
}
```

---

## Pasar userId a los webhooks: metadata

Stripe no sabe nada de tus usuarios. Tienes que pasar el `userId` al crear el checkout:

```typescript
// app/api/checkout/route.ts
import Stripe from 'stripe';
import { auth } from '@/lib/auth'; // tu sistema de auth

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: NextRequest) {
  const session = await auth(); // obtener sesión del usuario
  
  if (!session?.user?.id) {
    return NextResponse.json({ error: 'No autenticado' }, { status: 401 });
  }

  const checkout = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/precios`,
    metadata: {
      userId: session.user.id, // ← así lo recuperas en el webhook
    },
  });

  return NextResponse.json({ url: checkout.url });
}
```

---

## Testing en local con Stripe CLI

```bash
# Instalar Stripe CLI (Mac)
brew install stripe/stripe-cli/stripe

# Windows
scoop install stripe

# Login
stripe login

# Terminal 1 — reenvía eventos a tu servidor local
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Terminal 2 — dispara eventos de prueba
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
```

El CLI muestra en tiempo real qué eventos se envían y qué responde tu servidor.

---

## Checklist antes de ir a producción

- [ ] `STRIPE_WEBHOOK_SECRET` es el del endpoint de producción (no el del CLI local)
- [ ] El endpoint devuelve 200 antes de 30 segundos
- [ ] Tienes idempotencia implementada (tabla `StripeEvent`)
- [ ] Manejas `invoice.payment_failed` para degradar el plan
- [ ] El `userId` se pasa en `metadata` al crear el checkout
- [ ] Los logs de errores están configurados (Sentry, etc.)

Si combinas esto con [Auth.js para la autenticación](/blog/auth-js-nextauth-implementacion-real-2026/) y [variables de entorno correctamente configuradas](/blog/variables-de-entorno-nodejs-nextjs-guia-2026/), tienes la base de cualquier SaaS de pago. Puedes ver un caso real en [el artículo del SaaS con NestJS y React](/blog/caso-real-saas-atrapaclientes-nestjs-react/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Comandos Git Esenciales — Cheat Sheet para Desarrolladores 2026]]></title>
      <link>https://francobosg.netlify.app/blog/comandos-git-esenciales-2026/</link>
      <description><![CDATA[Los comandos Git que vas a usar el 99% del tiempo, explicados con ejemplos reales. Incluye resolución de conflictos, deshacer cambios, y los errores típicos de principiantes.]]></description>
      <pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/comandos-git-esenciales-2026/</guid>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Va a sonar duro, pero el 90% de los problemas que veo en proyectos de estudiantes tienen que ver con Git, no con el código. He recopilado los comandos que **realmente vas a usar** todos los días, sin la teoría que no necesitas ahora mismo.

> **TL;DR — Estructura de esta guía:**
> 1. Setup inicial (una sola vez)
> 2. Flujo diario (add → commit → push)
> 3. Ramas y merge
> 4. Deshacer cosas (sin entrar en pánico)
> 5. Los errores más comunes y cómo salir vivo

---

## Setup inicial (solo la primera vez)

Antes de nada, configura tu identidad. Estos datos aparecen en cada commit:

```bash
# Configurar nombre y email (obligatorio, se usa en cada commit)
git config --global user.name "Tu Nombre"
git config --global user.email "tu@email.com"

# Ver tu configuración actual
git config --list

# Iniciar un repositorio nuevo en la carpeta actual
git init

# O clonar uno existente (GitHub, GitLab, etc.)
git clone https://github.com/usuario/repo.git
```

---

## El flujo diario: add → commit → push

Esto es lo que vas a hacer 50 veces al día. Memorízalo:

```bash
# 1. Ver qué archivos has modificado
git status
# 🔴 rojo = modificado pero no añadido al staging
# 🟢 verde = añadido al staging, listo para commit

# 2. Añadir archivos al staging (área de preparación)
git add archivo.js          # Un archivo específico
git add src/                # Todo el contenido de una carpeta
git add .                   # TODO lo modificado (cuidado con esto)
git add -p                  # Seleccionar cambios línea por línea (modo pro)

# 3. Hacer commit (guardar snapshot del código)
git commit -m "feat: añadir login con JWT"
# El mensaje importa. Luego veremos la convención de commits.

# 4. Subir al remoto (GitHub/GitLab)
git push origin main
# origin = nombre del remoto (por defecto)
# main = la rama que quieres subir
```

### Convención de mensajes de commit

Los mensajes random tipo "cambios" o "asdf" dan mala imagen. Usa este formato:

```bash
# Formato: tipo: descripción breve
git commit -m "feat: añadir sistema de registro"
git commit -m "fix: corregir error en cálculo de precio"
git commit -m "docs: actualizar README con instrucciones"
git commit -m "style: formatear código con Prettier"
git commit -m "refactor: extraer lógica de validación"
git commit -m "test: añadir tests para servicio de pagos"
```

| Tipo | Cuándo usarlo |
|---|---|
| `feat` | Nueva funcionalidad |
| `fix` | Corregir un bug |
| `docs` | Solo documentación |
| `style` | Formato, sin cambiar lógica |
| `refactor` | Reestructurar sin cambiar comportamiento |
| `test` | Añadir o corregir tests |
| `chore` | Tareas de mantenimiento (deps, config) |

---

## Ramas: trabaja sin miedo

Las ramas te permiten desarrollar funcionalidades sin romper el código principal. En cualquier proyecto de más de una persona, son **obligatorias**.

```bash
# Ver todas las ramas (* = rama actual)
git branch

# Crear una rama nueva y moverte a ella
git checkout -b feature/login
# Equivalente moderno:
git switch -c feature/login

# Cambiar de rama
git checkout main
git switch main           # Equivalente moderno

# Ver ramas remotas también
git branch -a

# Eliminar una rama (local, ya mergeada)
git branch -d feature/login

# Eliminar una rama (forzar, aunque no esté mergeada)
git branch -D feature/login-fallida
```

### Merge: unir ramas

```bash
# 1. Moverte a la rama que va a RECIBIR los cambios
git checkout main

# 2. Hacer merge de la otra rama
git merge feature/login
# Si no hay conflictos → merge automático ✅
# Si hay conflictos → Git te avisa y tú los resuelves
```

---

## Resolver conflictos de merge (sin pánico)

Los conflictos pasan cuando dos personas tocan las mismas líneas. Git no sabe cuál elegir y te pide que decidas tú.

```bash
# Después de un merge con conflictos, Git te muestra:
# CONFLICT (content): Merge conflict in archivo.js
# Automatic merge failed; fix conflicts and then commit.
```

Abre el archivo en conflicto. Verás algo así:

```javascript
function calcularPrecio(cantidad) {
<<<<<<< HEAD
    // Tu código (rama actual)
    return cantidad * 19.99;
=======
    // Código del compañero (rama que estás mergeando)
    return cantidad * 24.99 * 0.9; // con descuento
>>>>>>> feature/precios
}
```

**Qué hacer:**
1. Elige qué versión mantener (o combina ambas)
2. Elimina los marcadores `<<<<<<<`, `=======`, `>>>>>>>`
3. Guarda el archivo

```bash
# Después de resolver todos los conflictos:
git add archivo.js        # Marcar como resuelto
git commit                # Git sugiere un mensaje de merge automático
```

---

## Deshacer cosas (la parte que más necesitas)

### Deshacer cambios NO commiteados

```bash
# Descartar cambios en un archivo (volver a la versión del último commit)
git checkout -- archivo.js
# Equivalente moderno:
git restore archivo.js

# Quitar un archivo del staging (después de git add)
git reset HEAD archivo.js
# Equivalente moderno:
git restore --staged archivo.js
```

### Deshacer commits

```bash
# Deshacer el último commit, MANTENER los cambios en staging
git reset --soft HEAD~1
# HEAD~1 = "un commit atrás". HEAD~3 = "tres commits atrás"

# Deshacer el último commit, MANTENER los cambios sin staging
git reset --mixed HEAD~1   # (--mixed es el default)

# ⚠️ Deshacer el último commit Y BORRAR los cambios (PELIGROSO)
git reset --hard HEAD~1
# Si haces esto por error: git reflog → buscar el hash → git reset --hard <hash>

# Cambiar el mensaje del último commit (solo si NO has hecho push)
git commit --amend -m "mensaje corregido"
```

### Crear un commit que "deshace" otro (seguro para código publicado)

```bash
# Si ya hiciste push y necesitas deshacer, usa revert
git revert HEAD
# Crea un commit NUEVO que revierte los cambios del último
# No reescribe historia → seguro para equipos
```

---

## Comandos de inspección

```bash
# Ver historial de commits
git log --oneline --graph
# --oneline = una línea por commit (más legible)
# --graph = muestra visualmente las ramas

# Ver cambios entre tu código y el último commit
git diff
git diff --staged        # Solo cambios en staging

# Ver quién modificó cada línea (para cazar bugs)
git blame archivo.js

# Buscar un texto en todo el historial
git log -S "nombreFuncion" --oneline
```

---

## Stash: guardar cambios temporalmente

A veces necesitas cambiar de rama pero tienes cambios sin commitear. Stash los guarda aparte:

```bash
# Guardar cambios actuales en el stash
git stash

# Ver la lista de stashes guardados
git stash list
# stash@{0}: WIP on feature/login: abc1234 último commit

# Recuperar los cambios guardados
git stash pop           # Aplica y elimina el stash
git stash apply         # Aplica pero mantiene el stash guardado

# Eliminar stash sin aplicarlo
git stash drop stash@{0}
```

---

## .gitignore: archivos que NO deben subirse

Crea un archivo `.gitignore` en la raíz del proyecto:

```bash
# Dependencias (se regeneran con npm install / mvn install)
node_modules/
target/

# Variables de entorno (contraseñas, API keys)
.env
.env.local

# Archivos del IDE
.idea/
.vscode/
*.iml

# Compilados y builds
dist/
build/
*.class

# Sistema operativo
.DS_Store
Thumbs.db
desktop.ini
```

**Si ya commiteaste un archivo que debería estar ignorado:**

```bash
# Dejar de trackear un archivo (sin borrarlo del disco)
git rm --cached .env
git commit -m "chore: dejar de trackear .env"
```

---

## Errores típicos que te darán el 0 en el examen

### 1. `fatal: not a git repository`

**Causa:** No estás dentro de un repositorio Git.

```bash
# Solución: inicializar o ir a la carpeta correcta
git init          # Si es un proyecto nuevo
cd mi-proyecto/   # Si estás en la carpeta equivocada
```

### 2. `error: failed to push some refs`

**Causa:** El remoto tiene commits que tú no tienes.

```bash
# Solución: traer los cambios primero
git pull origin main --rebase
# Luego volver a push
git push origin main
```

### 3. `fatal: refusing to merge unrelated histories`

**Causa:** Intentas mergear dos repos que no comparten historial.

```bash
# Solución (solo si estás seguro de que quieres unirlos)
git pull origin main --allow-unrelated-histories
```

### 4. Subir `node_modules/` o `.env` al repositorio

Esto es un **error de seguridad** si subes `.env` (contraseñas expuestas). Y `node_modules/` puede tener miles de archivos innecesarios.

```bash
# Arreglar: añadir al .gitignore y dejar de trackear
echo "node_modules/" >> .gitignore
echo ".env" >> .gitignore
git rm -r --cached node_modules/
git rm --cached .env
git commit -m "chore: eliminar archivos que no deberían trackearse"
```

### 5. Hacer `push --force` a main

```bash
# ❌ NUNCA hagas esto en una rama compartida
git push --force origin main
# Sobreescribe el historial del remoto
# Los commits de tus compañeros desaparecen

# ✅ Si necesitas forzar, usa force-with-lease (más seguro)
git push --force-with-lease origin main
# Solo fuerza si nadie más ha pusheado mientras tanto
```

---

## Cheat sheet resumido

| Qué quieres hacer | Comando |
|---|---|
| Ver estado | `git status` |
| Añadir cambios | `git add .` |
| Hacer commit | `git commit -m "msg"` |
| Subir cambios | `git push origin main` |
| Bajar cambios | `git pull origin main` |
| Crear rama | `git switch -c feature/x` |
| Cambiar rama | `git switch main` |
| Mergear | `git merge feature/x` |
| Deshacer commit | `git reset --soft HEAD~1` |
| Guardar temporal | `git stash` |
| Historial | `git log --oneline --graph` |

---

*Si te peleaste con un error de Git que no sale aquí, pégalo en el [traductor de errores](/blog/herramientas/traductor-errores/) — te dice qué significa y cómo solucionarlo en español.*

*¿Buscas ideas para tu TFG? Mira las [10 mejores ideas de proyecto final para DAW y DAM](/blog/ideas-proyecto-final-daw-dam-2026/) con stack recomendado.*]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Guía para Estudiantes de DAW, DAM, SMR y ASIR (2026)]]></title>
      <link>https://francobosg.netlify.app/blog/guia-estudiantes-daw-dam-smr-2026/</link>
      <description><![CDATA[¿Estudias DAW, DAM o SMR y no sabes por dónde empezar? Qué piden las empresas en 2026, qué aprender primero y cómo usar la IA sin perder fundamentos.]]></description>
      <pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/guia-estudiantes-daw-dam-smr-2026/</guid>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Llevo trabajando en el sector tech desde que terminé mis estudios, y si hay algo que me habría gustado que me dijeran cuando estaba en clase es esto: **el título no es suficiente**. Lo que te enseñan en DAW, DAM, SMR, ASIR o cualquier ciclo de informática es una base, pero lo que te separa del resto es tu actitud, tu capacidad de aprender por tu cuenta y cómo te adaptas a lo que viene.

Y lo que viene, en 2026, es la inteligencia artificial. No como amenaza. Como **herramienta**.

Este artículo es la guía que me habría gustado tener. Sin rodeos, sin vender humo. Solo lo que funciona.

> **TL;DR — 5 ideas clave:**
> 1. Los fundamentos (lógica, estructuras de datos, patrones) NO son opcionales — sin ellos la IA no te salva.
> 2. La IA no quita trabajos: cambia cómo trabajamos. Las empresas buscan gente que sepa usarla.
> 3. Lo que más valoran las empresas en juniors: resolver problemas solo, actitud y portfolio real.
> 4. Regla 70/20/10: 70% práctica, 20% comunidad, 10% teoría.
> 5. Haz proyectos que impresionen: no otro clon de TODO app — resuelve problemas reales.

---

## 📌 Primero: ¿qué ciclos de informática existen?

Si estás leyendo esto probablemente ya conozcas el tuyo, pero aquí tienes el mapa completo:

### Grado Medio

| Ciclo | Nombre completo | Enfoque |
|---|---|---|
| **SMR** | Sistemas Microinformáticos y Redes | Redes, sistemas operativos, hardware, soporte IT |

### Grado Superior

| Ciclo | Nombre completo | Enfoque |
|---|---|---|
| **DAW** | Desarrollo de Aplicaciones Web | Frontend, backend, bases de datos, despliegue web |
| **DAM** | Desarrollo de Aplicaciones Multiplataforma | Apps móviles, escritorio, APIs, multiplataforma |
| **ASIR** | Administración de Sistemas Informáticos en Red | Servidores, cloud, virtualización, seguridad, automatización |

### Cursos de Especialización (post-grado superior)

| Ciclo | Enfoque |
|---|---|
| **Ciberseguridad en Entornos de las TI** | Pentesting, hardening, análisis forense, respuesta a incidentes |
| **IA y Big Data** | Machine learning, procesamiento de datos, modelos predictivos |

Todos son **Formación Profesional**, y todos tienen una demanda brutal en el mercado laboral español. Según datos de InfoJobs y LinkedIn de 2026:

- El **87% de ofertas tech** en España no requieren carrera universitaria
- Los perfiles de FP en informática tienen una **tasa de empleo superior al 80%** antes de los 6 meses
- El salario medio de un junior con DAW/DAM/ASIR ronda los **20.000-26.000€** brutos en su primer año

Pero ojo: estos datos son para gente que **se mueve**. No para los que esperan que el título haga todo el trabajo.

---

## 🧠 Los fundamentos NO son opcionales

Sé que suena aburrido. Sé que quieres aprender React, o hacer una app con IA, o montar un SaaS. Pero escúchame:

**Si no entiendes los fundamentos, la IA te va a usar a ti en vez de tú usarla a ella.**

¿Por qué? Porque la IA genera código. Mucho código. Y si no tienes criterio para evaluarlo, estarás copiando y pegando sin entender qué hace. Eso funciona hasta que algo se rompe — y siempre se rompe.

### Lo que DEBES dominar sí o sí

No importa si haces DAW, DAM, SMR o ASIR. Esto es lo básico:

**1. Lógica de programación**
- Variables, bucles, condicionales, funciones
- Entender qué hace un `for` sin tener que ejecutarlo
- Saber leer código ajeno (esto es el 70% del trabajo real)

**2. Estructuras de datos**
- Arrays, objetos, listas, pilas, colas
- Cuándo usar cada una y por qué
- No hace falta que te sepas la complejidad algorítmica de memoria, pero sí entender que un array de 1 millón de elementos no se recorre con 3 `forEach` anidados

**3. Bases de datos**
- SQL básico: `SELECT`, `JOIN`, `WHERE`, `GROUP BY`
- Diferencia entre SQL y NoSQL
- Modelado de datos (saber diseñar tablas con sentido)

**4. Terminal / línea de comandos**
- Navegar por el sistema de archivos
- `git` básico (clone, add, commit, push, pull, branch)
- Ejecutar scripts, instalar paquetes, leer logs de error

**5. Redes y HTTP**
- Qué es una petición HTTP (GET, POST, PUT, DELETE)
- Qué es un servidor, un dominio, un DNS
- Cómo funciona HTTPS y por qué importa

> **💡 Regla de oro**: si no puedes explicar algo con tus propias palabras sin mirar apuntes, **no lo entiendes**. Y si no lo entiendes, la IA no te va a salvar.

---

## 🤖 La IA no te va a quitar el trabajo (pero sí lo va a cambiar)

Este es el elefante en la habitación. En Twitter, en TikTok, en los grupos de clase, en todas partes:

> *"La IA va a sustituir a los programadores"*
> *"Ya no hace falta aprender a programar"*
> *"En 2 años no habrá devs"*

**Esto es mentira.** Y te lo digo con datos, no con opinión:

### ¿Qué dicen los datos reales?

- **GitHub** reportó en 2025 que los desarrolladores que usan Copilot completan tareas un **55% más rápido** — pero siguen siendo desarrolladores humanos los que deciden QUÉ hacer
- **Stack Overflow Survey 2025**: el 92% de desarrolladores profesionales usan herramientas de IA, pero solo el 3% dice que podrían ser reemplazados completamente
- **LinkedIn Jobs**: las ofertas de empleo tech crecieron un **12% en 2025** en España, incluso con la adopción masiva de IA

### ¿Por qué la IA no reemplaza programadores?

Piénsalo así:

```
Sin IA: 1 dev → 1 proyecto en 3 meses
Con IA: 1 dev → 1 proyecto en 1 mes (o 3 proyectos en 3 meses)
```

**La IA no elimina devs. Multiplica la productividad.**

Las empresas no piensan *"genial, ahora necesito menos devs"*. Piensan *"genial, ahora mis devs pueden hacer 3x más"*. Y eso significa que quieren **más** proyectos, **más** features, **más** productos. La demanda crece, no se reduce.

¿Sabes qué pasó cuando llegaron los frameworks como React o Django? ¿Desaparecieron los desarrolladores? No. Se multiplicaron. Porque hacer webs se volvió más fácil, más empresas quisieron tener presencia online y se necesitó más gente.

Con la IA pasa exactamente lo mismo, pero a mayor escala.

### Lo que SÍ va a pasar

- **Desaparecerán los devs que solo copian y pegan** (ya sean de Stack Overflow o de ChatGPT)
- **Crecerá la demanda de devs que piensan**, que tienen criterio, que entienden el negocio
- La IA será una **herramienta más** en tu arsenal, como lo es VS Code, Git o Google

**El dev del futuro no compite con la IA. Trabaja CON la IA.**

---

## 🔧 Cómo aprender en 2026 (de verdad)

El error más común que veo en estudiantes de DAW/DAM/SMR/ASIR es estudiar como si fuera 2015. Apuntes, memorizar, hacer el ejercicio exacto del libro. Eso ya no funciona.

### La regla 70/20/10

Así es como realmente se aprende en tech:

- **70%** → Haciendo proyectos reales (sí, aunque sean pequeños y feos)
- **20%** → Leyendo código de otros (GitHub, proyectos open source, repos de empresas)
- **10%** → Cursos, vídeos, documentación

Nota: la clase cuenta como parte del 10%. Los apuntes te dan contexto, pero **no te enseñan a programar**. Programar se aprende programando.

### Proyectos que SÍ impresionan en una entrevista

No necesitas hacer el próximo Instagram. Necesitas demostrar que **sabes resolver problemas**:

| Proyecto | Qué demuestra | Dificultad |
|---|---|---|
| Portfolio personal con blog | HTML/CSS, deploy, dominio propio | ⭐ |
| API REST con autenticación | Backend, bases de datos, seguridad | ⭐⭐ |
| Clon simplificado de Trello/Notion | Frontend + backend + persistencia | ⭐⭐⭐ |
| Bot de Discord/Telegram | APIs, webhooks, lógica async | ⭐⭐ |
| CLI tool que resuelva un problema tuyo | Node.js/Python, argumentos, file system | ⭐⭐ |
| Contribución a un proyecto open source | Git avanzado, trabajo en equipo, leer código | ⭐⭐⭐ |

> **💡 Pro tip**: pon TODOS tus proyectos en GitHub con un buen README. El 80% de recruiters miran tu GitHub antes de llamarte.

### Cómo usar la IA para aprender (sin que te haga trampas)

La IA es el mejor tutor personalizado que existe, **si la usas bien**. Aquí van las reglas:

**✅ BIEN:**
- Pedirle que te **explique** un concepto que no entiendes
- Usarla para **debuggear** errores mostrándole el error completo
- Que te **revise** código que TÚ escribiste y te diga cómo mejorarlo
- Generar **tests** para tu código y ver si pasan
- Que te **plantee ejercicios** del tema que estás estudiando

**❌ MAL:**
- Pedirle que te haga la práctica entera
- Copiar y pegar sin leer lo que genera
- Usarla en exámenes como sustituto de entender
- No intentar resolver el problema antes de preguntarle
- Asumir que lo que genera es correcto (spoiler: a veces no lo es)

**La regla de los 20 minutos**: antes de preguntarle a la IA, intenta resolverlo tú solo durante 20 minutos. Busca en Google, lee documentación, prueba cosas. Si después de 20 minutos no avanzas, ahí sí — pregunta. Pero pregunta **bien**:

```
❌ "Hazme un login en React"

✅ "Estoy haciendo un login en React con useState. 
    El formulario envía los datos pero no me redirige 
    después del login. Este es mi código: [código]. 
    ¿Qué puede estar fallando?"
```

La diferencia entre un junior que crece rápido y uno que se estanca es **la calidad de sus preguntas**.

---

## 🏢 Qué buscan las empresas en 2026 (de verdad)

He hablado con recruiters, CTOs y leads de equipo. Esto es lo que buscan en un perfil junior, por orden de importancia:

### 1. 🧩 Capacidad de resolver problemas

No te van a preguntar la sintaxis de un `map()` en una entrevista (y si lo hacen, sal corriendo). Lo que quieren saber es:

- **¿Cómo piensas?** Cuando te encuentras un bug, ¿te quedas paralizado o empiezas a investigar?
- **¿Sabes buscar?** Google, documentación oficial, GitHub issues. El 90% de los problemas que te vas a encontrar ya los ha tenido alguien antes
- **¿Pides ayuda cuando toca?** No después de 5 minutos, no después de 3 días. Hay un punto medio

### 2. 🔄 Adaptabilidad

La empresa usa React pero tú aprendiste Angular. ¿Problema? **No debería serlo.**

Si entiendes los fundamentos (componentes, estado, props, ciclo de vida), cambiar de framework es cuestión de 1-2 semanas. Las empresas lo saben. No buscan que sepas SU stack exacto. Buscan que seas capaz de **aprenderlo rápido**.

Esto se demuestra:
- Teniendo proyectos en **más de una tecnología** en tu GitHub
- Mostrando que has **aprendido cosas por tu cuenta** fuera del temario
- Teniendo curiosidad genuina (no fingida en la entrevista)

### 3. 💬 Comunicación

Suena raro, pero la habilidad #1 que falta en juniors técnicos es **saber comunicar**:

- Explicar un problema técnico a alguien no técnico
- Escribir mensajes claros en Slack/Teams/email
- Documentar tu código (aunque sea un README decente)
- Pedir ayuda de forma concreta, no con un *"no me funciona"*

> 💡 **Ejercicio**: la próxima vez que tengas un bug, escríbelo como si fuera un issue de GitHub. Título, descripción del problema, pasos para reproducirlo, qué esperabas que pasara, qué pasa realmente. Si haces esto como hábito, vas a destacar inmediatamente en cualquier equipo.

### 4. 🤖 Productividad con herramientas de IA

En 2026, **saber usar IA no es opcional**. Las empresas ya dan por hecho que usas herramientas como:

- **GitHub Copilot** o **Cursor** para escribir código
- **ChatGPT** o **Claude** para investigar, debuggear o diseñar soluciones
- **Herramientas de automatización** como n8n o Make para tareas repetitivas

Lo que quieren ver es que seas **productivo**. Que una tarea que antes llevaba 4 horas, la hagas en 1. No porque seas un genio, sino porque sabes usar las herramientas.

### 5. 📂 Portfolio tangible

Un GitHub con proyectos reales vale más que un currículum de 3 páginas. Lo mínimo:

- **GitHub actualizado** con al menos 3-5 repos con buen README
- **LinkedIn** al día con tu stack y proyectos
- **Portfolio web** (bonus points si lo hiciste tú)
- Al menos **1 proyecto desplegado** que alguien pueda ver en vivo

---

## 🚀 Ser resolutivo: la habilidad que nadie enseña

Esto no viene en ningún módulo del ciclo, pero es literalmente lo que más va a determinar tu carrera:

**Ser resolutivo = encontrar la manera de que algo funcione, aunque no sepas cómo al principio.**

### La mentalidad del solucionador

Cuando te encuentras un error:

```
Mentalidad de "víctima":
"No funciona" → pido ayuda → me lo resuelven → sigo

Mentalidad resolutiva:
"No funciona" → leo el error → busco en Google → pruebo 3 cosas 
→ si no funciona, formulo una pregunta concreta → aprendo por qué 
fallaba → lo documento para no repetirlo
```

La diferencia no es inteligencia. Es **proceso**.

### El framework para resolver cualquier problema

1. **Lee el error completo**. No solo la última línea. Todo. El 80% de los errores te dicen exactamente qué falla
2. **Aísla el problema**. ¿Falla el frontend? ¿El backend? ¿La base de datos? ¿La red? Divide y vencerás
3. **Busca el error literal en Google**. Copia y pega el mensaje de error. Alguien ya lo ha tenido
4. **Mira la documentación oficial**. No un tutorial de 2019. La documentación del framework/librería que estés usando
5. **Pregunta con contexto**. Si después de 20 minutos no avanzas, pregunta — pero dale contexto a quien preguntes (o a la IA)

### Errores comunes de estudiantes

| Error | Por qué es un problema | Solución |
|---|---|---|
| *"No me funciona"* (sin más) | Nadie puede ayudarte si no describes el problema | Siempre describe: qué hiciste, qué esperabas, qué pasó |
| Copiar código sin entenderlo | Funciona hoy, explota mañana | Lee cada línea. Si no entiendes una, búscala |
| Rendirse al primer error | Los errores SON el trabajo | Cada error resuelto es experiencia ganada |
| Solo seguir tutoriales | Sabes replicar, no crear | Modifica el tutorial: cambia features, añade cosas |
| No usar Git desde el principio | Pierdes código, no puedes mostrar tu trabajo | `git init` en todo. Siempre. Desde el día 1 |

---

## 📱 Las tecnologías que importan en 2026

No te digo que aprendas todas. Te digo que entiendas **el mapa** para que puedas elegir tu camino:

### Si vas por web (DAW)

```
Frontend:  HTML + CSS + JavaScript → React o Vue o Svelte
Backend:   Node.js (Express/Fastify) o Python (Django/FastAPI)
Base datos: PostgreSQL + Redis
DevOps:    Git + Docker + Netlify/Vercel
IA:        GitHub Copilot + ChatGPT/Claude para día a día
```

### Si vas por multiplataforma (DAM)

```
Móvil:     Kotlin (Android) o Swift (iOS) o Flutter/React Native
Backend:   Java (Spring Boot) o Node.js o Python
Base datos: PostgreSQL + Firebase/Supabase
DevOps:    Git + Docker + CI/CD básico
IA:        Copilot + modelos locales para features on-device
```

### Si vas por sistemas (SMR)

```
Sistemas:  Linux (Ubuntu/Debian) + Windows Server
Redes:     TCP/IP, DNS, DHCP, VPN, firewalls
Cloud:     AWS o Azure (certificaciones = oro)
Scripting: Bash + PowerShell + Python
IA:        Automatización con scripts + ChatGPT para troubleshooting
```

### Si vas por administración de sistemas (ASIR)

```
Servidores: Linux Server + Windows Server + Active Directory
Cloud:      AWS / Azure / GCP (al menos uno con certificación)
Contenedores: Docker + Kubernetes básico
Automatización: Ansible + Terraform + scripts Bash/Python
Monitorización: Prometheus + Grafana, ELK Stack
IA:         ChatGPT para troubleshooting + scripts de automatización con IA
```

### Si vas por ciberseguridad (Especialización)

```
Ofensivo:   Kali Linux, Burp Suite, Metasploit, OWASP
Defensivo:  SIEM (Splunk/Wazuh), firewalls, IDS/IPS
Forense:    Autopsy, Volatility, análisis de logs
Redes:      Wireshark, Nmap, tcpdump
IA:         Detección de amenazas con ML + IA para análisis de logs
```

### Si vas por IA y Big Data (Especialización)

```
Lenguajes:  Python (obligatorio) + SQL
ML/DL:      scikit-learn, TensorFlow/PyTorch
Datos:      Pandas, NumPy, Apache Spark
Cloud:      AWS SageMaker / Azure ML / Google Vertex AI
MLOps:      MLflow, Docker, CI/CD para modelos
IA:         Aquí la IA ES tu campo — domínala al máximo
```

> **💡 Nota importante**: no intentes aprender TODO. Elige UNA ruta, profundiza, y cuando la domines, amplía. Un junior que sabe React bien vale más que uno que "sabe" React, Vue, Angular, Svelte pero no domina ninguno.

---

## 🎯 Plan de acción concreto

No quiero que cierres este artículo y sigas igual. Aquí tienes un plan de 30 días para empezar a diferenciarte:

### Semana 1: Fundamentos y setup
- [ ] Instala VS Code + extensiones esenciales (Copilot, Prettier, GitLens)
- [ ] Crea una cuenta en GitHub (si no la tienes) y haz tu primer repo
- [ ] Haz un proyecto simple sin IA: una calculadora, un to-do list, lo que sea. Entiende cada línea
- [ ] Aprende los 10 comandos básicos de Git

### Semana 2: Proyecto real
- [ ] Elige un proyecto de la tabla de arriba y empieza
- [ ] Usa Git desde el minuto 1 (commits pequeños y descriptivos)
- [ ] Cuando te atasques, usa la regla de los 20 minutos antes de pedir ayuda
- [ ] Documenta tu proceso: qué problemas encontraste y cómo los resolviste

### Semana 3: IA como herramienta
- [ ] Configura GitHub Copilot (es gratis para estudiantes con el [GitHub Student Pack](https://education.github.com/pack))
- [ ] Usa ChatGPT o Claude para que te **explique** partes de tu código que no entiendas
- [ ] Pídele que te **revise** tu código y sugiera mejoras
- [ ] Genera tests automáticos para tu proyecto y asegúrate de que pasen

### Semana 4: Visibilidad
- [ ] Sube tu proyecto a GitHub con un README completo (descripción, screenshots, cómo instalarlo)
- [ ] Despliega tu proyecto en algún sitio gratuito (Netlify, Vercel, Railway)
- [ ] Actualiza tu LinkedIn con tu stack y proyecto
- [ ] Escribe un post en LinkedIn contando qué aprendiste (esto impresiona más de lo que crees)

---

## 💪 La verdad que nadie te cuenta

El sector tech es uno de los pocos donde **no importa de dónde vienes**. No importa si hiciste una carrera de 4 años o un FP de 2. No importa si aprendiste en una academia o de forma autodidacta.

Lo que importa es:
- **¿Sabes resolver problemas?**
- **¿Puedes aprender cosas nuevas rápido?**
- **¿Eres productivo?**
- **¿Puedes trabajar en equipo?**

Eso es todo. Y todo eso se puede aprender y practicar.

Así que no te rindas. No dejes que el síndrome del impostor te paralice. No dejes que los titulares catastrofistas sobre la IA te asusten. 

La IA es una herramienta. Aprende a usarla. Pero primero, aprende los fundamentos. Porque las herramientas cambian cada 2 años, pero **la capacidad de pensar y resolver problemas dura toda la vida**.

---

## 🔗 Recursos que te recomiendo

- [GitHub Student Developer Pack](https://education.github.com/pack) — Copilot gratis + muchas herramientas más
- [freeCodeCamp](https://www.freecodecamp.org) — Cursos gratuitos de desarrollo web completos
- [The Odin Project](https://www.theodinproject.com) — Ruta de aprendizaje web full-stack gratuita
- [roadmap.sh](https://roadmap.sh) — Mapas visuales de qué aprender para cada rol tech
- [Mi artículo sobre herramientas de IA](/blog/herramientas/) — Las mejores herramientas de IA organizadas por categoría
- [Cómo hice mi portfolio con Vite y Tailwind](/blog/como-hice-mi-portfolio-vite-tailwind/) — Inspiración para tu primer portfolio
- [Los mejores modelos de IA para programar](/blog/mejores-modelos-ia-para-programar-2026/) — Qué IA usar para aprender a programar más rápido
- [Los 20 mejores prompts para programar con IA](/blog/mejores-prompts-programar-ia-2026/) — Prompts que te ayudarán en tus proyectos
- [10 ideas de TFG para DAW y DAM](/blog/ideas-proyecto-final-daw-dam-2026/) — Con stack, dificultad y código de ejemplo
- [Conectar Java con MySQL (JDBC)](/blog/conectar-java-mysql-jdbc-tutorial-2026/) — Tutorial paso a paso con CRUD completo
- [Comandos Git esenciales](/blog/comandos-git-esenciales-2026/) — Cheat sheet con todo lo que necesitas para tus proyectos

---

*¿Eres estudiante y tienes dudas? Lee [mi historia como developer](/blog/sobre-mi-fran-cobos-desarrollador-fullstack-ia/) — empecé igual que tú, con SMR y DAW. O pásate por mi [portfolio](/) y escríbeme. Siempre intento responder.*]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Inglés para Programadores: Guía Práctica para Developers (2026)]]></title>
      <link>https://francobosg.netlify.app/blog/aprender-ingles-para-developers-2026/</link>
      <description><![CDATA[¿Developer que no domina el inglés? Plan realista con shadowing, Free4Talk, Discord y recursos técnicos para mejorar tu nivel en 3 meses. Sin apps inútiles.]]></description>
      <pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/aprender-ingles-para-developers-2026/</guid>
      <category>Productividad</category>
      <category>Soft Skills</category>
      <content:encoded><![CDATA[Si eres developer y solo hablas español, estás trabajando con una mano atada a la espalda. **Punto.**

En 2026, la IA traduce casi todo. Pero la comunicación en tiempo real y el acceso a la documentación original siguen siendo la diferencia entre un desarrollador promedio y uno de élite — con sueldo internacional.

He decidido dejar de poner *"Inglés: Nivel Medio"* en mi CV y tomármelo en serio. Aquí te cuento **exactamente** cómo estoy hackeando mi aprendizaje.

> **TL;DR — El plan en 4 pilares:**
> 1. **Clases 1:1** con profesor (base gramatical y corrección).
> 2. **Shadowing** 15-20 min/día (pronunciación y comprensión).
> 3. **Inmersión real** con Free4Talk y Discord (conversación sin salir de casa).
> 4. **Cambiar el idioma de todo**: móvil, IDE, docs, YouTube.
> Resultado: mejora notable en 3 meses con consistencia diaria.

---

## Por qué el inglés sigue importando (aunque la IA traduzca)

Antes de entrar en técnicas, hablemos de la realidad:

| Situación | ¿La IA te salva? |
|-----------|:-----------------:|
| Leer documentación estática | ✅ Sí |
| Entrevista técnica en directo | ❌ No |
| Pair programming con equipo remoto | ❌ No |
| Participar en un issue de GitHub | ⚠️ A medias |
| Entender un podcast técnico | ❌ No |
| Negociar tu salario en inglés | ❌ No |

La conclusión es clara: **la IA cubre la lectura, pero no cubre la vida profesional real**.

### Los números hablan

- El **75% de la documentación técnica** se publica primero (o exclusivamente) en inglés.
- Las ofertas remotas internacionales pagan entre **2x y 5x** más que el mercado local en LATAM/España.
- Los mejores cursos, conferencias y comunidades operan en inglés.

No se trata de abandonar el español — se trata de **sumar un superpoder** a tu stack.

---

## Mi plan de ataque: 4 pilares

### 1. Clases con profesor particular 👨‍🏫

No hay trucos mágicos. Tengo sesiones **1:1** para pulir el speaking y, sobre todo, **perder el miedo a soltar código en inglés**.

Tener a alguien que corrija tus vicios de lenguaje al momento es una inversión, no un gasto.

**Lo que busco en un profesor:**
- Que entienda (o al menos tolere) jerga técnica.
- Que me corrija la pronunciación **en el momento**, no al final.
- Que no me ponga a rellenar huecos en un libro — quiero conversación real.

> **Tip:** Si no puedes pagar un profesor, plataformas como *iTalki* o *Preply* tienen tutores desde 5-10 $/hora que son más que suficientes para empezar.

---

### 2. Técnica de Shadowing 🗣️

Es mi ejercicio favorito. **Escucho podcasts técnicos o charlas y repito exactamente lo que dicen**, imitando la entonación y velocidad.

Es la mejor forma de que tu cerebro se acostumbre a los **fonemas que no existen en español** (como la *th*, la *v/b* diferenciada, o los sonidos vocálicos que en español no distinguimos).

#### Cómo hago shadowing paso a paso

1. **Elijo un fragmento corto** (2-3 minutos) de un podcast o charla de YouTube.
2. **Primera escucha**: solo escucho, sin repetir.
3. **Segunda escucha**: repito en voz alta *al mismo tiempo* que el audio.
4. **Tercera pasada**: bajo el volumen del audio y subo mi voz.
5. **Grabación**: me grabo y comparo con el original.

#### Podcasts y canales que uso para shadowing

| Recurso | Nivel | Por qué lo recomiendo |
|---------|-------|----------------------|
| **Syntax.fm** | Intermedio-Alto | JavaScript, web dev, ritmo natural |
| **The Changelog** | Alto | Open source, entrevistas profundas |
| **Fireship (YouTube)** | Intermedio | Vídeos cortos con vocabulario técnico denso |
| **Traversy Media** | Intermedio | Tutoriales claros con buen ritmo |
| **ThePrimeagen** | Alto | Jerga real de dev, muy natural |

> **Tiempo mínimo:** 15-20 minutos al día. La consistencia importa más que la duración.

---

### 3. Inmersión real (sin salir de casa) 🌐

Aquí es donde la cosa se pone interesante. No necesitas vivir en un país angloparlante para tener inmersión:

#### Free4Talk

[Free4Talk](https://www.free4talk.com/) es una plataforma gratuita con salas de voz temáticas. Entro a hablar con gente de todo el mundo. **Sin filtros, sin guion, sin red de seguridad.**

**Mi rutina:**
- Entro 2-3 veces por semana, 20-30 minutos.
- Elijo salas de "Technology" o "General" (las de tech son más pequeñas → más oportunidad de hablar).
- No me preocupo por los errores — el objetivo es **soltar la lengua**, no dar una charla TED.

#### Comunidades de Discord

Estoy metido en servidores de nicho donde el audio-chat es en inglés. **Hablar de lo que te apasiona hace que te olvides de que estás "estudiando".**

Algunos servidores que recomiendo:

- **The Coding Den** — comunidad enorme de devs, canales de voz activos.
- **Reactiflux** — si trabajas con React, este es tu sitio.
- **TypeScript Community** — discusiones técnicas de alto nivel.
- **Python Discord** — la comunidad de Python más grande.

> **Clave:** No entres solo a leer. Participa en los voice channels. El listening pasivo ayuda, pero el **speaking activo** es lo que realmente te hace avanzar.

---

### 4. Cambiar el idioma de todo 🔧

Esto parece menor, pero tiene un efecto acumulativo brutal:

- **Sistema operativo** → en inglés.
- **IDE (VS Code, IntelliJ)** → en inglés.
- **Búsquedas en Google** → en inglés.
- **Stack Overflow** → siempre la versión inglesa.
- **Documentación** → la original, no la traducción.
- **Series y películas** → en inglés con subtítulos en inglés (no en español).

Cuando todo tu entorno digital está en inglés, tu cerebro deja de "traducir" y empieza a **pensar directamente** en el idioma.

---

## Mi progreso real (sin venderle humo a nadie)

Soy honesto: no empecé desde cero. Tenía una base de "inglés de instituto" que me permitía leer documentación con algo de esfuerzo. Pero el **speaking y listening eran un desastre**.

### Antes (hace 6 meses)

- Leía docs técnicas con traductor al lado.
- Evitaba cualquier reunión en inglés.
- No entendía podcasts sin subtítulos.
- Me bloqueaba al intentar explicar algo técnico.

### Ahora

- Leo issues de GitHub y documentación **sin traductor**.
- Participo en voice chats de Discord **sin prepararme lo que voy a decir**.
- Entiendo podcasts técnicos al **80-90%** a velocidad normal.
- Puedo hacer una demo técnica en inglés (con nervios, pero funcional).

No es magia. Es **consistencia + las herramientas correctas + mucha vergüenza pasada**.

---

## Plan de acción: si empezaras hoy

Si estás en el punto de "entiendo algo pero no hablo nada", este sería mi plan para ti:

### Semana 1-2: Fundamentos
- Cambia **todo** a inglés (sistema operativo, IDE, móvil).
- Empieza con **10 minutos de shadowing** al día con Fireship o Traversy Media.
- Registra una cuenta en Free4Talk y entra a **una sala** (solo escucha si quieres).

### Semana 3-4: Activación
- Sube el shadowing a **15-20 minutos**.
- En Free4Talk, **habla**. Aunque sea presentarte y decir tres frases.
- Únete a **un servidor de Discord** técnico y lee las conversaciones.

### Mes 2-3: Consistencia
- Participa en voice channels de Discord al menos **2 veces por semana**.
- Lee **un artículo técnico** en inglés al día (MDN, blog posts, changelogs).
- Si puedes, invierte en un **tutor 1:1** (aunque sea una vez por semana).

### Mes 4+: Consolidación
- Intenta escribir tus **commits y PRs en inglés**.
- Haz un **side project** con toda la documentación en inglés.
- Graba un **loom o vídeo corto** explicando algo técnico en inglés.

---

## Herramientas que uso a diario

| Herramienta | Qué hago con ella | Gratis |
|-------------|-------------------|:------:|
| **Free4Talk** | Practicar speaking con extraños | ✅ |
| **Discord** | Voice chats en comunidades tech | ✅ |
| **YouTube (Fireship, Theo)** | Shadowing + listening | ✅ |
| **Anki** | Flashcards de vocabulario técnico | ✅ |
| **Grammarly** | Corregir textos escritos | Freemium |
| **ChatGPT / Claude** | Practicar conversación por texto y corregir frases | Freemium |
| **iTalki** | Clases 1:1 con tutores | De pago |

---

## El ROI del inglés vs. aprender otro framework

Voy a ser directo: **el retorno de inversión de dominar inglés es mayor que aprender cualquier framework nuevo.**

- Aprender un framework nuevo → te abre puertas a proyectos con ese framework.
- Dominar inglés → te abre puertas a **todo el mercado global**.

Un developer con inglés fluido y React tiene más oportunidades que un developer sin inglés que sabe React, Angular, Vue y Svelte juntos.

---

## Conclusión

El inglés no es una soft skill más que poner en el CV. Es la **tecnología base sobre la que se construye todo lo demás** en tu carrera tech.

No esperes a que salga el tutorial en español. No esperes a tener "más nivel". **Empieza hoy, con lo que tienes, desde donde estás.**

Lo peor que puede pasar es que pases un poco de vergüenza en una sala de Free4Talk. Lo mejor que puede pasar es que en 6 meses estés facturando en dólares.

Si acabas de empezar en el mundo tech, no te pierdas mi [guía completa para estudiantes de DAW, DAM, SMR y ASIR](/blog/guia-estudiantes-daw-dam-smr-2026/) donde explico todo lo que necesitas saber para destacar en 2026. Para ver cómo el inglés fue clave en mi carrera, lee mi artículo [sobre mí como desarrollador full-stack](/blog/sobre-mi-fran-cobos-desarrollador-fullstack-ia/).

¿Y tú? ¿Sigues esperando a que salga el tutorial en español o ya te has pasado al lado bilingüe? Cuéntamelo en [LinkedIn](https://www.linkedin.com/in/francobosg/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Prisma vs Drizzle ORM en 2026: Cuál Elegir para tu Proyecto Node.js]]></title>
      <link>https://francobosg.netlify.app/blog/prisma-vs-drizzle-orm-2026/</link>
      <description><![CDATA[Comparativa honesta entre Prisma y Drizzle ORM en 2026: DX, rendimiento, migraciones, bundle size y cuándo usar cada uno según el tipo de proyecto.]]></description>
      <pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/prisma-vs-drizzle-orm-2026/</guid>
      <category>Prisma</category>
      <category>Backend</category>
      <category>TypeScript</category>
      <content:encoded><![CDATA[Drizzle lleva dos años ganando tracción como alternativa seria a Prisma. En 2026 ya tiene suficiente madurez para considerarse en proyectos nuevos. Esta comparativa es la que me habría gustado tener cuando tuve que elegir.

## Lo que cada uno es en realidad

**Prisma** es un ORM de alto nivel con su propio lenguaje de schema (PSL), cliente generado automáticamente y herramientas de migración maduras. Abstrae bastante el SQL.

**Drizzle** se define a sí mismo como "SQL-like ORM". Las queries se escriben en TypeScript pero se parecen mucho al SQL real. No genera código, la inferencia de tipos ocurre en tiempo de compilación.

---

## Definir un schema

### Prisma

```prisma
// prisma/schema.prisma
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  nombre    String
  plan      String   @default("free")
  posts     Post[]
  createdAt DateTime @default(now())
}

model Post {
  id        String   @id @default(cuid())
  titulo    String
  publicado Boolean  @default(false)
  autorId   String
  autor     User     @relation(fields: [autorId], references: [id])
}
```

### Drizzle

```typescript
// db/schema.ts
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

export const users = pgTable('users', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  email: text('email').notNull().unique(),
  nombre: text('nombre').notNull(),
  plan: text('plan').notNull().default('free'),
  createdAt: timestamp('created_at').defaultNow(),
});

export const posts = pgTable('posts', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  titulo: text('titulo').notNull(),
  publicado: boolean('publicado').notNull().default(false),
  autorId: text('autor_id').notNull().references(() => users.id),
});

// Relaciones (solo para inferencia de tipos)
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));
```

Drizzle define el schema en TypeScript puro — no hay un lenguaje nuevo que aprender.

---

## Queries comparadas

### Read con JOIN

**Prisma:**

```typescript
const posts = await prisma.post.findMany({
  where: { publicado: true },
  include: { autor: { select: { nombre: true } } },
  orderBy: { createdAt: 'desc' },
  take: 10,
});
```

**Drizzle:**

```typescript
const posts = await db
  .select({
    id: posts.id,
    titulo: posts.titulo,
    autorNombre: users.nombre,
  })
  .from(posts)
  .innerJoin(users, eq(posts.autorId, users.id))
  .where(eq(posts.publicado, true))
  .orderBy(desc(posts.createdAt))
  .limit(10);
```

Drizzle se parece más a SQL. Si conoces SQL, la curva es mínima.

### Insert

**Prisma:**

```typescript
const user = await prisma.user.create({
  data: { email: 'fran@ejemplo.com', nombre: 'Fran' },
});
```

**Drizzle:**

```typescript
const [user] = await db
  .insert(users)
  .values({ email: 'fran@ejemplo.com', nombre: 'Fran' })
  .returning();
```

### Upsert

**Prisma:**

```typescript
const user = await prisma.user.upsert({
  where: { email: 'fran@ejemplo.com' },
  create: { email: 'fran@ejemplo.com', nombre: 'Fran' },
  update: { nombre: 'Fran Actualizado' },
});
```

**Drizzle:**

```typescript
const [user] = await db
  .insert(users)
  .values({ email: 'fran@ejemplo.com', nombre: 'Fran' })
  .onConflictDoUpdate({
    target: users.email,
    set: { nombre: 'Fran Actualizado' },
  })
  .returning();
```

---

## Migraciones

### Prisma

```bash
# Crea y aplica migración con historial
npx prisma migrate dev --name añadir_campo_avatar

# Aplica en producción
npx prisma migrate deploy
```

Las migraciones de Prisma generan SQL legible y tienen historial rastreable.

### Drizzle

```bash
# Genera SQL de migración
npx drizzle-kit generate

# Aplica migraciones
npx drizzle-kit migrate
```

Drizzle Kit también genera SQL. La diferencia es que Prisma tiene más madurez en detección de cambios complejos (renombrar columnas, cambiar tipos).

---

## Tabla comparativa

| Aspecto | Prisma | Drizzle |
|---------|--------|---------|
| DX / ergonomía | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Documentación | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Bundle size | Grande (~25MB) | Pequeño (~3KB) |
| Rendimiento | Bueno | Excelente |
| Migraciones | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Edge/Serverless | Problemático | Excelente |
| Comunidad | Grande | Creciendo rápido |
| Curva aprendizaje | Suave | Moderada (requiere SQL) |

---

## Cuándo elegir Drizzle

- **Edge functions / Cloudflare Workers**: Drizzle es compatible nativo, Prisma tiene problemas de bundle size
- **Serverless con cold starts sensibles**: Drizzle inicializa mucho más rápido
- **Queries SQL complejas**: Drizzle te permite escribir SQL casi directo sin perder el tipado
- **Proyecto pequeño**: menos magia, más control, menos abstracción

## Cuándo elegir Prisma

- **Equipo grande**: mejor documentación, más recursos de onboarding
- **Prototipado rápido**: la DX de Prisma es imbatible para ir rápido
- **Prisma Studio**: la UI para gestionar datos en desarrollo no tiene equivalente en Drizzle
- **Ecosistema**: más integraciones, más librerías construidas sobre Prisma

---

## Mi opinión honesta

Para el 80% de los proyectos web, **la diferencia es irrelevante en producción**. Lo que importa es que el equipo lo domine y que las migraciones sean seguras.

Si empiezas hoy: **Prisma** por el ecosistema y la documentación. Si mañana necesitas edge functions o el bundle size empieza a importar, mira Drizzle.

Antes de elegir el ORM, asegúrate de haber elegido bien la base de datos: [PostgreSQL vs MySQL en 2026](/blog/postgresql-vs-mysql-cual-elegir-2026/). Y para ver un ejemplo real de Prisma en un proyecto completo, revisa el [tutorial de Prisma desde cero](/blog/prisma-desde-cero-tutorial-completo-2026/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Claude 4 vs GPT-4.1 para Programar: Comparativa 2026]]></title>
      <link>https://francobosg.netlify.app/blog/claude-vs-gpt-programar-python-2026/</link>
      <description><![CDATA[¿Claude 4 o GPT-4.1 para escribir código Python? Comparo precisión, precio por token, errores y velocidad con pruebas reales. Tabla comparativa incluida.]]></description>
      <pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/claude-vs-gpt-programar-python-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[> **TL;DR**: Claude Opus 4 escribe mejor código Python para lógica compleja, refactoring y arquitectura. GPT-4.1 es 7x más barato y más rápido — úsalo para scripting, automatizaciones y tareas repetitivas. Para el día a día, GPT-4.1 mini es imbatible en relación calidad/precio.

## Requisitos previos

Antes de seguir, asegúrate de tener claro:

- [ ] Sabes qué tipo de código Python escribes más (scripts, APIs, data science, web)
- [ ] Tienes cuenta en [OpenAI Platform](https://platform.openai.com/) y/o [Anthropic Console](https://console.anthropic.com/)
- [ ] Conoces tu presupuesto mensual para APIs de IA (o si usarás un plan gratuito)

Si aún no tienes claro cuánto vas a gastar, consulta primero la [calculadora de precios de APIs de IA](/blog/calculadora-precios-ia-2026/).

---

Vas a pagar por una IA para programar Python y no sabes cuál elegir. Las dos opciones son Claude 4 (Anthropic) y GPT-4.1 (OpenAI). **Elegir mal te puede costar cientos de euros al mes** en una herramienta que no se adapta a tu flujo de trabajo.

He probado ambos modelos durante semanas en proyectos reales de Python: APIs con FastAPI, scripts de automatización, data pipelines y refactoring de código legacy. Aquí van los resultados.

## Tabla comparativa: Claude 4 vs GPT-4.1 para Python

| Criterio | Claude Opus 4 | GPT-4.1 | Ganador |
|----------|--------------|---------|---------|
| **Precio input** (por 1M tokens) | $15 | $2 | GPT-4.1 |
| **Precio output** (por 1M tokens) | $75 | $8 | GPT-4.1 |
| **Contexto máximo** | 200K tokens | 1M tokens | GPT-4.1 |
| **Velocidad de respuesta** | ~60 tokens/s | ~80 tokens/s | GPT-4.1 |
| **Precisión en código complejo** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | Claude |
| **Refactoring multi-archivo** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | Claude |
| **Seguir instrucciones largas** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | Claude |
| **Scripting / automatización** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | GPT-4.1 |
| **Documentación y docstrings** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | GPT-4.1 |
| **Debug de errores** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | Claude |

## Dónde Claude 4 aplasta a GPT-4.1

### 1. Lógica compleja y algoritmos

Cuando le pides código con lógica condicional anidada, recursión o patrones de diseño, Claude genera código que **funciona a la primera** con más frecuencia.

```python
# Prompt: "Implementa un rate limiter con sliding window para una API FastAPI"

# Claude Opus 4 — primer intento: ✅ completo y funcional
import time
from collections import defaultdict
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
request_log: dict[str, list[float]] = defaultdict(list)

async def rate_limit(request: Request, max_requests: int = 60, window: int = 60):
    client_ip = request.client.host
    now = time.time()
    # Limpia timestamps fuera de la ventana
    request_log[client_ip] = [
        t for t in request_log[client_ip] if now - t < window
    ]
    if len(request_log[client_ip]) >= max_requests:
        raise HTTPException(
            status_code=429,
            detail=f"Rate limit: {max_requests} req/{window}s"
        )
    request_log[client_ip].append(now)
```

GPT-4.1 con el mismo prompt generó código que funcionaba pero **olvidaba limpiar los timestamps viejos**, causando un memory leak en producción.

### 2. Refactoring de código legacy

Claude entiende mejor la **intención** detrás del código existente. Al pedirle refactorizar una clase de 500 líneas, mantiene la funcionalidad y mejora la estructura sin romper nada.

### 3. Debugging de errores crípticos

Al pegarle un traceback complejo de Python, Claude identifica la causa raíz más rápido. GPT-4.1 tiende a sugerir soluciones genéricas antes de analizar el error en profundidad.

> **Pro-Tip**: Si usas Claude para debugging, pega siempre el traceback completo + las 5 líneas del código que fallan. Claude rinde mucho mejor con contexto que con preguntas vagas.

## Dónde GPT-4.1 aplasta a Claude 4

### 1. Scripts de automatización rápidos

Para escribir scripts de Bash, cron jobs, scrapers o utilidades de línea de comandos, GPT-4.1 es más directo y rápido.

```python
# Prompt: "Script Python para renombrar 500 archivos CSV por fecha de modificación"

# GPT-4.1 — directo y funcional en 2 segundos
import os
from pathlib import Path
from datetime import datetime

csv_dir = Path("./data")
for f in csv_dir.glob("*.csv"):
    mod_time = datetime.fromtimestamp(f.stat().st_mtime)
    new_name = f"{mod_time:%Y-%m-%d}_{f.stem}{f.suffix}"
    f.rename(f.parent / new_name)
    print(f"{f.name} → {new_name}")
```

Claude con el mismo prompt añadía validaciones, logs y manejo de errores que **no le pedí**. A veces quieres que la IA haga lo justo, no más.

### 2. Contexto masivo (1M tokens)

GPT-4.1 soporta hasta **1 millón de tokens** de contexto. Si necesitas analizar un repositorio entero o un codebase de +50 archivos, GPT-4.1 lo procesa todo de golpe. Claude se limita a 200K.

### 3. Precio (la diferencia es brutal)

Para tareas repetitivas donde la calidad es "suficientemente buena", GPT-4.1 es **7x más barato**:

| Tarea ejemplo | Tokens input | Tokens output | Coste Claude | Coste GPT-4.1 |
|---------------|-------------|---------------|-------------|---------------|
| Generar 100 unit tests | 50K | 100K | $8.25 | $1.09 |
| Documentar 20 funciones | 30K | 40K | $3.45 | $0.38 |
| Traducir 50 archivos i18n | 100K | 100K | $9.00 | $1.00 |
| Analizar 200 PRs | 500K | 50K | $11.25 | $1.40 |

> **Advertencia**: Si haces muchas llamadas seguidas para generar tests o documentación, puedes recibir un Error 429 de rate limit. Aquí explico [cómo solucionarlo con retry y backoff exponencial](/blog/error-429-too-many-requests-api-ia-2026/).

## La opción que nadie menciona: GPT-4.1 mini + Claude para lo difícil

El setup más inteligente es **usar ambos**:

1. **GPT-4.1 mini** ($0.40/1M input) para autocompletado, docstrings, tests unitarios y tareas repetitivas
2. **Claude Sonnet 4** ($3/1M input) para refactoring, arquitectura y debugging complejo
3. **Claude Opus 4** ($15/1M input) solo cuando Sonnet no lo resuelve (lógica muy compleja, análisis de codebases grandes)

Con esta estrategia, un desarrollador que programa 6-8 horas al día puede gastar **entre $15 y $40/mes** en vez de $100+ usando solo Opus.

```python
# Ejemplo: router inteligente para elegir modelo según la tarea
def choose_model(task_type: str) -> str:
    routing = {
        "autocomplete": "gpt-4.1-mini",      # $0.40/M input
        "unit_test": "gpt-4.1-mini",          # rápido y barato
        "docstring": "gpt-4.1-mini",          # formato repetitivo
        "refactor": "claude-sonnet-4",        # entiende intención
        "debug": "claude-sonnet-4",           # analiza mejor errores
        "architecture": "claude-opus-4",       # lógica compleja
        "code_review": "claude-sonnet-4",     # detecta bugs sutiles
    }
    return routing.get(task_type, "gpt-4.1-mini")
```

Si necesitas comparar también los editores que integran estos modelos (Cursor, Copilot, Windsurf), mira mi [comparativa completa de editores con IA](/blog/cursor-vs-copilot-vs-windsurf-2026/).

## Python-specific: qué modelo maneja mejor cada librería

Esto no lo vas a encontrar en otras comparativas:

| Librería/Framework | Mejor modelo | Por qué |
|-------------------|-------------|---------|
| **FastAPI** | Claude | Genera middleware, dependencias y Pydantic models correctos |
| **Django** | GPT-4.1 | Mejor con código boilerplate y migraciones |
| **Pandas / NumPy** | GPT-4.1 | Más rápido en transformaciones de datos |
| **PyTorch / TensorFlow** | Claude | Entiende mejor la lógica de training loops |
| **SQLAlchemy** | Claude | Genera queries complejas con JOINs correctos |
| **Scrapy / BeautifulSoup** | GPT-4.1 | Scripts directos, sin sobreingeniería |
| **pytest** | GPT-4.1 mini | Tests repetitivos, barato y suficiente |
| **asyncio** | Claude | Maneja mejor concurrencia y race conditions |

## Consejo de seguridad que nadie menciona

Ambos modelos pueden generar código Python con **vulnerabilidades** que no son obvias:

- **SQL injection** en queries construidas con f-strings
- **Path traversal** al manejar uploads sin sanitizar
- Secrets hardcodeados en el código generado
- `eval()` o `exec()` sugeridos para "flexibilidad"

Revisa siempre el código generado con herramientas como `bandit` antes de hacer deploy:

```bash
pip install bandit
bandit -r ./src -f json -o security_report.json
```

> **Pro-Tip**: Añade esta instrucción a tu system prompt: *"Nunca uses eval(), exec() ni f-strings para construir SQL. Usa siempre parametrized queries y valida todas las rutas de archivo con pathlib."* Esto reduce drásticamente el código inseguro que genera la IA.

## Veredicto final

| Si necesitas... | Usa |
|----------------|-----|
| Código Python que funcione a la primera | Claude Opus 4 |
| Escribir 50 scripts rápido | GPT-4.1 |
| Relación calidad/precio | GPT-4.1 mini |
| Refactorizar un proyecto legacy | Claude Sonnet 4 |
| Procesar un codebase de +100 archivos | GPT-4.1 (1M contexto) |
| Presupuesto cero | [Alternativas gratis](/blog/alternativas-gratis-chatgpt-2026/) |

No elijas un modelo. **Elige una estrategia de routing** que use el modelo correcto para cada tarea. Tu código será mejor y tu factura será menor.

Para ver cómo se comparan estos modelos contra el resto del mercado, consulta mi [ranking completo de modelos de IA para programar](/blog/mejores-modelos-ia-para-programar-2026/).

---

¿Usas otro modelo para Python que no mencioné? Cuéntamelo en [LinkedIn](https://www.linkedin.com/in/francobosg/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Los 20 Mejores Prompts para Programar con IA en 2026]]></title>
      <link>https://francobosg.netlify.app/blog/mejores-prompts-programar-ia-2026/</link>
      <description><![CDATA[20 prompts copy-paste para programar con ChatGPT, Claude y Copilot. Para debugging, refactoring, tests y arquitectura. Probados en proyectos reales.]]></description>
      <pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/mejores-prompts-programar-ia-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Después de miles de horas programando con IA, estos son los **prompts que realmente funcionan**. No son genéricos — están probados en proyectos reales.

## Prompts para escribir código

### 1. El prompt de contexto completo

```
Actúa como un senior developer experto en [tecnología].
Estoy trabajando en [descripción del proyecto].
Stack: [lista de tecnologías].
Necesito: [lo que quieres].
Restricciones: [límites o requisitos].
```

**Por qué funciona**: Darle contexto reduce las alucinaciones un 70%. Sin contexto, la IA tiene que adivinar tu stack, tus convenciones y tu nivel.

### 2. Generar función con edge cases

```
Escribe una función en [lenguaje] que [descripción].
Incluye manejo de: valores nulos, arrays vacíos, strings con espacios,
números negativos y tipos inesperados.
Devuelve errores descriptivos, no genéricos.
```

### 3. Implementar feature completa

```
Necesito implementar [feature] en mi proyecto de [framework].
Estructura actual: [describe la estructura de carpetas relevante].
Dame: componente/función + tipos + ejemplo de uso.
Sigue el patrón que ya uso en [archivo existente similar].
```

## Prompts para debugging

### 4. El debugger experto

```
Tengo este error: [pegar error completo].
Código relevante: [pegar código].
Contexto: [qué intentabas hacer].
Versiones: [runtime + dependencias].
¿Cuál es la causa raíz y cómo lo arreglo?
```

### 5. Debug sin error visible

```
Este código compila pero el comportamiento es incorrecto.
Esperado: [qué debería pasar].
Actual: [qué pasa realmente].
[pegar código]
Analiza paso a paso la ejecución y encuentra el bug.
```

### 6. Error intermitente

```
Este bug ocurre de forma intermitente (~30% de las veces).
Código: [pegar].
Entorno: [dev/prod/ambos].
¿Qué condiciones de carrera, timing o estado podrían causarlo?
```

## Prompts para refactoring

### 7. Refactor con preservación de comportamiento

```
Refactoriza este código para mejorar [legibilidad/rendimiento/mantenibilidad].
IMPORTANTE: el comportamiento externo NO debe cambiar.
Código actual: [pegar].
Explica cada cambio y por qué lo haces.
```

### 8. Eliminar code smells

```
Analiza este código y encuentra:
- Funciones que hacen demasiado (>20 líneas)
- Duplicación
- Nombres poco descriptivos
- Acoplamiento innecesario
Propón refactoring concreto para cada uno.
[pegar código]
```

### 9. Migrar de X a Y

```
Convierte este código de [tecnología antigua] a [tecnología nueva].
Mapea cada feature equivalente.
Si algo no tiene equivalente directo, sugiere la alternativa idiomática.
Código original: [pegar].
```

## Prompts para testing

### 10. Tests exhaustivos

```
Escribe tests para esta función usando [framework de test].
Cubre: happy path, edge cases, errores esperados, inputs límite.
Usa describe/it con nombres descriptivos en español.
Mock las dependencias externas.
Función: [pegar código].
```

Si quieres ir más allá de generar tests con prompts y necesitas una estrategia completa de testing para código de IA (property-based testing, snapshot testing, CI), consulta [cómo testear código generado por IA](/blog/testear-codigo-generado-ia-copilot-cursor-2026/).

### 11. Tests de integración

```
Necesito tests de integración para [endpoint/feature].
Setup: [base de datos, servidor, etc.].
Prueba el flujo completo: request → procesamiento → respuesta → side effects.
Incluye cleanup después de cada test.
```

## Prompts para documentación

### 12. README profesional

```
Genera un README.md para este proyecto:
- Nombre: [nombre]
- Qué hace: [descripción en 1 línea]
- Stack: [tecnologías]
- Incluye: badges, instalación, uso, estructura de carpetas,
  variables de entorno, y cómo contribuir.
Tono: profesional pero accesible.
```

### 13. Documentar API

```
Documenta esta API/función con:
- Descripción de qué hace y cuándo usarla
- Parámetros con tipos y valores por defecto
- Valor de retorno
- Excepciones que puede lanzar
- Ejemplo de uso básico y avanzado
Código: [pegar].
```

## Prompts para arquitectura

### 14. Diseñar sistema

```
Necesito diseñar [tipo de sistema].
Requisitos: [lista].
Escala esperada: [usuarios/requests].
Presupuesto: [limitado/moderado/ilimitado].
Propón la arquitectura con: diagrama de componentes,
tecnologías específicas, y trade-offs de cada decisión.
```

### 15. Code review

```
Haz code review de este PR como si fueras un senior developer.
Busca: bugs potenciales, problemas de seguridad,
rendimiento, legibilidad, y convenciones del lenguaje.
Prioriza los problemas de mayor a menor impacto.
No menciones formateo ni estilo — tengo linter para eso.
[pegar código]
```

## Prompts para productividad

### 16. Regex en lenguaje humano

```
Necesito una regex que [descripción en lenguaje natural].
Ejemplos de match: [lista].
Ejemplos de NO match: [lista].
Lenguaje: [para saber el flavor de regex].
Explica cada parte de la regex.
```

### 17. Convertir datos

```
Transforma estos datos de [formato origen] a [formato destino].
Datos: [pegar muestra].
Reglas de mapeo: [si hay alguna conversión especial].
Dame el código de conversión y un ejemplo de output.
```

Si la conversión implica JSON generado por IA y necesitas validar que el output sea correcto, revisa las [técnicas para parsear JSON de IA sin errores](/blog/parsear-json-ia-sin-errores-openai-claude-2026/).

### 18. SQL complejo

```
Base de datos: [motor].
Tablas relevantes: [esquema simplificado].
Necesito una query que [descripción del resultado deseado].
Optimiza para rendimiento.
Explica el plan de ejecución esperado.
```

## Meta-prompts (prompts sobre prompts)

### 19. Mejorar tu propio prompt

```
Este es un prompt que estoy usando: [pegar tu prompt].
Los resultados son [buenos/mediocres/malos] porque [razón].
¿Cómo lo mejorarías para obtener resultados más [específicos/creativos/técnicos]?
```

### 20. Chain of thought forzado

```
Antes de dar la solución final:
1. Analiza el problema
2. Lista 3 posibles enfoques con pros/cons
3. Elige el mejor y explica por qué
4. Implementa paso a paso
5. Verifica que cumple todos los requisitos
[tu pregunta aquí]
```

## Buenas Prácticas para Hacer Prompts de Código Efectivos

| Tip | Impacto |
|-----|---------|
| **Dar contexto del proyecto** | Alto — reduce alucinaciones |
| **Pegar el error completo** | Alto — no parafrasees el error |
| **Especificar el lenguaje/framework** | Medio — evita respuestas genéricas |
| **Pedir explicación de cada paso** | Medio — detectas errores de la IA |
| **Iterar en la misma conversación** | Alto — la IA acumula contexto |
| **Copiar tu código real, no simplificado** | Alto — el bug suele estar en los detalles |
| **Decir "no uses X" si hay restricción** | Medio — evita sugerencias inviables |

## ¿Qué modelo usar para cada tarea?

| Tarea | Mejor modelo (2026) |
|-------|---------------------|
| **Código complejo / arquitectura** | Claude Opus 4, o3 |
| **Código rutinario / rápido** | GPT-4.1 mini, Claude Sonnet 4 |
| **Debugging** | Claude Opus 4, Gemini 2.5 Pro |
| **Documentación** | GPT-4o, Claude Sonnet 4 |
| **SQL / datos** | Gemini 2.5 Pro |
| **Refactoring grandes** | Claude Opus 4 (200K contexto) |

¿No sabes si elegir Claude o GPT para tu proyecto? Te lo cuento en detalle en mi [comparativa Claude 4 vs GPT-4.1 para programar en Python](/blog/claude-vs-gpt-programar-python-2026/).

Para saber qué modelo usar con estos prompts, consulta mi [ranking de los 10 mejores modelos de IA para programar](/blog/mejores-modelos-ia-para-programar-2026/). Si programas con editor IA, mira mi [comparativa Cursor vs Copilot vs Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/). Y si quieres que cada prompt gaste menos tokens, aprende la técnica de [caveman prompting para ahorrar un 70% en tokens](/blog/caveman-prompting-ahorrar-tokens-ia-2026/).

¿No quieres escribir prompts desde cero? Prueba mi [generador de prompts interactivo](/blog/herramientas/generador-prompts/) — seleccionas tarea, lenguaje y nivel, y te genera el prompt listo para copiar.

---

¿Quieres ver cómo aplico estos prompts en proyectos reales? Echa un vistazo a [mi portfolio](/) donde muestro el resultado de construir con IA a diario.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cómo Conseguir Trabajo de Programador sin Experiencia en España 2026 (Guía Real)]]></title>
      <link>https://francobosg.netlify.app/blog/conseguir-trabajo-programador-sin-experiencia-espana-2026/</link>
      <description><![CDATA[Guía paso a paso para conseguir tu primer trabajo de programador en España sin experiencia en 2026: stack ideal, portfolio que funciona, dónde buscar y cómo pasar entrevistas técnicas.]]></description>
      <pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/conseguir-trabajo-programador-sin-experiencia-espana-2026/</guid>
      <category>Carrera</category>
      <category>España</category>
      <category>Trabajo</category>
      <category>Junior</category>
      <category>Portfolio</category>
      <content:encoded><![CDATA[La pregunta más común en cualquier comunidad de developers españoles: "¿cómo entro al mercado sin experiencia?". Esta guía responde esa pregunta con datos reales del mercado español en 2026, no con consejos genéricos que valen igual para cualquier país.

> **Respuesta rápida:** Portfolio con 2-3 proyectos reales en GitHub + LinkedIn optimizado + postulación activa durante 3-6 meses = primer empleo. El título (DAW/DAM o universitario) ayuda pero no es imprescindible si tienes proyectos sólidos.

---

## La realidad del mercado junior en España en 2026

Antes de empezar, necesitas entender el contexto real:

**Lo que juega en tu contra:**
- Las empresas prefieren mids y seniors. Las plazas junior son menos y más disputadas.
- Hay muchos candidatos con el mismo certificado de bootcamp o el mismo curso de Udemy
- El mercado junior está más saturado en frontend que en backend o DevOps

**Lo que juega a tu favor:**
- Los juniors cuestan menos que los mids, y muchas pymes lo saben
- DAW/DAM es una vía oficial reconocida que tienen en cuenta muchas consultoras
- Un portfolio original diferencia enormemente (la mayoría llega sin nada)
- El mercado tech en España sigue creciendo aunque de forma selectiva

**Conclusión:** las plazas junior existen, pero tienes que llegar con algo tangible, no solo con el título.

---

## Paso 1: Elegir el stack adecuado (y no cambiar cada semana)

El mayor error de los que empiezan es cambiar de tecnología cada dos semanas siguiendo el hype. Elige uno y profundiza hasta el empleo.

### Ruta Frontend (más plazas junior, resultado más visual)

```
Fundamentos: HTML5 semántico + CSS (Flexbox/Grid)
↓
JavaScript ES6+ (sin framework primero)
↓
TypeScript básico
↓
React (el más demandado en España por mucho)
↓
Next.js (para proyectos con SSR/SSG)
Herramientas: Git, npm/pnpm, Vite, Tailwind CSS
```

**¿Cuánto tarda?** Con 5-6h diarias: 8-12 meses para estar listo para entrevistas junior.

### Ruta Backend (menos plazas junior, pero muy demandado a medio plazo)

```
Python → FastAPI o Django REST Framework
o
JavaScript/TypeScript → Node.js → Express o NestJS
↓
SQL + PostgreSQL (base de datos relacional)
↓
Prisma (ORM) o SQLAlchemy
↓
Conceptos: APIs REST, autenticación JWT, variables de entorno
```

Para profundizar en backend, el artículo sobre [variables de entorno en Node.js y Next.js](/blog/variables-de-entorno-nodejs-nextjs-guia-2026/) es un buen punto de entrada.

### Ruta Fullstack (la más pedida en startups y pymes)

```
React + TypeScript (frontend)
↓
Node.js + Express o Next.js API routes (backend)
↓
PostgreSQL + Prisma (base de datos)
↓
Despliegue en Vercel + Supabase/Railway (gratis)
```

Para saber qué tecnología paga mejor cuando llegues al mid-level, consulta la [guía de sueldos de developer en España](/blog/sueldo-desarrollador-web-espana-2026/).

### ¿DAW/DAM o bootcamp o autodidacta?

| Vía | Ventajas | Inconvenientes |
|-----|----------|----------------|
| **DAW/DAM** | Título oficial, prácticas en empresa, reconocido en consultoras | 2 años, currículo desactualizado en algunas partes |
| **Bootcamp** (Ironhack, The Bridge, Factoria F5) | 4-6 meses intensivos, bolsa de empleo, red de alumni | Caro (4.000€-9.000€), calidad variable |
| **Autodidacta** | Gratis o barato, a tu ritmo | Sin red, sin prácticas, más difícil el primer paso |
| **Universidad** | Título potente, base teórica sólida | 4 años, muy lento para empezar |

**Para alumnos de DAW/DAM:** tenéis una ventaja real. El título lo piden explícitamente muchas consultoras (Accenture, Indra, Capgemini) y empresas medianas. Aprovecha las prácticas del ciclo para tener experiencia real en el CV desde el primer día.

---

## Paso 2: El portfolio que realmente funciona

El error más común: 8 proyectos que son clones de tutoriales idénticos (la to-do app, el clon de Netflix, el clon de Spotify). Los recruiters los ven 50 veces al día.

### Qué proyectos construir

**Proyecto 1 — Resuelve un problema real tuyo (o de alguien cercano):**
Un gestor de gastos personales, un tracker de entrenamientos, una app de recetas que usas tú. Que esté **desplegado** (Vercel, Netlify — gratis), **funcional** y con **código limpio en GitHub**. La originalidad y el hecho de que lo uses tú mismo lo hacen memorable.

**Proyecto 2 — Full-stack con autenticación y base de datos:**
Login/registro, CRUD de algún recurso, persistencia en base de datos real. Demuestra que sabes conectar frontend, backend y base de datos. Usa Supabase o Railway para no pagar hosting.

**Proyecto 3 (opcional pero muy bueno) — Integración con API externa o IA:**
Integrar una API (OpenAI, Spotify, Stripe, un servicio de clima) muestra que sabes trabajar con código de terceros, que es lo que harás en cualquier trabajo real.

### Checklist de cada proyecto en GitHub

```
✅ README con: descripción, capturas/GIF, tecnologías usadas, 
   instrucciones para ejecutar, link al deploy en producción
✅ Commits con mensajes descriptivos (no "fix", "asd", "cambios")
✅ Variables de entorno en .env.example (nunca subas claves reales)
✅ Código organizado en carpetas lógicas
✅ Proyecto realmente funcional (no un "work in progress" roto)
```

### Errores de portfolio que eliminan candidatos

- **Link al deploy roto** — si el proyecto no carga, lo descartan
- **README vacío** — el recruiter no sabe qué es ni cómo ejecutarlo
- **Solo commits de un día** — parece que lo hiciste todo el último día (aunque no sea así)
- **Contraseñas o API keys en el código** — señal de rojo inmediata

---

## Paso 3: LinkedIn y GitHub optimizados

### LinkedIn que aparece en búsquedas de recruiters

El titular es lo más importante. Los recruiters buscan por keywords:

```
❌ "Estudiante de programación buscando oportunidades"
✅ "Desarrollador Frontend Junior | React | TypeScript | JavaScript"
✅ "Programador Junior Fullstack | Node.js + React | Buscando primer empleo"
```

**Checklist LinkedIn para junior:**
- [ ] Foto profesional (fondo neutro, cara visible, ropa informal-profesional)
- [ ] Titular con keywords del stack
- [ ] Sección "Acerca de" con quién eres, qué has construido y qué buscas (4-5 líneas)
- [ ] Proyectos listados en la sección "Proyectos" con links al código y al deploy
- [ ] Experiencia: prácticas, proyectos freelance, cualquier cosa relacionada
- [ ] Skills: React, TypeScript, Node.js, etc. Pide endorsements a compañeros
- [ ] Actividad regular: 1-2 posts al mes sobre algo que aprendiste

### GitHub que muestra que eres activo

- **Profile README** con presentación breve y links a proyectos principales
- **Commits regulares** (aunque sea en proyectos personales)
- **Pinned repositories** con tus mejores 4-6 proyectos
- **READMEs cuidados** en cada repositorio que quieras mostrar

---

## Paso 4: Dónde buscar trabajo junior en España

### Plataformas con más oferta junior en 2026

| Plataforma | Tipo de empresa | Notas |
|-----------|----------------|-------|
| **LinkedIn Jobs** | Todo tipo | Mayor volumen, muy competitivo |
| **InfoJobs** | Consultoras, pymes | Muy usado en España, especialmente fuera de Madrid |
| **Manfred** | Startups y producto | Mejor calidad de ofertas, procesos más humanos |
| **Tecnoempleo** | IT en general | Específico de tecnología |
| **Jobandtalent** | Consultoras | Muchos contratos de formación |
| **Domestika Jobs** | Startups creativas | Buena calidad, pocas plazas |

### Comunidades donde se publican ofertas que no están en bolsas

- **Telegram: WeLoveDevs Spain** — comunidad activa con canal de ofertas
- **Slack: Malditos Startups** — startups españolas con buen ambiente
- **Discord: Spain.js** — comunidad JavaScript española
- **Meetups locales** — Barcelona, Madrid, Valencia tienen meetups mensuales donde se conoce a tech leads que contratan

### El método de aplicación directa (el más subestimado)

En lugar de aplicar a 100 ofertas en JobBoards:
1. Identifica 20-30 empresas donde te gustaría trabajar
2. Encuentra a un developer del equipo en LinkedIn
3. Envía un mensaje directo **corto y personalizado**: "Vi que usáis React + Node.js, llevo X meses construyendo proyectos con ese stack, ¿estarías dispuesto a revisar mi portfolio brevemente?"
4. El 10-20% responde. De esos, el 10-20% acaba en proceso. Es mucho mejor ratio que las aplicaciones en frío.

---

## Paso 5: El proceso de entrevistas en España para juniors

### Proceso típico

```
1. Llamada inicial con RRHH o recruiter (15-30 min)
   → Expectativas, fit cultural, disponibilidad, salario esperado

2. Prueba técnica para llevar a casa (2-4 horas)
   → CRUD básico, un componente con lógica, un pequeño problema

3. Entrevista técnica con el equipo (45-90 min)
   → Explicas la prueba, preguntas de concepto, pair programming

4. Entrevista de fit cultural (30-60 min)
   → Reunión con el equipo, a veces informal
```

### Qué preguntan en entrevistas junior en 2026

**JavaScript / TypeScript:**
- ¿Qué es el event loop?
- Diferencia entre `var`, `let` y `const`
- ¿Qué es un closure?
- ¿Qué es `async/await` y cómo funciona?
- ¿Diferencia entre `==` y `===`?

**React:**
- ¿Qué es el Virtual DOM?
- ¿Para qué sirve `useEffect`? ¿Cuándo se ejecuta?
- ¿Qué es el estado en React y cuándo usar `useState` vs `useContext`?
- ¿Qué es el lifting del estado?

**General:**
- ¿Cuál es tu proyecto favorito del portfolio y por qué tomaste las decisiones que tomaste?
- ¿Cómo aprendes cuando te atascas?
- ¿Has trabajado con Git en equipo? ¿Qué es un pull request?

### Para las pruebas técnicas

**Lo que evalúan realmente:**
- Que el código funciona y hace lo que pide el enunciado
- Que el código está estructurado y es legible (no todo en un archivo)
- Que añades un README con instrucciones claras
- Que maneja casos básicos de error (input vacío, datos inválidos)

**Lo que no evalúan (no te obsesiones):**
- Que sea perfecto al 100%
- Que uses los patrones más avanzados
- Performance optimizations

**Entrega siempre algo funcional.** Un CRUD simple que funciona es mejor que un sistema complejo a medias.

---

## Timeline realista para conseguir el primer empleo

Este es el proceso medio que reportan developers que lo han conseguido:

```
Meses 1-3: Aprendizaje de fundamentos
  → HTML/CSS/JS bien, framework básico, primer proyecto pequeño

Meses 4-6: Proyectos del portfolio
  → 2-3 proyectos reales, desplegados, con README
  → Optimización de LinkedIn y GitHub

Mes 6-7: Comienzo de búsqueda activa
  → 10-20 aplicaciones/semana
  → Networking en comunidades

Meses 7-10: Primeras entrevistas y feedback
  → Pruebas técnicas, entrevistas, feedback de rechazos
  → Mejora de portfolio según feedback

Meses 10-12: Primera oferta
  → Media real: entre 8-14 meses desde cero absoluto
  → Con DAW/DAM o bootcamp bueno: 3-8 meses desde que terminas
```

---

## Errores que comete casi todo el mundo (y cómo evitarlos)

| Error | Consecuencia | Solución |
|-------|-------------|----------|
| Aplicar sin personalizar la carta | Ignoran el CV | Personaliza 3-4 líneas para cada empresa |
| Portfolio solo de tutoriales | No diferencias del 90% | Construye proyectos originales |
| No tener el portfolio desplegado | No pueden verlo | Vercel/Netlify gratis |
| Pedir sueldo sin investigar | Infra o sobrepasas el rango | Investiga con Manfred/InfoJobs antes |
| Rendirse tras 30 rechazos | Nunca llegas | La media son 60-150 aplicaciones |
| Dejar de aprender mientras buscan | CV estancado | Sigue construyendo durante la búsqueda |

---

## Salarios que puedes esperar en tu primer empleo

No apliques sin saber qué pedir. Datos del mercado español 2026 para juniors:

- **Consultoras grandes** (Accenture, Indra, Capgemini): 19.000€ – 23.000€
- **Pymes y agencias**: 20.000€ – 25.000€
- **Startups con financiación**: 23.000€ – 30.000€
- **Empresas de producto mid-size**: 24.000€ – 30.000€
- **Madrid/Barcelona**: +3.000€ – 5.000€ sobre esos rangos

Para entender la progresión salarial a medio y largo plazo, revisa la [guía completa de sueldos de desarrollador en España](/blog/sueldo-desarrollador-web-espana-2026/).

---

## Recursos gratuitos para prepararte

**Para aprender JavaScript de verdad:**
- [javascript.info](https://javascript.info) — el mejor recurso gratuito en profundidad
- [The Odin Project](https://www.theodinproject.com) — currículum gratuito completo para fullstack

**Para preparar entrevistas:**
- [LeetCode Easy](https://leetcode.com/problemset/?difficulty=Easy) — practica los básicos
- [Frontend Interview Handbook](https://www.frontendinterviewhandbook.com) — preguntas específicas de frontend

**Para deployar proyectos gratis:**
- [Vercel](https://vercel.com) — perfecto para Next.js y frontends
- [Netlify](https://netlify.com) — para sitios estáticos
- [Supabase](https://supabase.com) — PostgreSQL + autenticación gratis hasta cierto límite

---

Una vez que tengas tu primer trabajo y acumules 2-3 años de experiencia, el siguiente paso natural es el trabajo remoto para empresas extranjeras. La [guía de trabajo remoto para developers](/blog/trabajo-remoto-developers-como-encontrar-2026/) explica cómo dar ese salto y multiplicar el sueldo.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Calculadora de Precios de IA 2026: GPT, Claude, Gemini]]></title>
      <link>https://francobosg.netlify.app/blog/calculadora-precios-ia-2026/</link>
      <description><![CDATA[¿Cuánto cuesta usar IA en 2026? Tabla con precios de GPT-4.1, Claude, Gemini y 15 modelos más. Con calculadora de costes mensuales.]]></description>
      <pubDate>Wed, 18 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/calculadora-precios-ia-2026/</guid>
      <category>IA</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Si estás desarrollando con IA o simplemente quieres saber cuánto cuesta usar cada modelo, aquí tienes la **guía definitiva de precios actualizada a abril de 2026**.

## Tabla comparativa de precios por modelo

| Modelo | Proveedor | Input (1M tokens) | Output (1M tokens) | Contexto máx. |
|--------|-----------|-------------------|---------------------|---------------|
| **GPT-4o** | OpenAI | $2.50 | $10.00 | 128K |
| **GPT-4o mini** | OpenAI | $0.15 | $0.60 | 128K |
| **GPT-4.1** | OpenAI | $2.00 | $8.00 | 1M |
| **GPT-4.1 mini** | OpenAI | $0.40 | $1.60 | 1M |
| **GPT-4.1 nano** | OpenAI | $0.10 | $0.40 | 1M |
| **o3** | OpenAI | $2.00 | $8.00 | 200K |
| **o4-mini** | OpenAI | $1.10 | $4.40 | 200K |
| **Claude Opus 4** | Anthropic | $15.00 | $75.00 | 200K |
| **Claude Sonnet 4** | Anthropic | $3.00 | $15.00 | 200K |
| **Claude Haiku 3.5** | Anthropic | $0.80 | $4.00 | 200K |
| **Gemini 2.5 Pro** | Google | $1.25 / $2.50 | $10.00 / $15.00 | 1M |
| **Gemini 2.5 Flash** | Google | $0.15 / $0.30 | $0.60 / $3.50 | 1M |
| **Gemini 2.0 Flash** | Google | $0.10 | $0.40 | 1M |
| **DeepSeek V3** | DeepSeek | $0.27 | $1.10 | 128K |
| **DeepSeek R1** | DeepSeek | $0.55 | $2.19 | 128K |
| **Llama 4 Maverick** | Meta (vía API) | $0.20 | $0.60 | 1M |
| **Grok 3** | xAI | $3.00 | $15.00 | 128K |
| **Grok 3 mini** | xAI | $0.30 | $0.50 | 128K |

> **Nota**: Los precios pueden variar. Última actualización: abril 2026. Gemini 2.5 Pro/Flash tienen precios por tramos (hasta 200K / más de 200K tokens).

## ¿Qué es un token?

Un **token** equivale aproximadamente a **¾ de una palabra** en inglés o **½ palabra** en español. Un artículo de 1.000 palabras en español son unos 2.000 tokens.

### Ejemplos prácticos de consumo

| Tarea | Tokens aprox. input | Tokens aprox. output |
|-------|---------------------|----------------------|
| Pregunta corta (chat) | 50-100 | 200-500 |
| Resumen de un artículo | 2.000 | 500 |
| Generar un componente React | 200 | 800-1.500 |
| Analizar un PDF de 20 páginas | 15.000 | 2.000 |
| Agente con contexto largo | 50.000-200.000 | 5.000-20.000 |

## ¿Cuánto cuesta al mes usar IA como desarrollador?

Supongamos un desarrollador que hace **50 consultas al día**, con un promedio de 500 tokens de input y 1.000 tokens de output por consulta:

- **Tokens mensuales**: 50 × 30 = 1.500 consultas → 750K input + 1.5M output
- **GPT-4o mini**: $0.11 + $0.90 = **~$1/mes**
- **GPT-4o**: $1.88 + $15.00 = **~$17/mes**
- **Claude Sonnet 4**: $2.25 + $22.50 = **~$25/mes**
- **Claude Opus 4**: $11.25 + $112.50 = **~$124/mes**
- **DeepSeek V3**: $0.20 + $1.65 = **~$2/mes**

### ¿Cuál es el modelo más barato?

Para uso general: **GPT-4.1 nano** ($0.10/$0.40) y **Gemini 2.0 Flash** ($0.10/$0.40) son los más económicos de los grandes proveedores. **DeepSeek V3** es la mejor opción calidad/precio hoy.

### ¿Cuál es el más caro?

**Claude Opus 4** a $15/$75 por millón de tokens. Justificado solo para tareas complejas de razonamiento donde la calidad importa más que el coste.

## Suscripciones vs API: ¿qué te conviene?

| Plan | Precio | Qué incluye |
|------|--------|-------------|
| ChatGPT Plus | $20/mes | GPT-4o, GPT-4o mini, o3-mini, límites generosos |
| ChatGPT Pro | $200/mes | Acceso ilimitado a o3, o4-mini, GPT-4.1 |
| Claude Pro | $20/mes | Claude Sonnet 4, límites 5x free |
| Claude Max | $100-200/mes | Claude Opus 4 ilimitado |
| Gemini Advanced | $20/mes | Gemini 2.5 Pro, 1M contexto |
| API (pay-as-you-go) | Variable | Pagas solo lo que usas, sin límites artificiales |

**Mi recomendación**: Si eres desarrollador, el plan **ChatGPT Plus** o **Claude Pro** ($20/mes) cubre el 90% de tus necesitas. Para producción (agentes, apps), la API pay-as-you-go es más eficiente. Si quieres reducir drásticamente lo que gastas, lee la [guía completa para programar con IA sin arruinarte](/blog/programar-con-ia-sin-arruinarte-guia-2026/) con 10 técnicas probadas.

## Cómo Reducir Costes de API de IA (Tips Probados)

1. **Usa modelos pequeños para tareas simples** — GPT-4.1 nano/mini o Flash para completar código, Opus/o3 solo para razonamiento complejo. Para ver qué modelo rinde mejor programando, consulta la [comparativa Claude 4 vs GPT-4.1](/blog/claude-vs-gpt-programar-python-2026/).
2. **Cachea respuestas** — Si haces la misma consulta, guárdala. Anthropic ofrece "prompt caching" con 90% de descuento
3. **Reduce el contexto** — No mandes archivos enteros si solo necesitas un fragmento
4. **Batch API** — OpenAI ofrece 50% descuento en procesamiento asíncrono
5. **Modelos open source** — Llama 4 y DeepSeek puedes ejecutarlos localmente gratis si tienes GPU
6. **Controla los rate limits** — Si haces muchas peticiones seguidas, implementa retry con backoff exponencial para evitar [errores 429 Too Many Requests](/blog/error-429-too-many-requests-api-ia-2026/)

Si buscas opciones gratuitas, mira las [mejores alternativas gratis a ChatGPT](/blog/alternativas-gratis-chatgpt-2026/) o mi guía para [usar la API de ChatGPT y Claude sin gastar](/blog/usar-api-chatgpt-claude-gratis-2026/).

¿Quieres calcular cuánto gastarías con cada modelo según tu uso real? Usa el [comparador de precios de IA interactivo](/blog/herramientas/comparador-precios/) para simular tu consumo mensual y encontrar el modelo más rentable.

---

*¿Te ha sido útil? Echa un vistazo a [mi portfolio](/) para ver proyectos donde integro estos modelos en aplicaciones reales.*]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Subir Archivos a S3 y Cloudinary con Node.js en 2026: Guía Práctica]]></title>
      <link>https://francobosg.netlify.app/blog/subir-archivos-s3-cloudinary-nodejs-2026/</link>
      <description><![CDATA[Cómo subir archivos a AWS S3 con SDK v3 y a Cloudinary desde Node.js: upload directo, presigned URLs, validación y gestión segura.]]></description>
      <pubDate>Wed, 18 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/subir-archivos-s3-cloudinary-nodejs-2026/</guid>
      <category>Node.js</category>
      <category>Backend</category>
      <category>AWS</category>
      <content:encoded><![CDATA[Subir archivos parece simple hasta que llega a producción: archivos demasiado grandes, tipos no permitidos, costes inesperados o imágenes que tardan en cargar. Esta guía cubre el flujo completo.

## Opción A: AWS S3 con SDK v3

### Configuración

```bash
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner multer
npm install -D @types/multer
```

```bash
# .env
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=eu-west-1
S3_BUCKET_NAME=mi-app-uploads
```

### Cliente S3 (singleton)

```typescript
// lib/s3.ts
import { S3Client } from '@aws-sdk/client-s3';

export const s3 = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});
```

### Subida directa desde servidor (Node.js)

```typescript
// routes/upload.ts
import { PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { s3 } from '../lib/s3';
import multer from 'multer';
import { randomUUID } from 'crypto';
import path from 'path';

// Multer en memoria (no guarda en disco)
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB máximo
  fileFilter: (req, file, callback) => {
    const tiposPermitidos = ['image/jpeg', 'image/png', 'image/webp'];
    if (!tiposPermitidos.includes(file.mimetype)) {
      return callback(new Error('Solo se permiten imágenes JPEG, PNG y WebP'));
    }
    callback(null, true);
  },
});

// Endpoint de subida
app.post('/api/upload', upload.single('archivo'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No se proporcionó archivo' });
  }

  const extension = path.extname(req.file.originalname);
  const key = `uploads/${randomUUID()}${extension}`;

  try {
    await s3.send(new PutObjectCommand({
      Bucket: process.env.S3_BUCKET_NAME!,
      Key: key,
      Body: req.file.buffer,
      ContentType: req.file.mimetype,
      // No usar ACL: 'public-read' — usa políticas de bucket en su lugar
    }));

    const url = `https://${process.env.S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
    
    res.json({ url, key });
  } catch (error) {
    console.error('Error subiendo a S3:', error);
    res.status(500).json({ error: 'Error al subir el archivo' });
  }
});
```

### Presigned URLs (recomendado para archivos grandes)

Con presigned URLs, el cliente sube directamente a S3 sin pasar por tu servidor:

```typescript
// routes/presigned.ts
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3 } from '../lib/s3';
import { randomUUID } from 'crypto';

app.post('/api/upload/presigned', async (req, res) => {
  const { tipoArchivo, extension } = req.body;
  
  const tiposPermitidos = ['image/jpeg', 'image/png', 'image/webp'];
  if (!tiposPermitidos.includes(tipoArchivo)) {
    return res.status(400).json({ error: 'Tipo de archivo no permitido' });
  }

  const key = `uploads/${randomUUID()}.${extension}`;

  const comando = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET_NAME!,
    Key: key,
    ContentType: tipoArchivo,
  });

  const presignedUrl = await getSignedUrl(s3, comando, {
    expiresIn: 300, // válida 5 minutos
  });

  res.json({ presignedUrl, key });
});
```

```typescript
// En el cliente (frontend)
async function subirArchivoDirecto(archivo: File) {
  // 1. Pedir la URL firmada al servidor
  const { presignedUrl, key } = await fetch('/api/upload/presigned', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      tipoArchivo: archivo.type,
      extension: archivo.name.split('.').pop(),
    }),
  }).then(r => r.json());

  // 2. Subir directamente a S3
  await fetch(presignedUrl, {
    method: 'PUT',
    body: archivo,
    headers: { 'Content-Type': archivo.type },
  });

  return key;
}
```

---

## Opción B: Cloudinary

```bash
npm install cloudinary multer
```

```bash
# .env
CLOUDINARY_CLOUD_NAME=tu-cloud
CLOUDINARY_API_KEY=123456789
CLOUDINARY_API_SECRET=...
```

```typescript
// lib/cloudinary.ts
import { v2 as cloudinary } from 'cloudinary';

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME!,
  api_key: process.env.CLOUDINARY_API_KEY!,
  api_secret: process.env.CLOUDINARY_API_SECRET!,
  secure: true,
});

export { cloudinary };
```

### Subida con transformaciones automáticas

```typescript
import { cloudinary } from '../lib/cloudinary';
import multer from 'multer';
import { Readable } from 'stream';

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
});

app.post('/api/upload/cloudinary', upload.single('archivo'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No se proporcionó archivo' });
  }

  // Subir con transformaciones: redimensionar y convertir a webp
  const resultado = await new Promise<{ secure_url: string; public_id: string }>(
    (resolve, reject) => {
      const stream = cloudinary.uploader.upload_stream(
        {
          folder: 'mi-app/avatares',
          transformation: [
            { width: 400, height: 400, crop: 'fill', gravity: 'face' },
            { fetch_format: 'auto', quality: 'auto' }, // webp automático
          ],
          allowed_formats: ['jpg', 'jpeg', 'png', 'webp'],
        },
        (error, result) => {
          if (error || !result) return reject(error);
          resolve(result);
        }
      );

      Readable.from(req.file!.buffer).pipe(stream);
    }
  );

  res.json({
    url: resultado.secure_url,
    publicId: resultado.public_id,
  });
});
```

### Eliminar imagen de Cloudinary

```typescript
app.delete('/api/upload/cloudinary/:publicId', async (req, res) => {
  const { publicId } = req.params;

  await cloudinary.uploader.destroy(publicId);
  
  res.json({ eliminado: true });
});
```

---

## Tabla comparativa rápida

| | AWS S3 | Cloudinary |
|-|--------|-----------|
| Precio (almacenamiento) | Barato a escala | Más caro con volumen |
| Transformaciones | Manual (Lambda/Sharp) | Automáticas en URL |
| DX setup | Moderado | Muy simple |
| Control | Total | Limitado al plan |
| Mejor para | Producción a escala | Prototipos, imágenes |

---

Si tienes variables de entorno como las de AWS o Cloudinary bien separadas entre desarrollo y producción, consulta la guía de [variables de entorno en Node.js y Next.js](/blog/variables-de-entorno-nodejs-nextjs-guia-2026/). Para enviar notificaciones al usuario tras una subida exitosa, el artículo de [enviar emails con Node.js](/blog/enviar-emails-nodejs-nodemailer-resend-2026/) cubre los casos más comunes.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Los 10 Mejores Modelos de IA para Programar en 2026]]></title>
      <link>https://francobosg.netlify.app/blog/mejores-modelos-ia-para-programar-2026/</link>
      <description><![CDATA[Claude Opus 4, GPT-4.1 o Gemini 2.5 Pro: ¿qué IA programa mejor en 2026? Ranking basado en pruebas reales con código.]]></description>
      <pubDate>Sat, 14 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/mejores-modelos-ia-para-programar-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Llevo más de 5 años programando y el último año he usado prácticamente **todos** los modelos de IA para desarrollo. Aquí va mi ranking honesto, basado en uso real, no en benchmarks de marketing.

## Ranking: Mejor IA para programar en 2026

### 🥇 1. Claude Opus 4 / Sonnet 4 (Anthropic)

**El rey del código complejo.**

- Entiende codebases enteros gracias a su ventana de 200K tokens
- El mejor para refactoring, arquitectura y debugging de errores difíciles
- Modo "extended thinking" para razonamiento paso a paso
- Excelente para escribir tests y documentación

**Ideal para**: Arquitectura, refactoring masivo, debugging complejo, code review.

**Limitación**: Es caro (Opus) y puede ser lento en tareas simples donde un modelo mini bastaría.

| Aspecto | Puntuación |
|---------|------------|
| Calidad de código | ⭐⭐⭐⭐⭐ |
| Velocidad | ⭐⭐⭐ |
| Contexto | ⭐⭐⭐⭐⭐ |
| Precio | ⭐⭐ |

### 🥈 2. GPT-4.1 (OpenAI)

**El más versátil y fiable.**

- 1M de tokens de contexto — puedes meter proyectos enteros
- Sigue instrucciones largas y complejas con precisión
- Excelente para seguir guías de estilo y convenciones
- API muy estable y rápida

**Ideal para**: Completar código, generar componentes, seguir patrones existentes.

| Aspecto | Puntuación |
|---------|------------|
| Calidad de código | ⭐⭐⭐⭐ |
| Velocidad | ⭐⭐⭐⭐ |
| Contexto | ⭐⭐⭐⭐⭐ |
| Precio | ⭐⭐⭐⭐ |

### 🥉 3. Gemini 2.5 Pro (Google)

**El mejor para proyectos grandes con presupuesto ajustado.**

- 1M de tokens de contexto (el mayor del mercado)
- Razonamiento mejorado con "thinking mode"
- Precio agresivo para la calidad que ofrece
- Integrado con Google Cloud y Android Studio

**Ideal para**: Analizar codebases muy grandes, migración de proyectos.

| Aspecto | Puntuación |
|---------|------------|
| Calidad de código | ⭐⭐⭐⭐ |
| Velocidad | ⭐⭐⭐ |
| Contexto | ⭐⭐⭐⭐⭐ |
| Precio | ⭐⭐⭐⭐ |

### 4. o3 / o4-mini (OpenAI)

**Los mejores para razonamiento lógico.**

- Modelos de razonamiento que "piensan" antes de responder
- Excelentes para algoritmos complejos, math, y puzzles de código
- o4-mini es sorprendentemente bueno para su precio

**Ideal para**: Algoritmos, optimización, debugging de lógica compleja.

### 5. DeepSeek V3 / R1

**La opción open source más potente.**

- Rendimiento comparable a GPT-4o a una fracción del coste
- R1 tiene razonamiento similar a o3
- Se puede ejecutar localmente con hardware decente
- API absurdamente barata ($0.27/1M input)

**Ideal para**: Presupuesto limitado, auto-hosting, privacidad de datos.

### 6. GitHub Copilot (GPT-4o + Claude Sonnet)

**El mejor para autocompletar en el editor.**

- Integración nativa con VS Code
- Sugerencias inline en tiempo real
- Chat + agente para tareas complejas
- $10/mes (o gratis para open source y estudiantes)

**Ideal para**: Autocompletar código, escribir más rápido en el día a día.

### 7. Cursor (Claude Sonnet + GPT-4.1)

**El mejor IDE con IA integrada.**

- Editor completo basado en VS Code con IA nativa
- "Composer" para cambios multi-archivo
- Excelente para prototipar rápido
- Entiende el contexto del proyecto automáticamente

**Ideal para**: Prototyping rápido, proyectos nuevos, desarrolladores que quieren todo integrado.

### 8. Llama 4 Maverick (Meta)

**El mejor modelo abierto multimodal.**

- 1M de tokens de contexto
- Se puede ejecutar 100% local
- Rendimiento competitivo con modelos comerciales
- Gratis y open source

**Ideal para**: Self-hosting, privacidad total, empresas que no quieren depender de APIs. Si quieres probarlo en tu propio PC, sigue mi [guía para instalar Ollama y ejecutar IA local gratis](/blog/ollama-ia-local-gratis-sin-api-2026/).

### 9. GPT-4.1 nano (OpenAI)

**El mejor para tareas simples a coste casi cero.**

- Extremadamente rápido y barato ($0.10/1M input)
- Suficiente para completar funciones, generar tests básicos, formatear
- Perfecto como modelo "rápido" en workflows multi-modelo

**Ideal para**: Autocompletar, formateo, tareas repetitivas, pipelines de CI.

### 10. Grok 3 (xAI)

**El más creativo para soluciones no convencionales.**

- Buen rendimiento en programación general
- Acceso gratuito básico con cuenta de X/Twitter
- Benchmarks competitivos con GPT-4o

**Ideal para**: Explorar enfoques alternativos, brainstorming técnico.

## Mi setup personal como desarrollador

Uso una combinación según la tarea:

| Tarea | Modelo | Por qué |
|-------|--------|---------|
| Autocomplete rápido | GitHub Copilot | Inline, no rompe el flow |
| Componentes React/UI | Claude Sonnet 4 | Entiende bien diseño + código |
| Debugging complejo | Claude Opus 4 | El mejor razonamiento |
| Scripts y automatización | GPT-4.1 mini | Rápido y barato |
| Arquitectura/diseño | Claude Opus 4 + o3 | Combino perspectivas |
| Code review | Gemini 2.5 Pro | Gran contexto para diffs largos |

## Qué modelo de IA elegir para programar en 2026

No busques "el mejor modelo" — busca **el mejor modelo para cada tarea**. Usa modelos baratos (nano/mini/Flash) para el 80% del trabajo y reserva los caros (Opus/o3) para cuando realmente necesitas calidad máxima. Si dudas entre los dos líderes, tengo una [comparativa directa de Claude 4 vs GPT-4.1 para programar en Python](/blog/claude-vs-gpt-programar-python-2026/). Para la guía completa de cómo reducir tu factura de IA un 80%, lee [cómo programar con IA sin arruinarte](/blog/programar-con-ia-sin-arruinarte-guia-2026/).

Para comparar editores que integran estos modelos, lee mi [comparativa Cursor vs GitHub Copilot vs Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/). Si quieres sacarles el máximo partido, aquí tienes los [20 mejores prompts para programar con IA](/blog/mejores-prompts-programar-ia-2026/). Y para entender cuánto cuestan, consulta la [calculadora de precios de IA](/blog/calculadora-precios-ia-2026/).

---

*¿Quieres ver cómo aplico estas herramientas en proyectos reales? Mira cómo usé Gemini y GPT en mi [ecosistema de IA para reuniones](/blog/caso-real-ia-reuniones-gemini-supabase/) o cómo construí un [SaaS multitenant con NestJS y React](/blog/caso-real-saas-atrapaclientes-nestjs-react/). Si necesitas desarrollo a medida, consulta mis [servicios](/blog/servicios/).*]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cursor vs GitHub Copilot vs Windsurf: Comparativa Completa 2026]]></title>
      <link>https://francobosg.netlify.app/blog/cursor-vs-copilot-vs-windsurf-2026/</link>
      <description><![CDATA[Análisis detallado de los 3 mejores editores de código con IA. Precios, funciones, modelos, velocidad y cuál elegir según tu caso.]]></description>
      <pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/cursor-vs-copilot-vs-windsurf-2026/</guid>
      <category>IA</category>
      <category>Código</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Los tres editores de código con IA más usados en 2026 son **Cursor**, **GitHub Copilot** (en VS Code) y **Windsurf**. Llevo meses usándolos en proyectos reales y esta es mi comparativa honesta.

## Tabla comparativa rápida

| Característica | Cursor | GitHub Copilot | Windsurf |
|---------------|--------|----------------|----------|
| **Base** | Fork de VS Code | Extensión VS Code | Fork de VS Code |
| **Plan gratis** | Sí (limitado) | Sí (limitado) | Sí (limitado) |
| **Plan Pro** | $20/mes | $10/mes | $15/mes |
| **Plan Business** | $40/mes | $19/mes | $35/mes |
| **Autocompletado** | ✅ Excelente | ✅ Excelente | ✅ Muy bueno |
| **Chat en editor** | ✅ Cmd+K inline | ✅ Inline + panel | ✅ Inline |
| **Agente autónomo** | ✅ Composer/Agent | ✅ Copilot Agent | ✅ Cascade |
| **Multi-archivo** | ✅ Nativo | ✅ Workspace | ✅ Cascade |
| **Modelos** | Claude, GPT, Gemini | GPT-4.1, Claude, o3 | Claude, GPT, propio |
| **Contexto del repo** | ✅ @codebase | ✅ @workspace | ✅ Automático |
| **Terminal IA** | ✅ | ✅ | ✅ |
| **Extensiones VS Code** | ✅ Compatibles | ✅ Nativas | ✅ Compatibles |
| **MCP (herramientas)** | ✅ | ✅ | ✅ |

## Cursor: el más potente

### Pros
- **Composer/Agent** es el modo agente más avanzado — puede crear archivos, editar múltiples simultáneamente y ejecutar comandos
- **Cmd+K** para edición inline es rapidísimo: seleccionas código, describes el cambio y listo
- **@codebase** indexa todo tu proyecto y responde con contexto real
- Soporta elegir entre Claude Opus, Sonnet, GPT-4.1, Gemini y más
- **Rules for AI** te permite definir convenciones del proyecto (`.cursorrules`)
- Community muy activa, actualizaciones semanales

### Contras
- $20/mes puede ser caro si ya pagás Copilot
- Al ser fork de VS Code, a veces las actualizaciones llegan tarde
- El modelo propio (cursor-small) no es tan bueno para tareas complejas
- Consume bastante RAM

### Mejor para
Developers que trabajan en proyectos medianos/grandes y necesitan ediciones multi-archivo constantes.

---

## GitHub Copilot: el más integrado

### Pros
- **$10/mes** es el plan pro más barato
- Integración nativa con VS Code — sin instalar otro editor
- **Copilot Agent** mode puede planificar y ejecutar cambios multi-archivo
- Acceso a GPT-4.1, Claude Opus/Sonnet, o3 y Gemini
- **@workspace** entiende la estructura del proyecto
- Integración con GitHub: genera PR descriptions, revisa código, crea issues
- MCP tools para conectar con APIs externas
- El plan gratuito ya incluye bastante

### Contras
- El autocompletado a veces sugiere código genérico que no encaja con tu estilo
- El agent mode es más nuevo y puede ser menos fluido que Composer de Cursor
- No tiene un equivalente a `.cursorrules` tan potente (tiene `copilot-instructions.md`)

### Mejor para
Developers que ya usan VS Code y GitHub, quieren IA sin cambiar de editor, o buscan la opción más económica.

---

## Windsurf: el más fluido

### Pros
- **Cascade** es su modo agente — navega tu código automáticamente sin necesidad de `@` tags
- Contexto automático: detecta qué archivos son relevantes sin que se lo digas
- La experiencia se siente muy "fluida" — menos fricción que los otros
- Buen equilibrio precio/rendimiento a $15/mes
- Modelo propio optimizado para código

### Contras
- Comunidad más pequeña que Cursor o Copilot
- Menos modelos disponibles que la competencia
- Debugging complejo a veces necesita intervención manual
- Ecosistema de extensiones algo limitado comparado con VS Code nativo

### Mejor para
Developers que priorizan la experiencia fluida y no quieren configurar nada — "just works".

---

## Benchmark personal: tarea real

Pedí a los 3 la misma tarea: *"Añade dark mode a este componente React, con toggle, persistencia en localStorage y transición suave"*. Código base: componente de 80 líneas con Tailwind.

| Criterio | Cursor | Copilot | Windsurf |
|----------|--------|---------|----------|
| **Tiempo primera respuesta** | 3s | 4s | 3s |
| **Código funcional a la primera** | ✅ | ✅ | ✅ |
| **Editó archivos correctos** | 2/2 | 2/2 | 2/2 |
| **Siguió mi estilo de código** | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
| **Explicación del cambio** | Detallada | Breve | Media |

Resultado: **empate técnico**. Las diferencias reales aparecen en tareas más complejas (refactoring de 10+ archivos, debugging en producción, migraciones de framework).

## Mi configuración actual

Uso **GitHub Copilot** como base diaria en VS Code (por la integración con GitHub y el precio) + **Cursor** para sesiones de desarrollo intensivo donde necesito el Composer para ediciones multi-archivo grandes.

| Situación | Herramienta |
|-----------|------------|
| Código rutinario + autocompletado | GitHub Copilot |
| Refactoring grande (> 5 archivos) | Cursor Composer |
| Proyecto nuevo desde cero | Cursor Agent |
| PR review + documentación | GitHub Copilot |
| Explorar codebase desconocido | Cualquiera de los 3 |

## ¿Cuál elegir?

- **Presupuesto limitado** → GitHub Copilot ($10/mes) o su plan gratis
- **Máxima potencia** → Cursor ($20/mes)
- **Quiero que funcione sin configurar** → Windsurf ($15/mes)
- **Ya uso VS Code y no quiero cambiar** → GitHub Copilot

Los tres son excelentes. La mejor IA para programar es la que te integres en tu workflow real. Si buscas una alternativa más barata que funciona desde la terminal, [Aider puede costarte $5-15/mes sin límite de peticiones](/blog/aider-ia-terminal-barata-alternativa-cursor-2026/). Eso sí, antes de elegir asegúrate de tener todo bien configurado — consulta los [5 errores más comunes al configurar extensiones de IA en VS Code](/blog/errores-comunes-ia-vscode-copilot-cline-2026/) para evitar problemas desde el inicio.

Independientemente del editor que elijas, el código generado por IA necesita validación rigurosa. Consulta [cómo testear código generado por IA](/blog/testear-codigo-generado-ia-copilot-cursor-2026/) para las mejores estrategias de testing.

Si quieres saber qué modelos de IA están detrás de cada editor, mira mi [ranking de los mejores modelos de IA para programar](/blog/mejores-modelos-ia-para-programar-2026/). Y para sacarles todo el jugo, consulta los [20 mejores prompts para programar con IA](/blog/mejores-prompts-programar-ia-2026/).

---

¿Quieres ver proyectos que construí usando estas herramientas? Mira cómo desarrollé un [SaaS multitenant completo](/blog/caso-real-saas-atrapaclientes-nestjs-react/) o un sistema de [monitorización IoT con ESP32](/blog/caso-real-iot-sensores-esp32-dolibarr/). Si necesitas desarrollo a medida, consulta mis [servicios](/blog/servicios/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[shadcn/ui en Español: Guía Completa de Instalación y Uso 2026]]></title>
      <link>https://francobosg.netlify.app/blog/shadcn-ui-guia-completa-espanol-2026/</link>
      <description><![CDATA[Aprende a instalar y usar shadcn/ui en Next.js y React: qué es realmente, componentes, temas, personalización y por qué no es una librería tradicional.]]></description>
      <pubDate>Sun, 08 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/shadcn-ui-guia-completa-espanol-2026/</guid>
      <category>React</category>
      <category>TypeScript</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[shadcn/ui es lo más usado en el ecosistema React en 2026 para UI, pero genera confusión porque no funciona como cualquier otra librería. Esta guía explica qué es de verdad y cómo usarlo desde cero.

## Qué es shadcn/ui (y qué no es)

**No es** una librería npm que instalas y usas como `import { Button } from 'shadcn-ui'`.

**Es** un CLI que copia componentes bien diseñados directamente a tu proyecto. El código queda en `components/ui/` y es tuyo: lo puedes modificar sin restricciones.

Los componentes usan:
- **Radix UI** para la lógica/accesibilidad (menús, diálogos, selects)
- **Tailwind CSS** para los estilos
- **class-variance-authority (CVA)** para variantes de componentes

---

## Instalación en Next.js (App Router)

```bash
# Requisitos: Next.js + TypeScript + Tailwind CSS
npx create-next-app@latest mi-app --typescript --tailwind --app

cd mi-app

# Inicializar shadcn/ui
npx shadcn@latest init
```

El CLI preguntará:

```
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Would you like to use CSS variables for colors? › yes
```

Esto crea:
- `components.json` — configuración de shadcn
- Actualiza `tailwind.config.ts` con las variables de color
- Crea `lib/utils.ts` con la función `cn()`

---

## Instalación en Vite + React

```bash
npm create vite@latest mi-app -- --template react-ts
cd mi-app
npm install
npm install -D tailwindcss @tailwindcss/vite

# Configurar Tailwind en vite.config.ts
# Luego inicializar shadcn
npx shadcn@latest init
```

---

## Añadir componentes

```bash
# Componentes individuales
npx shadcn@latest add button
npx shadcn@latest add input
npx shadcn@latest add dialog
npx shadcn@latest add table
npx shadcn@latest add form

# Ver todos los disponibles
npx shadcn@latest add
```

Cada comando copia los archivos a `components/ui/`.

---

## Usar los componentes

### Button

```tsx
import { Button } from '@/components/ui/button';

export function EjemploButton() {
  return (
    <div className="flex gap-4">
      <Button>Por defecto</Button>
      <Button variant="destructive">Eliminar</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button size="sm">Pequeño</Button>
      <Button size="lg">Grande</Button>
      <Button disabled>Deshabilitado</Button>
    </div>
  );
}
```

### Input + Label + Form básico

```tsx
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';

export function FormLogin() {
  return (
    <form className="space-y-4 max-w-sm">
      <div className="space-y-2">
        <Label htmlFor="email">Email</Label>
        <Input
          id="email"
          type="email"
          placeholder="tu@ejemplo.com"
        />
      </div>
      <div className="space-y-2">
        <Label htmlFor="password">Contraseña</Label>
        <Input
          id="password"
          type="password"
          placeholder="••••••••"
        />
      </div>
      <Button type="submit" className="w-full">
        Iniciar sesión
      </Button>
    </form>
  );
}
```

### Dialog (modal)

```tsx
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';

export function ConfirmarEliminar({ onConfirmar }: { onConfirmar: () => void }) {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="destructive">Eliminar cuenta</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>¿Estás seguro?</DialogTitle>
          <DialogDescription>
            Esta acción no se puede deshacer. Se eliminará tu cuenta permanentemente.
          </DialogDescription>
        </DialogHeader>
        <div className="flex justify-end gap-3 mt-4">
          <Button variant="outline">Cancelar</Button>
          <Button variant="destructive" onClick={onConfirmar}>
            Sí, eliminar
          </Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}
```

### Tabla con datos

```tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';

interface Usuario {
  id: string;
  nombre: string;
  email: string;
  plan: 'free' | 'pro';
}

export function TablaUsuarios({ usuarios }: { usuarios: Usuario[] }) {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Nombre</TableHead>
          <TableHead>Email</TableHead>
          <TableHead>Plan</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {usuarios.map((user) => (
          <TableRow key={user.id}>
            <TableCell className="font-medium">{user.nombre}</TableCell>
            <TableCell>{user.email}</TableCell>
            <TableCell>
              <span className={`px-2 py-1 rounded text-xs font-semibold ${
                user.plan === 'pro'
                  ? 'bg-blue-100 text-blue-800'
                  : 'bg-gray-100 text-gray-800'
              }`}>
                {user.plan.toUpperCase()}
              </span>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

---

## Personalizar estilos

### Cambiar colores del tema

```css
/* app/globals.css */
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --primary: 221.2 83.2% 53.3%;       /* Azul personalizado */
    --primary-foreground: 210 40% 98%;
    --destructive: 0 84.2% 60.2%;       /* Rojo */
    --border: 214.3 31.8% 91.4%;
    --radius: 0.5rem;                    /* Bordes más o menos redondeados */
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --primary: 217.2 91.2% 59.8%;
    --border: 217.2 32.6% 17.5%;
  }
}
```

### Modificar un componente directamente

Como el código está en tu repo, puedes editarlo:

```tsx
// components/ui/button.tsx
// Añade una variante personalizada
const buttonVariants = cva(
  '...clases base...',
  {
    variants: {
      variant: {
        default: '...',
        destructive: '...',
        // ✅ Tu variante personalizada
        brand: 'bg-purple-600 text-white hover:bg-purple-700 shadow-lg',
      },
    },
  }
);
```

---

## La función `cn()`

Todas las variantes de shadcn usan `cn()`, que combina `clsx` y `tailwind-merge`. Úsala en tus propios componentes:

```typescript
// lib/utils.ts (creado por shadcn init)
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
```

```tsx
// Uso en tus componentes
import { cn } from '@/lib/utils';

function MiComponente({ className, activo }: { className?: string; activo: boolean }) {
  return (
    <div className={cn(
      'p-4 rounded-lg border',
      activo && 'border-blue-500 bg-blue-50',
      className // permite sobreescribir desde el padre
    )}>
      contenido
    </div>
  );
}
```

---

## Cuándo no usar shadcn/ui

- Si tu proyecto no usa Tailwind y no quieres introducirlo
- Si necesitas SSR/SSG sin ningún JS en cliente (los componentes Radix requieren JS)
- Si el diseño debe ser 100% personalizado sin base — en ese caso es más trabajo adaptar que construir desde cero

Para formularios complejos con shadcn, combina con React Hook Form y Zod. Para gestión de datos del servidor, [TanStack Query v5](/blog/tanstack-query-v5-tutorial-2026/) es el complemento natural si usas shadcn en tu UI.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Usar la API de ChatGPT y Claude Gratis en 2026]]></title>
      <link>https://francobosg.netlify.app/blog/usar-api-chatgpt-claude-gratis-2026/</link>
      <description><![CDATA[Usa la API de GPT-4.1 y Claude sin pagar: créditos gratis, Groq, Google AI Studio y Ollama. Con código JavaScript listo para copiar.]]></description>
      <pubDate>Fri, 06 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/usar-api-chatgpt-claude-gratis-2026/</guid>
      <category>IA</category>
      <category>Gratis</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Sí, puedes usar las APIs de los mejores modelos de IA **sin gastar dinero** (o gastando céntimos). Aquí te explico todas las opciones reales en 2026.

## Créditos gratis al registrarte

| Proveedor | Créditos gratis | Validez | Qué necesitas |
|-----------|----------------|---------|---------------|
| **OpenAI** | $5 | 3 meses | Solo registrarte |
| **Anthropic** | $5 | - | Registrarte + tarjeta |
| **Google AI Studio** | Gratis ilimitado* | Indefinido | Cuenta de Google |
| **Groq** | Gratis (rate limited) | Indefinido | Solo registrarte |
| **Together AI** | $5 | - | Solo registrarte |
| **Fireworks AI** | $1 | - | Solo registrarte |
| **Mistral** | Tier gratis | Indefinido | Solo registrarte |

> \*Google AI Studio es gratis para uso personal con rate limits (15 RPM para Gemini 2.5 Pro).

## Opción 1: Google AI Studio (100% gratis)

La opción más generosa. Gemini 2.5 Pro y Flash son **gratis** para uso personal:

```javascript
import { GoogleGenAI } from '@google/genai';

const ai = new GoogleGenAI({ apiKey: 'tu-api-key' });

const response = await ai.models.generateContent({
  model: 'gemini-2.5-pro',
  contents: '¿Cuál es la mejor forma de ordenar un array en JavaScript?'
});

console.log(response.text);
```

**Límites gratis**: 15 requests/minuto, 1M tokens/día. Más que suficiente para proyectos personales. Si superas estos límites, recibirás un error 429; consulta [cómo solucionar el error 429 Too Many Requests en APIs de IA](/blog/error-429-too-many-requests-api-ia-2026/).

## Opción 2: Groq (ultra rápido y gratis)

Groq ejecuta modelos open-source (Llama 4, Mixtral) en hardware especializado. Es **gratis con rate limits**:

```javascript
import Groq from 'groq-sdk';

const groq = new Groq({ apiKey: 'tu-api-key' });

const completion = await groq.chat.completions.create({
  messages: [{ role: 'user', content: 'Explica async/await en JS' }],
  model: 'llama-4-maverick-17b-128e',
});

console.log(completion.choices[0].message.content);
```

**Ventaja**: Respuestas en < 500ms. Ideal para apps que necesitan velocidad.

## Opción 3: OpenAI con modelos baratos

Si necesitas la API de OpenAI, usa los modelos más económicos:

```javascript
import OpenAI from 'openai';

const openai = new OpenAI({ apiKey: 'tu-api-key' });

// GPT-4.1 nano: $0.10 input / $0.40 output por millón de tokens
const response = await openai.chat.completions.create({
  model: 'gpt-4.1-nano',
  messages: [{ role: 'user', content: 'Resume este texto en 3 puntos...' }],
  max_tokens: 500, // Limitar output ahorra dinero
});
```

**Coste real**: Con GPT-4.1 nano, 1000 peticiones de ~500 tokens cuestan **$0.05**. Prácticamente gratis.

### Tabla de costes reales por uso

| Uso | Modelo | Tokens/petición | 1000 peticiones |
|-----|--------|----------------|-----------------|
| Chatbot simple | GPT-4.1 nano | ~800 | **$0.04** |
| Resúmenes | GPT-4o mini | ~1500 | **$0.10** |
| Código complejo | GPT-4.1 | ~3000 | **$0.72** |
| Análisis largo | Claude Sonnet 4 | ~5000 | **$1.20** |

## Opción 4: Modelos open-source (gratis total)

Ejecutar modelos en tu máquina = **$0 para siempre**:

### Con Ollama (la forma más fácil)

```bash
# Instalar Ollama
curl -fsSL https://ollama.ai/install.sh | sh

# Descargar y ejecutar Llama 4
ollama run llama4

# Usar como API local
curl http://localhost:11434/api/generate \
  -d '{"model": "llama4", "prompt": "Qué es una API REST?"}'
```

### Modelos recomendados para local

| Modelo | RAM mínima | Calidad código | Velocidad |
|--------|-----------|---------------|-----------|
| **Llama 4 Scout** | 16GB | ⭐⭐⭐⭐ | Rápido |
| **Qwen 2.5 Coder 32B** | 24GB | ⭐⭐⭐⭐⭐ | Medio |
| **DeepSeek Coder V3** | 16GB | ⭐⭐⭐⭐ | Rápido |
| **Phi-4** | 8GB | ⭐⭐⭐ | Muy rápido |
| **Mistral Small** | 16GB | ⭐⭐⭐⭐ | Rápido |

## Opción 5: Claude con uso inteligente

Anthropic no tiene tier gratis ilimitado, pero con $5 de créditos iniciales puedes hacer mucho si usas **Claude Haiku 3.5**:

```javascript
import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic({ apiKey: 'tu-api-key' });

const message = await anthropic.messages.create({
  model: 'claude-3-5-haiku-20241022',
  max_tokens: 1024,
  messages: [{ role: 'user', content: 'Revisa este código por bugs:\n...' }],
});
```

**Coste**: $0.80/$4.00 por millón de tokens. Los $5 gratis te dan unas 6000 peticiones con Haiku.

## Trucos para gastar menos

### 1. Cachear respuestas repetidas

```javascript
const cache = new Map();

async function askAI(prompt) {
  const key = prompt.trim().toLowerCase();
  if (cache.has(key)) return cache.get(key);
  
  const response = await openai.chat.completions.create({
    model: 'gpt-4.1-nano',
    messages: [{ role: 'user', content: prompt }],
  });
  
  const result = response.choices[0].message.content;
  cache.set(key, result);
  return result;
}
```

### 2. Usar system prompt corto

Cada token en el system prompt se cobra **en cada petición**. Un system prompt de 500 tokens × 1000 peticiones = 500K tokens extra.

```javascript
// ❌ Malo: system prompt de 500 tokens
system: "Eres un asistente experto... [párrafo largo]..."

// ✅ Bueno: system prompt de 30 tokens
system: "Eres un dev senior. Responde en español. Sé conciso."
```

### 3. Limitar max_tokens

```javascript
// Solo pides lo que necesitas
max_tokens: 200, // para respuestas cortas
max_tokens: 1000, // para código
// No pongas 4096 "por si acaso" — pagas por output generado
```

### 4. Usar streaming para UX sin esperar

```javascript
const stream = await openai.chat.completions.create({
  model: 'gpt-4.1-nano',
  messages: [{ role: 'user', content: prompt }],
  stream: true,
});

for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta?.content || '');
}
```

No ahorra dinero, pero mejora la experiencia y puedes cortar early si la respuesta no va bien.

Para una implementación completa de streaming con SSE hasta el navegador (incluyendo manejo de errores, reconexión y efecto typewriter), consulta el tutorial de [streaming SSE con ChatGPT y Claude en Node.js](/blog/streaming-sse-chatgpt-claude-nodejs-2026/). Y si necesitas que la respuesta sea JSON estructurado, revisa [cómo parsear JSON de IA sin errores](/blog/parsear-json-ia-sin-errores-openai-claude-2026/).

## Comparativa: ¿cuál es la opción más barata para cada caso?

| Caso de uso | Mejor opción gratis |
|-------------|-------------------|
| **Chatbot personal** | Google Gemini (gratis) |
| **App con pocas peticiones** | OpenAI GPT-4.1 nano ($5 créditos) |
| **Prototipo rápido** | Groq + Llama 4 (gratis) |
| **Producción con tráfico** | Ollama local ($0) |
| **Máxima calidad** | Claude Haiku 3.5 ($5 créditos) |
| **Código / debugging** | Google Gemini 2.5 Pro (gratis) |

## Conclusión

No necesitas gastar $20/mes en ChatGPT Plus para usar IA en tus proyectos. La API es **mucho más barata** y te da más control. Empezá con Google AI Studio (gratis) o Groq, y solo pagá cuando tu proyecto lo justifique.

Cuando empieces a pagar por API, usa [prompt caching para ahorrar hasta un 90%](/blog/prompt-caching-openai-claude-ahorrar-tokens-2026/) y [caveman prompting para reducir un 70% los tokens de input](/blog/caveman-prompting-ahorrar-tokens-ia-2026/).

---

Para comparar todos los precios por token en detalle, consulta mi [calculadora de precios de IA](/blog/calculadora-precios-ia-2026/). Si no quieres usar APIs y prefieres herramientas con interfaz, mira las [mejores alternativas gratis a ChatGPT](/blog/alternativas-gratis-chatgpt-2026/).

¿Listo para construir algo con estas APIs? Sigue mi [tutorial para crear un agente de IA con LangChain](/blog/crear-agente-ia-langchain-nodejs-tutorial/). O si quieres conectar la IA con tus propias APIs y bases de datos, aprende a usar [function calling en OpenAI y Claude](/blog/function-calling-openai-claude-conectar-ia-apis-2026/).

---

¿Quieres ver proyectos que usan estas APIs? En [mi portfolio](/) muestro apps reales construidas con IA y sus stacks.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Enviar Emails con Node.js en 2026: Nodemailer + Resend (Guía Completa)]]></title>
      <link>https://francobosg.netlify.app/blog/enviar-emails-nodejs-nodemailer-resend-2026/</link>
      <description><![CDATA[Tutorial actualizado para enviar emails con Node.js usando Nodemailer con SMTP y Resend con su SDK. Includes HTML templates, adjuntos y gestión de errores.]]></description>
      <pubDate>Thu, 05 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/enviar-emails-nodejs-nodemailer-resend-2026/</guid>
      <category>Node.js</category>
      <category>Backend</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Enviar emails con Node.js parece sencillo hasta que tus mensajes acaban en spam, el token de Gmail expira a las 24h o el SDK de turno tiene breaking changes sin documentar. Esta guía cubre las dos opciones que funcionan en 2026.

## Opción 1: Resend (la opción moderna)

[Resend](https://resend.com) es el servicio diseñado para developers. Tier gratuito generoso, SDK limpio y soporte para React Email.

### Instalar

```bash
npm install resend
```

### Envío básico

```typescript
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

async function enviarBienvenida(email: string, nombre: string) {
  const { data, error } = await resend.emails.send({
    from: 'Fran <hola@tudominio.com>',
    to: email,
    subject: `Bienvenido, ${nombre}`,
    html: `
      <h1>Hola ${nombre} 👋</h1>
      <p>Gracias por registrarte. Ya puedes acceder a tu cuenta.</p>
      <a href="https://tuapp.com/dashboard">Ir al dashboard</a>
    `,
  });

  if (error) {
    console.error('Error enviando email:', error);
    throw new Error(error.message);
  }

  return data;
}
```

### Con React Email (templates reutilizables)

```bash
npm install @react-email/components
```

```tsx
// emails/bienvenida.tsx
import { Html, Head, Body, Container, Heading, Text, Button } from '@react-email/components';

interface BienvenidaEmailProps {
  nombre: string;
  loginUrl: string;
}

export default function BienvenidaEmail({ nombre, loginUrl }: BienvenidaEmailProps) {
  return (
    <Html>
      <Head />
      <Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f4f4f4' }}>
        <Container style={{ maxWidth: '560px', margin: '0 auto', padding: '20px' }}>
          <Heading>Hola, {nombre}</Heading>
          <Text>Gracias por registrarte en nuestra plataforma.</Text>
          <Button href={loginUrl} style={{ backgroundColor: '#6366f1', color: '#fff', padding: '12px 24px' }}>
            Acceder a mi cuenta
          </Button>
        </Container>
      </Body>
    </Html>
  );
}
```

```typescript
// Usar el template con Resend
import { render } from '@react-email/render';
import BienvenidaEmail from './emails/bienvenida';

async function enviarConTemplate(email: string, nombre: string) {
  const html = await render(BienvenidaEmail({ nombre, loginUrl: 'https://tuapp.com/login' }));

  return resend.emails.send({
    from: 'Fran <hola@tudominio.com>',
    to: email,
    subject: `Bienvenido, ${nombre}`,
    html,
  });
}
```

---

## Opción 2: Nodemailer con SMTP

Nodemailer sigue siendo la opción estándar cuando necesitas SMTP directo (Gmail corporativo, Outlook 365, servidor propio).

```bash
npm install nodemailer
npm install -D @types/nodemailer
```

### Configurar el transporter

```typescript
import nodemailer from 'nodemailer';

// Para Gmail (requiere App Password, no la contraseña normal)
const transporter = nodemailer.createTransport({
  service: 'gmail',
  auth: {
    user: process.env.GMAIL_USER,
    pass: process.env.GMAIL_APP_PASSWORD, // App Password de Google, no tu contraseña
  },
});

// Para servidor SMTP genérico (Brevo, Mailgun, etc.)
const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,      // smtp.brevo.com
  port: Number(process.env.SMTP_PORT), // 587
  secure: false,                     // true para 465, false para otros
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});
```

### Enviar email

```typescript
async function enviarEmail(to: string, subject: string, html: string) {
  const info = await transporter.sendMail({
    from: '"Mi App" <hola@tudominio.com>',
    to,
    subject,
    html,
    // Adjunto
    attachments: [
      {
        filename: 'factura.pdf',
        path: './facturas/factura-001.pdf',
        contentType: 'application/pdf',
      },
    ],
  });

  console.log('Email enviado:', info.messageId);
  return info;
}
```

### Configurar Gmail correctamente

Google desactivó el acceso con contraseña normal. Necesitas una **App Password**:

1. Ve a tu cuenta Google → Seguridad → Verificación en dos pasos (actívala si no está)
2. Busca "Contraseñas de aplicaciones"
3. Genera una para "Correo" → "Otro"
4. Esa cadena de 16 dígitos es `GMAIL_APP_PASSWORD`

---

## Probar emails en desarrollo: Mailtrap

Nunca envíes emails reales en desarrollo. Usa Mailtrap:

```typescript
// .env.development
SMTP_HOST=sandbox.smtp.mailtrap.io
SMTP_PORT=2525
SMTP_USER=tu_usuario_mailtrap
SMTP_PASS=tu_contraseña_mailtrap
```

Todos los emails que envíes aparecen en el inbox de Mailtrap sin llegar a nadie.

---

## Entregabilidad: por qué acaban en spam

La configuración DNS es tan importante como el código. Necesitas tres registros en tu dominio:

```
# SPF — declara qué servidores pueden enviar en tu nombre
TXT  @  "v=spf1 include:_spf.resend.com ~all"

# DKIM — firma criptográfica de cada email
TXT  resend._domainkey  "v=DKIM1; k=rsa; p=MIGf..."  (te lo da Resend/SendGrid)

# DMARC — política para emails que fallan SPF o DKIM
TXT  _dmarc  "v=DMARC1; p=none; rua=mailto:dmarc@tudominio.com"
```

Si usas [variables de entorno correctamente en Node.js](/blog/variables-de-entorno-nodejs-nextjs-guia-2026/), las claves de Resend/SMTP nunca llegarán al repositorio.

Para integrar el envío de emails con la autenticación, mira [la guía de Auth.js con Next.js](/blog/auth-js-nextauth-implementacion-real-2026/) — el flujo de "olvide mi contraseña" usa exactamente estos patrones.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Crear un Agente de IA con LangChain y Node.js (2026)]]></title>
      <link>https://francobosg.netlify.app/blog/crear-agente-ia-langchain-nodejs-tutorial/</link>
      <description><![CDATA[Tutorial paso a paso para construir un agente de IA con LangChain y Node.js. Con búsqueda web, RAG, memoria y API REST. Código completo.]]></description>
      <pubDate>Mon, 02 Mar 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/crear-agente-ia-langchain-nodejs-tutorial/</guid>
      <category>IA</category>
      <category>Tutorial</category>
      <category>LangChain</category>
      <content:encoded><![CDATA[Los agentes de IA son la pieza central del desarrollo moderno. En este tutorial vas a construir uno **desde cero** que puede buscar en internet, consultar documentos y ejecutar acciones.

## ¿Qué vamos a construir?

Un agente que:
1. Recibe una pregunta del usuario
2. Decide qué herramientas usar (búsqueda web, base de conocimiento, calculadora)
3. Ejecuta las herramientas necesarias
4. Sintetiza una respuesta final

**Stack**: Node.js + TypeScript + LangChain.js + OpenAI GPT-4.1

## Requisitos previos

- Node.js 20+
- Una API key de OpenAI
- Conocimientos básicos de TypeScript

## Paso 1: Setup del proyecto

```bash
mkdir mi-agente-ia && cd mi-agente-ia
npm init -y
npm install langchain @langchain/openai @langchain/community dotenv
npm install -D typescript @types/node
npx tsc --init
```

Crea un `.env`:
```
OPENAI_API_KEY=sk-...
```

## Paso 2: El agente básico

```typescript
// src/agent.ts
import { ChatOpenAI } from '@langchain/openai';
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { Calculator } from '@langchain/community/tools/calculator';
import 'dotenv/config';

const llm = new ChatOpenAI({
  model: 'gpt-4.1-mini',
  temperature: 0,
});

const tools = [new Calculator()];

const prompt = ChatPromptTemplate.fromMessages([
  ['system', 'Eres un asistente útil. Usa las herramientas disponibles cuando sea necesario.'],
  ['human', '{input}'],
  ['placeholder', '{agent_scratchpad}'],
]);

const agent = createToolCallingAgent({ llm, tools, prompt });
const executor = new AgentExecutor({ agent, tools });

const result = await executor.invoke({
  input: '¿Cuánto es 15% de 2.340€?',
});

console.log(result.output);
// → "El 15% de 2.340€ es 351€"
```

El agente **decide solo** que necesita la calculadora, la usa, y formula la respuesta.

> Esta selección automática de herramientas funciona gracias al **function calling**. Si quieres entender cómo definir herramientas, manejar respuestas y conectar la IA con APIs externas sin LangChain, consulta el tutorial de [function calling en OpenAI y Claude](/blog/function-calling-openai-claude-conectar-ia-apis-2026/).

## Paso 3: Añadir búsqueda web

```typescript
import { TavilySearch } from '@langchain/community/tools/tavily_search';

const searchTool = new TavilySearch({
  apiKey: process.env.TAVILY_API_KEY,
  maxResults: 3,
});

const tools = [new Calculator(), searchTool];
```

Ahora el agente puede buscar información actualizada en internet.

## Paso 4: RAG — Consultar tus propios documentos

Esta es la killer feature. El agente puede consultar una base de conocimiento propia.

```typescript
import { OpenAIEmbeddings } from '@langchain/openai';
import { MemoryVectorStore } from 'langchain/vectorstores/memory';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
import { createRetrieverTool } from 'langchain/tools/retriever';

// 1. Cargar y dividir documentos
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200,
});

const docs = await splitter.createDocuments([
  'Tu documentación interna va aquí...',
  'Políticas de la empresa, FAQs, etc.',
]);

// 2. Crear vectorstore
const vectorStore = await MemoryVectorStore.fromDocuments(
  docs,
  new OpenAIEmbeddings()
);

// 3. Crear herramienta de retrieval
const retrieverTool = createRetrieverTool(vectorStore.asRetriever(), {
  name: 'knowledge_base',
  description: 'Busca información en la base de conocimiento interna.',
});

const tools = [new Calculator(), searchTool, retrieverTool];
```

Ahora el agente tiene 3 herramientas y **elige cuál usar** según la pregunta.

## Paso 5: Memoria conversacional

```typescript
import { BufferMemory } from 'langchain/memory';

const memory = new BufferMemory({
  memoryKey: 'chat_history',
  returnMessages: true,
});

const executor = new AgentExecutor({
  agent,
  tools,
  memory,
});

// Primera pregunta
await executor.invoke({ input: 'Busca el precio de GPT-4.1' });
// Segunda pregunta (recuerda el contexto)
await executor.invoke({ input: '¿Y cuánto costaría procesar 1M de tokens?' });
```

## Paso 6: API REST con Express

```typescript
import express from 'express';

const app = express();
app.use(express.json());

app.post('/chat', async (req, res) => {
  const { message } = req.body;
  const result = await executor.invoke({ input: message });
  res.json({ response: result.output });
});

app.listen(3000, () => console.log('Agente escuchando en :3000'));
```

## Arquitectura final

```
Usuario → API REST → AgentExecutor → LLM (GPT-4.1)
                                        ↓
                              ¿Qué herramienta uso?
                              ├── Calculator
                              ├── TavilySearch (web)
                              └── KnowledgeBase (RAG)
                                        ↓
                              Respuesta sintetizada
```

## Cómo Desplegar un Agente LangChain en Producción

1. **Usa GPT-4.1 mini para el 90% de casos** — es 5x más barato y suficiente para la mayoría de agentes
2. **Limita las iteraciones** — `maxIterations: 5` en el executor para evitar loops infinitos
3. **Logging** — Activa `verbose: true` para ver qué decide el agente en cada paso
4. **Fallback** — Si el agente falla, devuelve una respuesta por defecto en vez de un error
5. **Rate limiting** — Implementa cola de peticiones para no exceder límites de la API. Si tu agente recibe errores 429, aquí explico [cómo diagnosticarlos y solucionarlos](/blog/error-429-too-many-requests-api-ia-2026/)
6. **Context length** — Si las respuestas se truncan o el agente falla con documentos largos, revisa [cómo solucionar el error context length exceeded](/blog/error-context-length-exceeded-openai-claude-2026/)

## ¿Cuánto cuesta ejecutar este agente?

Con GPT-4.1 mini y un promedio de 3 tool calls por consulta:
- **~2.000 tokens por consulta** (input + output)
- **$0.003 por consulta**
- **1.000 consultas/día = ~$3/día = ~$90/mes**

Con GPT-4.1 nano sería **~$15/mes** para el mismo volumen. Consulta la [calculadora de precios de IA](/blog/calculadora-precios-ia-2026/) para comparar todos los modelos. Si quieres usar APIs sin pagar, mira cómo [usar la API de ChatGPT y Claude gratis](/blog/usar-api-chatgpt-claude-gratis-2026/).

¿Prefieres un chatbot más sencillo? Sigue mi [tutorial para crear un chatbot RAG con OpenAI](/blog/crear-chatbot-rag-openai-tutorial-2026/).

---

*Este es el tipo de proyecto que construyo profesionalmente. Mira cómo apliqué Gemini y GPT en un [ecosistema real de IA para reuniones](/blog/caso-real-ia-reuniones-gemini-supabase/) con transcripción, diarización y generación de tickets automática. Si necesitas un agente o integración de IA para tu empresa, consulta mis [servicios de desarrollo](/blog/servicios/).*]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Prisma desde Cero: Tutorial Completo en Español 2026 (Schema, Migrate, Queries)]]></title>
      <link>https://francobosg.netlify.app/blog/prisma-desde-cero-tutorial-completo-2026/</link>
      <description><![CDATA[Aprende Prisma ORM desde cero: instalar, definir el schema, migraciones, queries CRUD, relaciones y patrones avanzados. El tutorial en español más completo de 2026.]]></description>
      <pubDate>Sat, 28 Feb 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/prisma-desde-cero-tutorial-completo-2026/</guid>
      <category>Prisma</category>
      <category>Backend</category>
      <category>Node.js</category>
      <content:encoded><![CDATA[Prisma es el ORM más usado en el ecosistema Node.js/TypeScript en 2026 y con razón: el tipado automático desde el schema elimina toda una categoría de bugs que otros ORMs no previenen.

## Instalación

```bash
# Crear proyecto nuevo o ir a uno existente
npm init -y
npm install typescript ts-node @types/node --save-dev
npm install @prisma/client
npm install prisma --save-dev

# Inicializar Prisma
npx prisma init --datasource-provider postgresql
```

Esto crea:
- `prisma/schema.prisma` — define tus modelos
- `.env` con `DATABASE_URL`

---

## Configurar la base de datos

```bash
# .env
DATABASE_URL="postgresql://usuario:contraseña@localhost:5432/mi_app_db"

# Para desarrollo con SQLite (sin instalar nada)
# DATABASE_URL="file:./dev.db"
```

---

## Definir el schema

```prisma
// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  nombre    String
  plan      String   @default("free")
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relaciones
  posts     Post[]
  profile   Profile?
}

model Profile {
  id      String  @id @default(cuid())
  bio     String?
  avatar  String?
  userId  String  @unique
  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Post {
  id        String   @id @default(cuid())
  titulo    String
  contenido String?
  publicado Boolean  @default(false)
  autorId   String
  autor     User     @relation(fields: [autorId], references: [id])
  tags      Tag[]
  createdAt DateTime @default(now())
}

model Tag {
  id    String @id @default(cuid())
  nombre String @unique
  posts Post[]
}
```

---

## Migraciones

```bash
# Crear y aplicar migración (genera SQL)
npx prisma migrate dev --name crear_tablas_iniciales

# Ver el SQL que generó
cat prisma/migrations/*/migration.sql

# Aplicar migraciones en producción
npx prisma migrate deploy

# Reset completo (dev only) — borra todos los datos
npx prisma migrate reset
```

---

## Generar el cliente TypeScript

```bash
npx prisma generate
```

Ejecuta esto después de cada cambio en el schema. El cliente generado incluye los tipos de todos tus modelos.

---

## Queries CRUD

### Configurar el cliente (singleton)

```typescript
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ?? new PrismaClient({ log: ['error'] });

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
```

### Create

```typescript
import { prisma } from './lib/prisma';

// Crear usuario
const usuario = await prisma.user.create({
  data: {
    email: 'fran@ejemplo.com',
    nombre: 'Fran',
  },
});

// Crear con relación anidada
const usuarioConProfile = await prisma.user.create({
  data: {
    email: 'ana@ejemplo.com',
    nombre: 'Ana',
    profile: {
      create: {
        bio: 'Desarrolladora frontend',
        avatar: 'https://ejemplo.com/avatar.jpg',
      },
    },
  },
  include: { profile: true }, // incluye el profile en la respuesta
});
```

### Read

```typescript
// Buscar por id
const user = await prisma.user.findUnique({
  where: { id: '...' },
});

// Buscar con condiciones
const usersPro = await prisma.user.findMany({
  where: {
    plan: 'pro',
    createdAt: {
      gte: new Date('2026-01-01'), // mayor o igual que
    },
  },
  orderBy: { createdAt: 'desc' },
  take: 10,    // LIMIT
  skip: 0,     // OFFSET
});

// Incluir relaciones
const postConAutor = await prisma.post.findUnique({
  where: { id: '...' },
  include: {
    autor: {
      select: { nombre: true, email: true }, // select solo lo que necesitas
    },
    tags: true,
  },
});

// Select específico (más eficiente que include completo)
const emails = await prisma.user.findMany({
  select: { email: true, nombre: true },
});
```

### Update

```typescript
// Actualizar uno
const actualizado = await prisma.user.update({
  where: { id: '...' },
  data: { plan: 'pro' },
});

// Upsert — crea si no existe, actualiza si existe
const usuario = await prisma.user.upsert({
  where: { email: 'fran@ejemplo.com' },
  create: {
    email: 'fran@ejemplo.com',
    nombre: 'Fran',
  },
  update: {
    nombre: 'Fran Actualizado',
  },
});

// Actualizar muchos
await prisma.user.updateMany({
  where: { plan: 'trial' },
  data: { plan: 'free' },
});
```

### Delete

```typescript
// Borrar uno
await prisma.user.delete({
  where: { id: '...' },
});

// Borrar muchos
await prisma.user.deleteMany({
  where: {
    createdAt: { lt: new Date('2025-01-01') },
    plan: 'free',
  },
});
```

---

## Transacciones

```typescript
// Transacción — si algo falla, todo se revierte
const resultado = await prisma.$transaction(async (tx) => {
  const usuario = await tx.user.create({
    data: { email: 'nuevo@ejemplo.com', nombre: 'Nuevo' },
  });

  const post = await tx.post.create({
    data: {
      titulo: 'Mi primer post',
      autorId: usuario.id,
    },
  });

  return { usuario, post };
});
```

---

## Paginación

```typescript
async function getPaginatedPosts(page: number, perPage = 10) {
  const [posts, total] = await prisma.$transaction([
    prisma.post.findMany({
      where: { publicado: true },
      orderBy: { createdAt: 'desc' },
      skip: (page - 1) * perPage,
      take: perPage,
    }),
    prisma.post.count({ where: { publicado: true } }),
  ]);

  return {
    posts,
    totalPages: Math.ceil(total / perPage),
    currentPage: page,
  };
}
```

---

## Prisma Studio: UI para tu base de datos

```bash
npx prisma studio
```

Abre una interfaz web en `localhost:5555` para ver y editar los datos sin escribir SQL.

---

## Consejos para producción

```bash
# Genera el cliente antes de iniciar la app
{
  "scripts": {
    "postinstall": "prisma generate",
    "build": "prisma generate && tsc",
    "start": "prisma migrate deploy && node dist/index.js"
  }
}
```

Si tu proyecto usa TypeScript, las queries de Prisma te darán autocompletado completo. Combina esto con la guía de [TypeScript para developers JavaScript](/blog/typescript-para-javascript-developers-guia-2026/) para aprovechar todo el tipado.

Para elegir la base de datos que usarás con Prisma, la comparativa de [PostgreSQL vs MySQL](/blog/postgresql-vs-mysql-cual-elegir-2026/) te ayuda a decidir.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Automatizar Tareas con IA usando n8n y Make (Guía Práctica 2026)]]></title>
      <link>https://francobosg.netlify.app/blog/automatizar-tareas-ia-n8n-make-2026/</link>
      <description><![CDATA[6 automatizaciones listas para copiar con n8n y Make: emails, redes sociales, formularios y más. Con código, capturas y costes reales por flujo.]]></description>
      <pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/automatizar-tareas-ia-n8n-make-2026/</guid>
      <category>IA</category>
      <category>Tutorial</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[La IA no sirve de nada si la usas manualmente para todo. El poder real está en **automatizar**: que la IA trabaje sola mientras tú haces otra cosa. Las dos mejores herramientas para esto son **n8n** (open-source) y **Make** (antes Integromat).

## n8n vs Make: ¿cuál elegir?

| Característica | n8n | Make |
|---------------|-----|------|
| **Precio** | Gratis (self-hosted) | Desde $9/mes |
| **Open-source** | ✅ Sí | ❌ No |
| **Self-hosted** | ✅ Docker/VPS | ❌ Solo cloud |
| **Cloud hosted** | Desde $24/mes | Desde $9/mes |
| **Nodos de IA** | ✅ OpenAI, Anthropic, Ollama | ✅ OpenAI, Anthropic |
| **Modelos locales** | ✅ Ollama integration | ❌ No |
| **Complejidad** | Media (algo técnico) | Baja (drag & drop) |
| **Comunidad** | Grande y creciendo | Muy grande |
| **Integraciones** | 400+ | 1500+ |

**Mi recomendación**: Si eres developer, **n8n self-hosted**. Es gratis, puedes usar modelos locales y tienes control total. Si no quieres montar servidor, **Make** es más fácil.

## Automatización 1: Resumir emails importantes

**Problema**: Recibes 50 emails al día y no tienes tiempo de leerlos todos.

**Solución**: La IA lee tus emails, los clasifica y te manda un resumen por Telegram/Slack.

### Flujo en n8n

```
Trigger: Gmail (cada 15 min)
  → Filter: Solo emails no leídos con >100 palabras
  → AI Agent: OpenAI GPT-4.1 nano
      Prompt: "Clasifica este email como [urgente/importante/spam/informativo].
               Resume en 1 línea. Email: {{$json.body}}"
  → IF: clasificación == "urgente" o "importante"
  → Telegram: Enviar resumen al chat personal
```

### Coste real
- n8n: $0 (self-hosted)
- GPT-4.1 nano: ~$0.01/día (50 emails × ~500 tokens)
- **Total: $0.30/mes**

## Automatización 2: Publicar en redes desde un doc

**Problema**: Escribes contenido pero publicar en cada red social es tedioso.

**Solución**: Escribes en Google Docs/Notion → la IA adapta el tono para cada red → publica automáticamente.

### Flujo

```
Trigger: Google Sheets (nueva fila = nuevo contenido)
  → AI Agent: Claude Haiku
      Prompt 1: "Adapta este texto para Twitter en <280 caracteres.
                 Tono casual, con emoji. Texto: {{$json.contenido}}"
      Prompt 2: "Adapta para LinkedIn. Profesional, con párrafos cortos.
                 Texto: {{$json.contenido}}"
  → Twitter API: Publicar versión Twitter
  → LinkedIn API: Publicar versión LinkedIn
  → Slack: Notificar "Publicado en 2 redes"
```

## Automatización 3: Monitorear competencia

**Problema**: Quieres saber qué hace tu competencia sin revisar sus webs cada día.

### Flujo

```
Trigger: Schedule (diario, 9:00)
  → HTTP Request: Scrape web del competidor con Browserless/Apify
  → AI Agent: Gemini 2.5 Flash
      Prompt: "Compara este contenido con la versión anterior. 
               ¿Hay cambios relevantes? ¿Precios nuevos? ¿Productos nuevos?
               Anterior: {{$json.previous}} | Actual: {{$json.current}}"
  → IF: hay cambios relevantes
  → Email/Slack: "Cambios detectados en [competidor]: [resumen]"
  → Google Sheets: Guardar histórico
```

## Automatización 4: Procesar formularios con IA

**Problema**: Recibes formularios de contacto y necesitas clasificarlos y responder rápido.

### Flujo

```
Trigger: Webhook (formulario web)
  → AI Agent: GPT-4.1 nano
      Prompt: "Analiza este lead de contacto:
               Nombre: {{$json.name}}
               Mensaje: {{$json.message}}
               
               Clasifica como: [proyecto web, consulta, spam, otro].
               Genera un email de respuesta profesional y personalizado.
               Estima prioridad: [alta/media/baja]."
  → IF: No es spam
  → Gmail: Enviar respuesta auto-generada (como borrador)
  → Notion: Crear entrada en CRM con clasificación
  → Slack: Notificar si prioridad == alta
```

## Automatización 5: Transcribir y resumir reuniones

### Flujo

```
Trigger: Google Drive (nuevo archivo .mp3/.mp4)
  → Whisper API / AssemblyAI: Transcribir audio
  → AI Agent: Claude Sonnet 4
      Prompt: "De esta transcripción extrae:
               1. Resumen ejecutivo (3 líneas)
               2. Action items con responsable
               3. Decisiones tomadas
               4. Temas pendientes
               Transcripción: {{$json.transcript}}"
  → Notion: Crear página con el resumen estructurado
  → Slack: Enviar a canal del equipo
```

## Automatización 6: Generar contenido para el blog

Meta, pero funciona:

### Flujo

```
Trigger: Manual o Schedule (semanal)
  → AI Agent: Claude Opus 4
      Prompt: "Genera 5 ideas de artículos de blog sobre [tu nicho].
               Para cada una: título SEO, meta description, 
               3 keywords principales, outline de H2s."
  → Google Sheets: Guardar ideas con fecha
  → IF: idea aprobada (columna "aprobado" = true)
  → AI Agent: Claude Opus 4
      Prompt: "Escribe el artículo completo siguiendo este outline..."
  → Google Docs: Crear borrador para revisión
```

## Tutorial rápido: tu primera automatización con n8n

### 1. Instalar n8n con Docker

```bash
docker run -it --rm \
  --name n8n \
  -p 5678:5678 \
  -v n8n_data:/home/node/.n8n \
  n8nio/n8n
```

Abre `http://localhost:5678` y ya tienes n8n corriendo.

### 2. Crear workflow básico

1. **Add Trigger** → Schedule (cada hora)
2. **Add Node** → HTTP Request (llama a una API)
3. **Add Node** → OpenAI (procesa la respuesta con IA)
4. **Add Node** → Slack/Email (envía resultado)
5. **Activate** el workflow

### 3. Conectar OpenAI

1. Ve a **Credentials** → **Add Credential** → **OpenAI API**
2. Pega tu API key
3. En el nodo OpenAI:
   - Model: `gpt-4.1-nano` (el más barato)
   - Prompt: Tu instrucción
   - Max tokens: 500

## Costes reales de automatización

| Automatización | Ejecuciones/mes | Coste IA | Coste total |
|---------------|-----------------|----------|-------------|
| Resumen emails | 1500 | $0.30 | $0.30 |
| Publicar redes | 60 | $0.10 | $0.10 |
| Monitor competencia | 30 | $0.05 | $0.05 |
| Procesar formularios | 100 | $0.02 | $0.02 |
| Transcribir reuniones | 8 | $2.00 | $2.00 |

**Total: ~$2.50/mes** por 5 automatizaciones corriendo 24/7. Con n8n self-hosted no hay coste adicional de plataforma.

## Tips para automatizaciones con IA

1. **Usa el modelo más barato que funcione** — GPT-4.1 nano sirve para el 80% de las tareas
2. **Siempre pon fallbacks** — Si la API de OpenAI falla, que pruebe con Gemini
3. **Logea todo** — Guarda inputs/outputs para debugging
4. **Rate limiting** — No hagas 100 llamadas simultáneas, usa delays
5. **Prompt engineering** — Un buen prompt ahorra tokens y mejora resultados
6. **Cachea** — Si el mismo input se repite, no llames a la API de nuevo

Si quieres crear agentes de IA más avanzados, sigue mi [tutorial para crear un agente con LangChain y Node.js](/blog/crear-agente-ia-langchain-nodejs-tutorial/). Para las APIs más baratas para tus flujos, consulta cómo [usar la API de ChatGPT y Claude gratis](/blog/usar-api-chatgpt-claude-gratis-2026/). Y si encuentras errores 429 frecuentes al llamar APIs, consulta la [guía para solucionar Error 429 Too Many Requests](/blog/error-429-too-many-requests-api-ia-2026/).

---

¿Te interesa ver más proyectos de automatización y desarrollo? En [mi portfolio](/) muestro lo que construyo con estas herramientas.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Mejores IAs para Transcribir Audio y Vídeo en 2026 (Comparativa)]]></title>
      <link>https://francobosg.netlify.app/blog/mejores-ia-para-transcribir-2026/</link>
      <description><![CDATA[Whisper, AssemblyAI o Deepgram: ¿cuál transcribe mejor en 2026? Benchmark real con precios, precisión y código de ejemplo.]]></description>
      <pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/mejores-ia-para-transcribir-2026/</guid>
      <category>IA</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[Necesitas transcribir una reunión, un podcast, un vídeo de YouTube o las llamadas de tu empresa. ¿Qué IA usas? Aquí tienes la comparativa definitiva en 2026.

## Ranking rápido

| Herramienta | Precisión | Velocidad | Precio | Mejor para |
|-------------|-----------|-----------|--------|------------|
| **Whisper V3 Turbo** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Gratis (local) | Desarrolladores, privacidad |
| **AssemblyAI** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | $0.12-0.65/min | Producción, diarización |
| **Deepgram Nova-3** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | $0.04-0.07/min | Tiempo real, precio |
| **Google Chirp 2** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | $0.016/min | Multiidioma, GCP |
| **ElevenLabs Scribe** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | $0.04/min | Calidad + multiidioma |
| **Gemini 2.5** | ⭐⭐⭐⭐ | ⭐⭐⭐ | $0.006/min | Resumen + análisis |

## Análisis detallado

### 🥇 Whisper V3 Turbo (OpenAI) — Gratis y local

El modelo de transcripción open source más popular del mundo.

**Pros:**
- **Gratis** — se ejecuta localmente en tu máquina
- Soporta **100+ idiomas** con detección automática
- V3 Turbo es 8x más rápido que V2 con la misma precisión
- Puedes usarlo en `Python` con 3 líneas de código

**Contras:**
- Necesitas GPU para velocidad razonable (o usar CPU con paciencia)
- No tiene diarización nativa (quién habla cuándo)
- Sin API oficial (solo modelo descargable)

```python
import whisper
model = whisper.load_model("turbo")
result = model.transcribe("audio.mp3")
print(result["text"])
```

**Mi veredicto**: La opción por defecto si eres desarrollador y tienes GPU. Para un podcast de 1 hora tarda ~3 minutos en una RTX 3060.

### 🥈 AssemblyAI — La mejor API profesional

**Pros:**
- Mejor precisión en inglés del mercado (menor WER)
- **Diarización** avanzada — identifica quién habla
- **Speaker labels**, detección de sentimiento, resúmenes automáticos
- Modelo Universal-2 específicamente entrenado para cada idioma

**Contras:**
- Solo API, no tiene app de escritorio
- El precio escala con las funciones que actives
- Español no es tan preciso como inglés

**Precios:**
| Función | Precio |
|---------|--------|
| Transcripción base | $0.12/min |
| + Diarización | $0.24/min |
| + Análisis de sentimiento | $0.35/min |
| LeMUR (IA sobre transcripción) | $0.65/min |

### 🥉 Deepgram Nova-3 — La más rápida y barata

**Pros:**
- Modelo Nova-3 con precisión superior a Whisper
- **Tiempo real** — streaming de audio con resultados instantáneos
- Precio agresivo: **$0.04/min** (pre-grabado)
- 36 idiomas soportados
- WebSocket API para transcripción en vivo

**Contras:**
- Menor ecosistema que AssemblyAI
- La documentación puede ser confusa al principio

**Ideal para**: Aplicaciones de tiempo real (call centers, subtítulos en vivo, asistentes de voz).

### Google Chirp 2 (Cloud Speech-to-Text V2)

**Pros:**
- **100+ idiomas** con calidad excelente
- El más barato para volúmenes altos ($0.016/min)
- Integración nativa con GCP
- Modelo Chirp 2 con mejoras significativas en 2025

**Contras:**
- Requiere cuenta de Google Cloud (setup más complejo)
- Facturación por GCP puede ser confusa
- Sin funciones avanzadas como diarización rich

### ElevenLabs Scribe — Nuevo competidor fuerte

**Pros:**
- 99 idiomas con detección automática
- Excelente precisión en español
- Diarización incluida en el precio
- $0.04/min — muy competitivo
- Timestamps a nivel de palabra

**Contras:**
- Servicio relativamente nuevo (menos track record)
- Mejor conocido por TTS, STT es secundario

### Gemini 2.5 — Transcripción + Análisis combo

**Pros:**
- Acepta archivos de audio directamente (hasta 9 horas)
- No solo transcribe: analiza, resume, y responde preguntas
- Precio imbatible para uso ligero ($0.006/min via API)
- 1M de tokens de contexto para audios largos

**Contras:**
- No es un modelo STT dedicado (menor precisión que especialistas)
- Latencia más alta que Deepgram/Whisper
- Sin streaming en tiempo real

**Ideal para**: "Transcríbeme esta reunión y hazme un resumen con action items" — todo en un solo paso.

## ¿Cuál elegir?

| Si necesitas... | Usa |
|-----------------|-----|
| Gratis y local | Whisper V3 Turbo |
| Mejor precisión en producción | AssemblyAI |
| Tiempo real / más barato | Deepgram Nova-3 |
| Volumen alto + multiidioma | Google Chirp 2 |
| Transcribir + resumir en un paso | Gemini 2.5 |
| Mejor español | ElevenLabs Scribe |

## Coste mensual estimado

Para un equipo que transcribe **20 horas de reuniones al mes**:

| Servicio | Coste mensual |
|----------|---------------|
| Whisper (local) | $0 (solo electricidad) |
| Deepgram Nova-3 | ~$48 |
| ElevenLabs Scribe | ~$48 |
| Google Chirp 2 | ~$19 |
| AssemblyAI (base) | ~$144 |
| Gemini 2.5 Flash | ~$7 |

Si quieres automatizar la transcripción con flujos de trabajo, mira mi guía para [automatizar tareas con IA usando n8n y Make](/blog/automatizar-tareas-ia-n8n-make-2026/). Y para comparar precios de todos los modelos de IA, consulta la [calculadora de precios](/blog/calculadora-precios-ia-2026/). Para un caso real de transcripción de reuniones con generación de tickets automática, lee el [caso práctico de IA con Gemini y Supabase](/blog/caso-real-ia-reuniones-gemini-supabase/).

---

*¿Necesitas integrar transcripción de IA en tu proyecto? Visita [mi portfolio](/) — tengo experiencia integrando estos servicios en aplicaciones de producción.*]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[WebSockets con Socket.io en Node.js y React: Tutorial Real 2026]]></title>
      <link>https://francobosg.netlify.app/blog/websockets-socketio-nodejs-react-2026/</link>
      <description><![CDATA[Implementa WebSockets en tiempo real con Socket.io 4 en Node.js y React. Chat, notificaciones, salas y autenticación con tokens JWT.]]></description>
      <pubDate>Sun, 15 Feb 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/websockets-socketio-nodejs-react-2026/</guid>
      <category>Node.js</category>
      <category>React</category>
      <category>Backend</category>
      <content:encoded><![CDATA[WebSockets es la tecnología detrás de cualquier feature en tiempo real: chats, notificaciones, dashboards live, juegos multijugador. Socket.io la simplifica enormemente.

## Setup básico: servidor Node.js

```bash
mkdir ws-app && cd ws-app
npm init -y
npm install express socket.io cors
npm install -D typescript @types/node ts-node nodemon
```

### Servidor

```typescript
// server/index.ts
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import cors from 'cors';

const app = express();
const httpServer = createServer(app);

const io = new Server(httpServer, {
  cors: {
    origin: process.env.CLIENT_URL || 'http://localhost:5173',
    methods: ['GET', 'POST'],
    credentials: true,
  },
});

// Middleware de autenticación
io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  
  if (!token) {
    return next(new Error('Token requerido'));
  }

  try {
    // Verifica el JWT
    const payload = verificarToken(token);
    socket.data.usuario = payload;
    next();
  } catch {
    next(new Error('Token inválido'));
  }
});

// Gestión de conexiones
io.on('connection', (socket) => {
  const usuario = socket.data.usuario;
  console.log(`Usuario conectado: ${usuario.nombre} (${socket.id})`);

  // Unirse a una sala
  socket.on('sala:unirse', (salaId: string) => {
    socket.join(salaId);
    socket.to(salaId).emit('sala:usuario-unido', {
      usuario: usuario.nombre,
      timestamp: new Date().toISOString(),
    });
    console.log(`${usuario.nombre} se unió a la sala ${salaId}`);
  });

  // Enviar mensaje a una sala
  socket.on('mensaje:enviar', (data: { salaId: string; texto: string }) => {
    const mensaje = {
      id: crypto.randomUUID(),
      texto: data.texto,
      autor: usuario.nombre,
      autorId: usuario.id,
      timestamp: new Date().toISOString(),
    };

    // Emite a todos en la sala (incluyendo el emisor)
    io.to(data.salaId).emit('mensaje:nuevo', mensaje);
  });

  // Notificar escritura
  socket.on('mensaje:escribiendo', (salaId: string) => {
    socket.to(salaId).emit('mensaje:usuario-escribiendo', usuario.nombre);
  });

  // Desconexión
  socket.on('disconnect', (reason) => {
    console.log(`${usuario.nombre} desconectado: ${reason}`);
  });
});

httpServer.listen(3001, () => {
  console.log('Servidor WebSocket en puerto 3001');
});
```

---

## Cliente React

```bash
npm install socket.io-client
```

### Hook personalizado para Socket.io

```typescript
// hooks/useSocket.ts
import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';

interface UseSocketOptions {
  token: string;
  serverUrl?: string;
}

export function useSocket({ token, serverUrl = 'http://localhost:3001' }: UseSocketOptions) {
  const socketRef = useRef<Socket | null>(null);
  const [conectado, setConectado] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const socket = io(serverUrl, {
      auth: { token },
      reconnection: true,
      reconnectionAttempts: 5,
      reconnectionDelay: 1000,
    });

    socket.on('connect', () => {
      setConectado(true);
      setError(null);
    });

    socket.on('disconnect', () => setConectado(false));

    socket.on('connect_error', (err) => {
      setError(err.message);
      setConectado(false);
    });

    socketRef.current = socket;

    return () => {
      socket.disconnect();
    };
  }, [token, serverUrl]);

  return { socket: socketRef.current, conectado, error };
}
```

### Componente de chat

```tsx
// components/Chat.tsx
import { useState, useEffect, useRef } from 'react';
import { useSocket } from '../hooks/useSocket';

interface Mensaje {
  id: string;
  texto: string;
  autor: string;
  autorId: string;
  timestamp: string;
}

interface ChatProps {
  salaId: string;
  token: string;
  usuarioId: string;
}

export function Chat({ salaId, token, usuarioId }: ChatProps) {
  const { socket, conectado } = useSocket({ token });
  const [mensajes, setMensajes] = useState<Mensaje[]>([]);
  const [input, setInput] = useState('');
  const [escribiendo, setEscribiendo] = useState<string | null>(null);
  const bottomRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!socket) return;

    // Unirse a la sala al montar
    socket.emit('sala:unirse', salaId);

    // Escuchar mensajes nuevos
    socket.on('mensaje:nuevo', (mensaje: Mensaje) => {
      setMensajes(prev => [...prev, mensaje]);
    });

    // Indicador de escritura
    socket.on('mensaje:usuario-escribiendo', (nombre: string) => {
      setEscribiendo(nombre);
      setTimeout(() => setEscribiendo(null), 2000);
    });

    return () => {
      socket.off('mensaje:nuevo');
      socket.off('mensaje:usuario-escribiendo');
    };
  }, [socket, salaId]);

  // Scroll al último mensaje
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [mensajes]);

  function enviar() {
    if (!input.trim() || !socket) return;

    socket.emit('mensaje:enviar', { salaId, texto: input });
    setInput('');
  }

  function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
    setInput(e.target.value);
    socket?.emit('mensaje:escribiendo', salaId);
  }

  return (
    <div className="flex flex-col h-full">
      <div className="flex-1 overflow-y-auto p-4 space-y-2">
        {mensajes.map(m => (
          <div
            key={m.id}
            className={`flex ${m.autorId === usuarioId ? 'justify-end' : 'justify-start'}`}
          >
            <div className={`max-w-xs px-3 py-2 rounded-lg text-sm ${
              m.autorId === usuarioId
                ? 'bg-blue-500 text-white'
                : 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
            }`}>
              {m.autorId !== usuarioId && (
                <p className="text-xs font-semibold mb-1 opacity-70">{m.autor}</p>
              )}
              <p>{m.texto}</p>
            </div>
          </div>
        ))}
        {escribiendo && (
          <p className="text-xs text-gray-400 italic">{escribiendo} está escribiendo...</p>
        )}
        <div ref={bottomRef} />
      </div>

      <div className="p-3 border-t dark:border-gray-700 flex gap-2">
        <input
          value={input}
          onChange={handleInput}
          onKeyDown={e => e.key === 'Enter' && enviar()}
          placeholder="Escribe un mensaje..."
          className="flex-1 border rounded-lg px-3 py-2 text-sm dark:bg-gray-800 dark:border-gray-600"
          disabled={!conectado}
        />
        <button
          onClick={enviar}
          disabled={!conectado || !input.trim()}
          className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg text-sm disabled:opacity-50"
        >
          Enviar
        </button>
      </div>
    </div>
  );
}
```

---

## Escalar con Redis (múltiples servidores)

Si tienes más de una instancia del servidor, los sockets de diferentes instancias no se comunican. La solución es el adaptador de Redis:

```bash
npm install @socket.io/redis-adapter redis
```

```typescript
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

io.adapter(createAdapter(pubClient, subClient));
```

---

## Problemas comunes

**Socket.io con Nginx:** añade estas cabeceras en tu config de proxy:

```nginx
location /socket.io/ {
  proxy_pass http://localhost:3001;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_set_header Host $host;
}
```

**CORS en producción:** especifica los orígenes exactos, nunca `origin: '*'` en producción. Puedes ver la guía completa de [CORS en Node.js](/blog/cors-nodejs-express-configuracion-produccion-2026/).

Para la autenticación con JWT en el middleware de Socket.io, aplica los mismos principios que en [proteger una API Node.js con JWT](/blog/proteger-api-nodejs-jwt-auth-guia-2026/).]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Crear un Chatbot con RAG y OpenAI desde Cero (2026)]]></title>
      <link>https://francobosg.netlify.app/blog/crear-chatbot-rag-openai-tutorial-2026/</link>
      <description><![CDATA[Crea un chatbot que responde con TUS datos: tutorial RAG paso a paso con Node.js, OpenAI y ChromaDB. Código completo.]]></description>
      <pubDate>Wed, 11 Feb 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/crear-chatbot-rag-openai-tutorial-2026/</guid>
      <category>IA</category>
      <category>Tutorial</category>
      <category>LangChain</category>
      <content:encoded><![CDATA[Un chatbot normal con ChatGPT solo sabe lo que GPT aprendió en su entrenamiento. Un chatbot con **RAG** sabe eso + **tus propios documentos**. Puede responder preguntas sobre tu código, tus PDFs, tu base de datos interna, lo que sea. Para un caso real de IA + documentos en producción, mira el [ecosistema de IA para reuniones con Gemini y Supabase](/blog/caso-real-ia-reuniones-gemini-supabase/).

En este tutorial construimos uno desde cero con Node.js.

## ¿Qué es RAG?

**Retrieval-Augmented Generation** = antes de responder, la IA busca información relevante en tus documentos y la usa como contexto.

```
Flujo RAG:
1. Usuario pregunta algo
2. El sistema busca documentos relevantes en tu base de datos
3. Los documentos se pasan como contexto al LLM
4. El LLM responde usando esa información específica
```

**Sin RAG**: "¿Cuál es la política de devoluciones?" → La IA inventa algo genérico.

**Con RAG**: La IA busca en tu documento de políticas y cita la información real.

## Stack que usamos

| Componente | Tecnología | Por qué |
|-----------|-----------|---------|
| **Runtime** | Node.js 20+ | Universal, async nativo |
| **LLM** | OpenAI GPT-4.1 mini | Buena calidad, barato |
| **Framework** | LangChain.js | Simplifica la cadena RAG |
| **Embeddings** | OpenAI text-embedding-3-small | Rápido y preciso |
| **Vector DB** | ChromaDB | Fácil setup, open-source |
| **Frontend** | HTML + Vanilla JS | Sin complejidad extra |

## Paso 1: Setup del proyecto

```bash
mkdir mi-chatbot-rag
cd mi-chatbot-rag
npm init -y
```

```bash
npm install langchain @langchain/openai @langchain/community \
  chromadb chromadb-default-embed express dotenv
```

Crear `.env`:

```env
OPENAI_API_KEY=sk-tu-api-key-aqui
PORT=3000
```

## Paso 2: Cargar documentos

Creamos un script que lee tus documentos y los convierte en vectores (embeddings).

```javascript
// src/ingest.js
import { DirectoryLoader } from 'langchain/document_loaders/fs/directory';
import { TextLoader } from 'langchain/document_loaders/fs/text';
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
import { OpenAIEmbeddings } from '@langchain/openai';
import { Chroma } from '@langchain/community/vectorstores/chroma';
import 'dotenv/config';

async function ingestDocs() {
  console.log('Cargando documentos...');
  
  // Cargar archivos de la carpeta docs/
  const loader = new DirectoryLoader('./docs', {
    '.txt': (path) => new TextLoader(path),
    '.md': (path) => new TextLoader(path),
    '.pdf': (path) => new PDFLoader(path),
  });
  
  const rawDocs = await loader.load();
  console.log(`${rawDocs.length} documentos cargados`);
  
  // Dividir en chunks pequeños (mejor para búsqueda)
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 1000,      // 1000 caracteres por chunk
    chunkOverlap: 200,    // Solapamiento para no cortar contexto
  });

  // Estos parámetros son clave para no perder contexto.
  // Si tu chatbot sigue fallando con documentos largos, revisa las
  // técnicas avanzadas en: /blog/error-context-length-exceeded-openai-claude-2026/
  
  const docs = await splitter.splitDocuments(rawDocs);
  console.log(`${docs.length} chunks creados`);
  
  // Crear embeddings y guardar en ChromaDB
  const embeddings = new OpenAIEmbeddings({
    modelName: 'text-embedding-3-small', // $0.02 por 1M tokens
  });
  
  await Chroma.fromDocuments(docs, embeddings, {
    collectionName: 'mi-chatbot',
    url: 'http://localhost:8000', // ChromaDB local
  });
  
  console.log('Documentos indexados correctamente');
}

ingestDocs();
```

### Ejecutar ChromaDB

```bash
# Con Docker
docker run -p 8000:8000 chromadb/chroma

# O sin Docker
pip install chromadb
chroma run --path ./chroma_data
```

### Preparar documentos

Crea una carpeta `docs/` y mete ahí tus archivos:

```
docs/
  politicas.txt
  faq.md
  manual-producto.pdf
  precios.txt
```

```bash
node src/ingest.js
# Output: 15 documentos cargados → 87 chunks creados
```

## Paso 3: El motor de respuestas (RAG Chain)

```javascript
// src/ragChain.js
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
import { Chroma } from '@langchain/community/vectorstores/chroma';
import { PromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { RunnableSequence } from '@langchain/core/runnables';
import 'dotenv/config';

// Conectar a ChromaDB
const embeddings = new OpenAIEmbeddings({
  modelName: 'text-embedding-3-small',
});

const vectorStore = await Chroma.fromExistingCollection(embeddings, {
  collectionName: 'mi-chatbot',
  url: 'http://localhost:8000',
});

const retriever = vectorStore.asRetriever({
  k: 4, // Buscar los 4 chunks más relevantes
});

// Modelo
const llm = new ChatOpenAI({
  modelName: 'gpt-4.1-mini',
  temperature: 0.3, // Más determinista para info factual
});

// Prompt template
const promptTemplate = PromptTemplate.fromTemplate(`
Eres un asistente útil que responde preguntas basándote en la información proporcionada.

CONTEXTO (documentos relevantes):
{context}

REGLAS:
- Responde SOLO con información del contexto proporcionado
- Si no encuentras la respuesta en el contexto, di "No tengo información sobre eso"
- Sé conciso y directo
- Si citas datos específicos, menciona de qué documento vienen
- Responde en español

PREGUNTA: {question}

RESPUESTA:`);

// Cadena RAG
const ragChain = RunnableSequence.from([
  {
    context: async (input) => {
      const docs = await retriever.invoke(input.question);
      return docs.map(d => d.pageContent).join('\n\n---\n\n');
    },
    question: (input) => input.question,
  },
  promptTemplate,
  llm,
  new StringOutputParser(),
]);

export { ragChain };
```

## Paso 4: Servidor API

```javascript
// src/server.js
import express from 'express';
import { ragChain } from './ragChain.js';
import 'dotenv/config';

const app = express();
app.use(express.json());
app.use(express.static('public'));

// Endpoint del chat
app.post('/api/chat', async (req, res) => {
  try {
    const { question } = req.body;
    
    if (!question?.trim()) {
      return res.status(400).json({ error: 'Pregunta requerida' });
    }
    
    const answer = await ragChain.invoke({ question });
    
    res.json({ answer });
  } catch (error) {
    console.error('Error:', error.message);
    res.status(500).json({ error: 'Error al procesar la pregunta' });
  }
});

// Streaming (mejor UX)
app.post('/api/chat/stream', async (req, res) => {
  try {
    const { question } = req.body;
    
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    
    const stream = await ragChain.stream({ question });
    
    for await (const chunk of stream) {
      res.write(`data: ${JSON.stringify({ text: chunk })}\n\n`);
    }
    
    res.write('data: [DONE]\n\n');
    res.end();
  } catch (error) {
    res.status(500).json({ error: 'Error al procesar' });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Chatbot RAG corriendo en http://localhost:${PORT}`);
});
```

## Paso 5: Frontend simple

```html
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Chatbot RAG</title>
  <style>
    * { box-sizing: border-box; margin: 0; }
    body { font-family: system-ui; background: #0f172a; color: #e2e8f0; 
           display: flex; justify-content: center; padding: 2rem; }
    .chat { width: 100%; max-width: 600px; }
    .messages { height: 60vh; overflow-y: auto; padding: 1rem; 
                border: 1px solid #334155; border-radius: 12px; margin-bottom: 1rem; }
    .msg { padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 0.5rem; 
           max-width: 85%; line-height: 1.5; }
    .user { background: #7c3aed; margin-left: auto; }
    .bot { background: #1e293b; border: 1px solid #334155; }
    .input-row { display: flex; gap: 0.5rem; }
    input { flex: 1; padding: 0.75rem 1rem; border-radius: 8px; 
            border: 1px solid #334155; background: #1e293b; color: #e2e8f0; }
    button { padding: 0.75rem 1.5rem; border-radius: 8px; border: none; 
             background: #7c3aed; color: white; cursor: pointer; font-weight: 600; }
    button:hover { background: #6d28d9; }
  </style>
</head>
<body>
  <div class="chat">
    <h2 style="margin-bottom:1rem;">💬 Chatbot RAG</h2>
    <div class="messages" id="messages"></div>
    <div class="input-row">
      <input id="input" placeholder="Pregunta algo..." 
             onkeydown="if(event.key==='Enter')ask()">
      <button onclick="ask()">Enviar</button>
    </div>
  </div>
  <script>
    const messages = document.getElementById('messages');
    const input = document.getElementById('input');
    
    async function ask() {
      const q = input.value.trim();
      if (!q) return;
      
      addMsg(q, 'user');
      input.value = '';
      
      const botMsg = addMsg('Pensando...', 'bot');
      
      try {
        const res = await fetch('/api/chat', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ question: q })
        });
        const data = await res.json();
        botMsg.textContent = data.answer;
      } catch (e) {
        botMsg.textContent = 'Error al conectar con el servidor.';
      }
      
      messages.scrollTop = messages.scrollHeight;
    }
    
    function addMsg(text, type) {
      const div = document.createElement('div');
      div.className = `msg ${type}`;
      div.textContent = text;
      messages.appendChild(div);
      messages.scrollTop = messages.scrollHeight;
      return div;
    }
  </script>
</body>
</html>
```

## Paso 6: Ejecutar

```bash
# Terminal 1: ChromaDB
docker run -p 8000:8000 chromadb/chroma

# Terminal 2: Indexar documentos (solo la primera vez)
node src/ingest.js

# Terminal 3: Servidor
node src/server.js
```

Abre `http://localhost:3000` y pregunta lo que quieras sobre tus documentos.

## Mejoras para producción

### 1. Añadir historial de conversación

```javascript
// Mantener contexto entre mensajes
const chatHistory = [];

app.post('/api/chat', async (req, res) => {
  const { question } = req.body;
  
  chatHistory.push({ role: 'user', content: question });
  
  // Incluir historial en el contexto
  const historyText = chatHistory.slice(-6) // Últimos 3 pares
    .map(m => `${m.role}: ${m.content}`)
    .join('\n');
  
  const answer = await ragChain.invoke({ 
    question: `Historial:\n${historyText}\n\nPregunta actual: ${question}` 
  });
  
  chatHistory.push({ role: 'assistant', content: answer });
  res.json({ answer });
});
```

### 2. Usar modelos más baratos

| Modelo | Coste por consulta (~2K tokens) | Calidad |
|--------|-------------------------------|---------|
| GPT-4.1 | ~$0.006 | ⭐⭐⭐⭐⭐ |
| GPT-4.1 mini | ~$0.001 | ⭐⭐⭐⭐ |
| GPT-4.1 nano | ~$0.0003 | ⭐⭐⭐ |

Para un chatbot de FAQ, GPT-4.1 nano alcanza perfectamente y cada consulta cuesta **$0.0003** (3000 consultas = $1).

### 3. Alternativas gratuitas

Reemplaza OpenAI por Ollama para coste $0:

```javascript
import { ChatOllama } from '@langchain/community/chat_models/ollama';
import { OllamaEmbeddings } from '@langchain/community/embeddings/ollama';

const llm = new ChatOllama({ model: 'llama4' });
const embeddings = new OllamaEmbeddings({ model: 'nomic-embed-text' });
```

## Estructura final del proyecto

```
mi-chatbot-rag/
├── docs/                  # Tus documentos
│   ├── faq.md
│   └── politicas.txt
├── public/
│   └── index.html         # Frontend
├── src/
│   ├── ingest.js          # Indexar documentos
│   ├── ragChain.js        # Lógica RAG
│   └── server.js          # API Express
├── .env                   # API keys
└── package.json
```

## Costes totales

| Componente | Coste |
|----------|-------|
| ChromaDB | $0 (self-hosted) |
| Embeddings (indexar 100 docs) | ~$0.01 |
| GPT-4.1 nano (1000 consultas) | ~$0.30 |
| Hosting (VPS básico) | $5/mes |
| **Total** | **~$5/mes** |

Con Ollama local: **$0/mes** total. Para más detalle sobre los costes de cada modelo, consulta la [calculadora de precios de IA](/blog/calculadora-precios-ia-2026/).

Si quieres convertir este chatbot en un agente completo con herramientas y búsqueda web, sigue mi [tutorial para crear un agente de IA con LangChain](/blog/crear-agente-ia-langchain-nodejs-tutorial/). Y si buscas APIs gratuitas para tu chatbot, mira cómo [usar la API de ChatGPT y Claude gratis](/blog/usar-api-chatgpt-claude-gratis-2026/).

---

¿Quieres ver más proyectos que construí con IA? En [mi portfolio](/) muestro cada proyecto con su stack y proceso de desarrollo.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[TanStack Query v5 Tutorial en Español 2026: Data Fetching en React sin Dolor]]></title>
      <link>https://francobosg.netlify.app/blog/tanstack-query-v5-tutorial-2026/</link>
      <description><![CDATA[Domina TanStack Query v5 (React Query) en 2026: useQuery, useMutation, invalidación de caché, paginación y optimistic updates. El tutorial en español más completo.]]></description>
      <pubDate>Tue, 10 Feb 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/tanstack-query-v5-tutorial-2026/</guid>
      <category>React</category>
      <category>TypeScript</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[TanStack Query v5 cambió suficiente la API respecto a v4 que muchos tutoriales que encuentras están desactualizados. Esta guía es para v5, que es la versión actual en 2026.

## Instalación

```bash
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools  # herramientas de debug
```

## Configuración inicial

```tsx
// main.tsx (o app/layout.tsx en Next.js)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,    // 5 minutos antes de refetch
      gcTime: 1000 * 60 * 10,      // 10 minutos en caché (antes cacheTime)
      retry: 2,                     // reintentos en error
      refetchOnWindowFocus: true,   // revalida al volver a la tab
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <TuApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
```

---

## useQuery: leer datos

### Básico

```tsx
import { useQuery } from '@tanstack/react-query';

// Función de fetch (fuera del componente)
async function getUsuarios(): Promise<Usuario[]> {
  const res = await fetch('/api/usuarios');
  if (!res.ok) throw new Error('Error al cargar usuarios');
  return res.json();
}

function ListaUsuarios() {
  const { data: usuarios, isLoading, isError, error } = useQuery({
    queryKey: ['usuarios'],        // clave de caché única
    queryFn: getUsuarios,
  });

  if (isLoading) return <div>Cargando...</div>;
  if (isError) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {usuarios.map(u => (
        <li key={u.id}>{u.nombre}</li>
      ))}
    </ul>
  );
}
```

### Con parámetros dinámicos

```tsx
async function getUsuario(id: string): Promise<Usuario> {
  const res = await fetch(`/api/usuarios/${id}`);
  if (!res.ok) throw new Error('Usuario no encontrado');
  return res.json();
}

function PerfilUsuario({ userId }: { userId: string }) {
  const { data: usuario, isLoading } = useQuery({
    queryKey: ['usuarios', userId],   // ← incluye el parámetro en la key
    queryFn: () => getUsuario(userId),
    enabled: !!userId,                // no ejecuta si userId es undefined
  });

  if (isLoading) return <Skeleton />;
  return <div>{usuario?.nombre}</div>;
}
```

---

## useMutation: crear, actualizar, borrar

```tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';

async function crearPost(datos: { titulo: string; contenido: string }) {
  const res = await fetch('/api/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(datos),
  });
  if (!res.ok) throw new Error('Error al crear post');
  return res.json();
}

function FormNuevoPost() {
  const queryClient = useQueryClient();
  const [titulo, setTitulo] = useState('');

  const mutation = useMutation({
    mutationFn: crearPost,
    onSuccess: (nuevoPost) => {
      // Invalida la caché para que se recarguen los posts
      queryClient.invalidateQueries({ queryKey: ['posts'] });
      
      // O añade optimistamente a la caché existente
      queryClient.setQueryData(['posts'], (old: Post[] = []) => [
        ...old,
        nuevoPost,
      ]);
      
      setTitulo('');
    },
    onError: (error) => {
      console.error('Error al crear:', error.message);
    },
  });

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      mutation.mutate({ titulo, contenido: '' });
    }}>
      <input
        value={titulo}
        onChange={e => setTitulo(e.target.value)}
        placeholder="Título del post"
      />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creando...' : 'Crear post'}
      </button>
      {mutation.isError && <p>Error: {mutation.error.message}</p>}
    </form>
  );
}
```

---

## Invalidación de caché

```typescript
const queryClient = useQueryClient();

// Invalida todas las queries con esta key
queryClient.invalidateQueries({ queryKey: ['posts'] });

// Invalida queries específicas (con parámetros)
queryClient.invalidateQueries({ queryKey: ['posts', postId] });

// Invalida todas las queries que empiecen con 'usuario'
queryClient.invalidateQueries({ queryKey: ['usuario'] });

// Actualiza la caché directamente sin refetch
queryClient.setQueryData(['posts', postId], (old: Post) => ({
  ...old,
  titulo: 'Nuevo título',
}));
```

---

## Paginación

```tsx
async function getPosts(page: number) {
  const res = await fetch(`/api/posts?page=${page}&limit=10`);
  if (!res.ok) throw new Error('Error al cargar posts');
  return res.json() as Promise<{ posts: Post[]; totalPages: number }>;
}

function ListaPaginada() {
  const [page, setPage] = useState(1);

  const { data, isLoading, isPlaceholderData } = useQuery({
    queryKey: ['posts', page],
    queryFn: () => getPosts(page),
    placeholderData: (prevData) => prevData, // muestra datos anteriores mientras carga
  });

  return (
    <div>
      {isLoading ? (
        <Skeleton />
      ) : (
        <ul>
          {data?.posts.map(p => <li key={p.id}>{p.titulo}</li>)}
        </ul>
      )}

      <div className="flex gap-2 mt-4">
        <button
          onClick={() => setPage(p => Math.max(1, p - 1))}
          disabled={page === 1}
        >
          Anterior
        </button>
        <span>Página {page} de {data?.totalPages}</span>
        <button
          onClick={() => setPage(p => p + 1)}
          disabled={isPlaceholderData || page === data?.totalPages}
        >
          Siguiente
        </button>
      </div>
    </div>
  );
}
```

---

## Scroll infinito con useInfiniteQuery

```tsx
import { useInfiniteQuery } from '@tanstack/react-query';
import { useIntersectionObserver } from '@uidotdev/usehooks';

async function getPostsInfinite({ pageParam = 1 }) {
  const res = await fetch(`/api/posts?page=${pageParam}&limit=10`);
  return res.json() as Promise<{
    posts: Post[];
    nextPage: number | null;
  }>;
}

function FeedInfinito() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: getPostsInfinite,
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.nextPage,
  });

  const [ref, entry] = useIntersectionObserver({ threshold: 0.1 });

  // Auto-cargar al llegar al final
  useEffect(() => {
    if (entry?.isIntersecting && hasNextPage) {
      fetchNextPage();
    }
  }, [entry, hasNextPage, fetchNextPage]);

  const posts = data?.pages.flatMap(p => p.posts) ?? [];

  return (
    <div>
      {posts.map(post => <PostCard key={post.id} post={post} />)}
      <div ref={ref}>
        {isFetchingNextPage && <Spinner />}
      </div>
    </div>
  );
}
```

---

## Optimistic Updates

Muestra el resultado al usuario antes de que el servidor confirme:

```tsx
const mutation = useMutation({
  mutationFn: (id: string) => fetch(`/api/posts/${id}`, { method: 'DELETE' }),
  onMutate: async (postId) => {
    // Cancela queries en vuelo
    await queryClient.cancelQueries({ queryKey: ['posts'] });

    // Guarda el estado anterior por si hay error
    const anteriores = queryClient.getQueryData<Post[]>(['posts']);

    // Actualiza optimistamente
    queryClient.setQueryData(['posts'], (old: Post[] = []) =>
      old.filter(p => p.id !== postId)
    );

    return { anteriores }; // contexto para el rollback
  },
  onError: (err, postId, context) => {
    // Rollback en caso de error
    queryClient.setQueryData(['posts'], context?.anteriores);
  },
  onSettled: () => {
    // Revalida siempre al terminar
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});
```

---

## Diferencias clave v4 → v5

```typescript
// ❌ v4 (ya no funciona)
useQuery(['usuarios'], getUsuarios, { onSuccess: (data) => {...} });
useQuery(['usuarios'], getUsuarios);

// ✅ v5 (todo en objeto)
useQuery({ queryKey: ['usuarios'], queryFn: getUsuarios });

// ❌ v4
const { data } = useQuery(['posts', id], () => getPost(id));

// ✅ v5
const { data } = useQuery({
  queryKey: ['posts', id],
  queryFn: () => getPost(id),
});

// ❌ v4: cacheTime
// ✅ v5: gcTime
```

Si usas shadcn/ui para la interfaz, TanStack Query es el complemento natural para el fetching de datos. Combínalo con [shadcn/ui](/blog/shadcn-ui-guia-completa-espanol-2026/) para tener una base sólida de UI + data fetching en cualquier proyecto React.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Mejores IAs para Generar Imágenes en 2026 (Comparativa)]]></title>
      <link>https://francobosg.netlify.app/blog/mejores-ia-generar-imagenes-2026/</link>
      <description><![CDATA[Midjourney v7, DALL-E 3, Flux, Stable Diffusion 3.5… ¿Cuál genera mejores imágenes en 2026? Comparativa con prompts y precios.]]></description>
      <pubDate>Wed, 04 Feb 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/mejores-ia-generar-imagenes-2026/</guid>
      <category>IA</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[La generación de imágenes con IA explotó en 2025-2026. Hay tantas opciones que es difícil saber cuál usar. Probé todas las principales y esta es mi comparativa honesta.

## Tabla comparativa rápida

| Modelo | Calidad | Precio | Velocidad | Texto en imagen | Estilo |
|--------|---------|--------|-----------|-----------------|--------|
| **Midjourney v7** | ⭐⭐⭐⭐⭐ | $10-30/mes | 15-30s | ✅ Bueno | Artístico |
| **DALL-E 3** | ⭐⭐⭐⭐ | Pay per use | 10-20s | ✅ Muy bueno | Versátil |
| **Flux 1.1 Pro** | ⭐⭐⭐⭐⭐ | Pay per use | 5-10s | ✅ Excelente | Fotorrealista |
| **Ideogram 3** | ⭐⭐⭐⭐ | Free/Premium | 10-15s | ✅ El mejor | Diseño/Logo |
| **Stable Diffusion 3.5** | ⭐⭐⭐⭐ | Gratis (local) | 5-30s | ⚠️ Medio | Customizable |
| **Imagen 3** (Google) | ⭐⭐⭐⭐ | Pay per use | 5-10s | ✅ Bueno | Natural |
| **Recraft V3** | ⭐⭐⭐⭐⭐ | Free/Premium | 5-10s | ✅ Excelente | Profesional |
| **Grok (Aurora)** | ⭐⭐⭐ | Incluido en X | 10s | ⚠️ Medio | Memes/casual |

## Midjourney v7: el rey del arte

### Puntos fuertes
- **Calidad artística** insuperable — composición, iluminación, color
- Estilos variados: fotorrealismo, ilustración, 3D, anime
- La comunidad genera prompts increíbles que puedes reutilizar
- **Consistent characters** — puedes mantener personajes entre imágenes

### Puntos débiles
- Solo funciona en Discord (la app web mejora pero menos)
- No tiene API pública oficial
- $10/mes mínimo (200 imágenes), $30/mes para ilimitado
- No es open-source

### Best for
Ilustraciones, concept art, imágenes de marketing, fondos de pantalla, contenido visual premium.

### Prompt example
```
A developer's workspace at night, dual monitors glowing with code,
ambient purple lighting, cozy atmosphere, highly detailed,
cinematic lighting --ar 16:9 --v 7 --style raw
```

---

## DALL-E 3: el más accesible

### Puntos fuertes
- Integrado en ChatGPT — le describes lo que quieres en conversación
- **Texto en imágenes** muy preciso
- Entiende prompts complejos y los interpreta bien
- API disponible para integrar en apps

### Puntos débiles
- Calidad inferior a Midjourney en composición artística
- Estilo algo "plastificado" en fotorrealismo
- $0.04-0.08 por imagen (API)
- Censura bastante estricta

### API pricing
| Resolución | Calidad | Precio/imagen |
|-----------|---------|---------------|
| 1024×1024 | Standard | $0.040 |
| 1024×1024 | HD | $0.080 |
| 1024×1792 | Standard | $0.080 |
| 1024×1792 | HD | $0.120 |

### Best for
Generación rápida desde ChatGPT, imágenes con texto, mockups, thumbnails.

---

## Flux 1.1 Pro: el nuevo referente

### Puntos fuertes
- **Fotorrealismo** al nivel de fotos reales
- Rápido: 5-10 segundos por imagen
- Texto excelente en imágenes
- Disponible en múltiples plataformas (Replicate, fal.ai, Together)
- Versión open-source (Flux Schnell) para uso local

### Puntos débiles
- Menos versátil en estilos artísticos que Midjourney
- Los prompts necesitan ser más específicos
- Consume más VRAM que SD para ejecución local

### Variantes
| Variante | Calidad | Velocidad | Precio |
|----------|---------|-----------|--------|
| **Flux Pro 1.1** | ⭐⭐⭐⭐⭐ | 5-10s | $0.04/img |
| **Flux Dev** | ⭐⭐⭐⭐ | 10-15s | Gratis (local) |
| **Flux Schnell** | ⭐⭐⭐ | 2-3s | Gratis (local) |

### Best for
Fotos de producto, stock photography, imágenes que necesitan parecer reales.

---

## Ideogram 3: el rey del texto y diseño

### Puntos fuertes
- **Mejor renderizado de texto** de todos los modelos
- Ideal para logos, posters, tarjetas, banners
- Genera tipografías coherentes y legibles
- Plan gratis generoso

### Puntos débiles
- Menos detalle en escenas complejas
- El fotorrealismo no es su fuerte
- Estilo algo "limpio" — le falta carácter en ilustraciones

### Best for
**Diseño gráfico**: logos, carteles, thumbnails de YouTube, slides, infografías. Si necesitas texto en la imagen, Ideogram es la primera opción.

---

## Stable Diffusion 3.5: el gratis total

### Puntos fuertes
- **100% gratis** ejecutándolo en local
- Personalizable con LoRAs, ControlNet, IP-Adapter
- Comunidad enorme con modelos fine-tuned para cualquier estilo
- Sin censura (útil para arte, no para lo que piensas)
- Puedes entrenarlo con tus propias imágenes

### Puntos débiles
- Necesita GPU (mínimo 8GB VRAM, ideal 12GB+)
- Configuración inicial es técnica (ComfyUI, A1111)
- La calidad base es menor que Midjourney/Flux Pro
- Texto en imágenes mejoró pero sigue por detrás

### Setup rápido con ComfyUI
```bash
# Clonar ComfyUI
git clone https://github.com/comfyanonymous/ComfyUI
cd ComfyUI
pip install -r requirements.txt

# Descargar modelo SD 3.5
# Colocar en models/checkpoints/

# Ejecutar
python main.py
```

### Best for
Uso masivo sin coste, estilos personalizados, contenido sin censura, experimentación. Ideal si tienes buena GPU.

---

## Recraft V3: el profesional

### Puntos fuertes
- Calidad profesional para diseño
- Consistencia de estilo excelente
- Genera vectores SVG
- Herramienta de edición integrada
- Plan gratis funcional

### Best for
Diseño UI/UX, iconos, ilustraciones consistentes para un proyecto.

---

## ¿Cuál elegir según tu caso?

| Necesidad | Mejor opción |
|-----------|-------------|
| **Arte / ilustración** | Midjourney v7 |
| **Fotos realistas** | Flux 1.1 Pro |
| **Texto en imagen / logos** | Ideogram 3 |
| **Integrar en app** | DALL-E 3 API o Flux API |
| **Gratis total** | Stable Diffusion local |
| **Diseño profesional** | Recraft V3 |
| **Contenido rápido** | DALL-E 3 (vía ChatGPT) |
| **Thumbnails YouTube** | Ideogram 3 o Midjourney |

## Tips para mejores resultados

### 1. Estructura del prompt

```
[Sujeto] + [Acción/pose] + [Ambiente/fondo] + [Estilo] + [Iluminación] + [Detalles técnicos]
```

Ejemplo: *"A young developer sitting at a desk with three monitors, typing code, modern minimalist office, cyberpunk neon purple lighting, photorealistic, 8K, shallow depth of field"*

### 2. Palabras mágicas que mejoran calidad

| Palabra | Efecto |
|---------|--------|
| `highly detailed` | Más detalles |
| `cinematic lighting` | Iluminación dramática |
| `8K / ultra HD` | Mayor definición |
| `professional photography` | Estilo foto real |
| `award-winning` | Composición profesional |
| `shallow depth of field` | Fondo desenfocado |

### 3. Negative prompts (SD/Flux)

```
blurry, low quality, deformed, ugly, bad anatomy,
watermark, text overlay, cropped, out of frame
```

## Precios mensuales comparados

Para alguien que genera ~200 imágenes/mes:

| Servicio | Coste/mes | Imágenes |
|----------|-----------|----------|
| Midjourney Basic | $10 | 200 |
| DALL-E 3 API | ~$8-16 | 200 |
| Flux Pro API | ~$8 | 200 |
| Ideogram Free | $0 | 100 |
| Stable Diffusion (local) | $0 | Ilimitadas |
| Recraft Free | $0 | 50/día |

Para saber cuánto cuestan todos los modelos de IA (no solo imágenes), consulta la [calculadora de precios de IA en 2026](/blog/calculadora-precios-ia-2026/). Y si quieres automatizar la generación de imágenes en flujos de trabajo, aprende a [automatizar tareas con IA usando n8n y Make](/blog/automatizar-tareas-ia-n8n-make-2026/).

---

¿Quieres ver más herramientas de IA que uso? Echa un vistazo a [mi portfolio](/) donde muestro mi stack y proyectos.]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Mejores Alternativas Gratis a ChatGPT en 2026 (Sin Pagar)]]></title>
      <link>https://francobosg.netlify.app/blog/alternativas-gratis-chatgpt-2026/</link>
      <description><![CDATA[Las 8 mejores alternativas gratuitas a ChatGPT en 2026. Modelos potentes sin suscripción: DeepSeek, Gemini, Llama 4, Mistral y más. Comparativa real.]]></description>
      <pubDate>Wed, 28 Jan 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/alternativas-gratis-chatgpt-2026/</guid>
      <category>IA</category>
      <category>Gratis</category>
      <category>Herramientas</category>
      <content:encoded><![CDATA[ChatGPT Plus cuesta $20/mes. Pero, ¿realmente necesitas pagarlo? En 2026 hay alternativas gratuitas que son **sorprendentemente buenas**. Aquí va mi ranking honesto.

## Top 8 alternativas gratis a ChatGPT

### 1. DeepSeek Chat — La mejor alternativa gratis

**Por qué es genial:**
- Modelo DeepSeek V3 y R1 disponibles gratis en [chat.deepseek.com](https://chat.deepseek.com)
- R1 tiene razonamiento comparable a o3 de OpenAI
- Sin límites estrictos de uso en la web
- Excelente en español
- Búsqueda web integrada

**Limitaciones:** Servidores en China (latencia variable), sin subida de archivos grandes.

**Veredicto:** Si solo pudiera usar una alternativa gratuita, sería esta.

### 2. Google Gemini — La más completa sin pagar

**Por qué es genial:**
- Gemini 2.0 Flash gratis en [gemini.google.com](https://gemini.google.com)
- Sube imágenes, PDFs, y archivos de código
- Integración con Google Workspace (Gmail, Docs, Drive)
- Búsqueda web con Google en tiempo real
- 1M de contexto incluso en versión gratuita

**Limitaciones:** Gemini 2.5 Pro (el mejor) requiere Advanced ($20/mes).

### 3. Claude.ai — El mejor para textos largos

**Por qué es genial:**
- Claude Sonnet 4 gratis con cuenta
- Excelente para escribir, resumir y analizar documentos
- La mejor calidad de respuesta en español de la lista gratuita
- UI limpia y agradable

**Limitaciones:** Límite de mensajes estricto en plan gratuito (~20-30/día). No disponible en todos los países.

### 4. Microsoft Copilot — Gratis con GPT-4o

**Por qué es genial:**
- GPT-4o gratis vía [copilot.microsoft.com](https://copilot.microsoft.com)
- Genera imágenes con DALL-E 3
- Búsqueda web con Bing
- Sin necesidad de cuenta para uso básico

**Limitaciones:** Límite de turnos por conversación, respuestas a veces más conservadoras.

### 5. Llama 4 (vía interfaces gratuitas)

**Por qué es genial:**
- Modelo open source de Meta, tan bueno como GPT-4o
- Disponible gratis en [meta.ai](https://meta.ai), HuggingChat, Perplexity
- Se puede ejecutar localmente en tu PC
- Sin censura excesiva

**Limitaciones:** La experiencia depende de qué interfaz uses. Para ejecutarlo en local fácilmente, te recomiendo [Ollama: IA local, gratis y sin API](/blog/ollama-ia-local-gratis-sin-api-2026/).

### 6. Mistral Le Chat — La alternativa europea

**Por qué es genial:**
- Mistral Large gratis en [chat.mistral.ai](https://chat.mistral.ai)
- Modelo europeo (servidores en la UE, cumple RGPD)
- Muy bueno en español y francés
- Canvas para edición colaborativa

**Limitaciones:** Menos potente que Claude/GPT en tareas complejas.

### 7. HuggingChat — El playground de modelos

**Por qué es genial:**
- Acceso a múltiples modelos: Llama, Mistral, DeepSeek, Qwen
- Puedes cambiar de modelo en medio de la conversación
- Totalmente gratuito y open source
- Sin registro obligatorio

**Limitaciones:** Interfaz menos pulida, velocidad variable.

### 8. Perplexity — El mejor para investigar

**Por qué es genial:**
- No es un chatbot, es un **motor de búsqueda con IA**
- Cita fuentes de cada afirmación
- Gratis con 5 búsquedas "Pro" al día
- Excelente para respuestas factuales y actualizadas

**Limitaciones:** No genera código tan bien como ChatGPT o Claude.

## Tabla comparativa

| Alternativa | Modelo | Código | Español | Archivos | Web | Límite |
|-------------|--------|--------|---------|----------|-----|--------|
| DeepSeek | V3/R1 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ❌ | ✅ | Generoso |
| Gemini | 2.0 Flash | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ | ✅ | Generoso |
| Claude | Sonnet 4 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ✅ | ❌ | ~25/día |
| Copilot | GPT-4o | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ | ✅ | Moderado |
| Llama 4 | Maverick | ⭐⭐⭐⭐ | ⭐⭐⭐ | ❌ | ❌ | Sin límite |
| Mistral | Large | ⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ | ✅ | Generoso |
| HuggingChat | Varios | ⭐⭐⭐ | ⭐⭐⭐ | ❌ | ✅ | Sin límite |
| Perplexity | Varios | ⭐⭐⭐ | ⭐⭐⭐⭐ | ❌ | ✅ | 5 Pro/día |

## ¿Cuándo SÍ merece la pena pagar?

- **Programas mucho**: GitHub Copilot ($10/mes) o Cursor Pro ($20/mes) se pagan solos en productividad
- **Necesitas Opus/o3**: Para razonamiento complejo, no hay alternativa gratuita equivalente
- **Volumen alto**: Si haces 100+ consultas/día, los límites gratuitos se quedan cortos
- **Privacidad**: La API de pago ofrece garantías de no entrenamiento con tus datos

## Mi recomendación: mejor alternativa gratis a ChatGPT

Para el **90% de usuarios**: combinar **DeepSeek** (para conversaciones generales y código) + **Gemini** (para archivos y búsqueda web) te da una experiencia comparable a ChatGPT Plus sin pagar un céntimo. Si además quieres ejecutar IA en tu propia máquina sin depender de ningún servicio, lee la [guía de Ollama para usar IA local gratis](/blog/ollama-ia-local-gratis-sin-api-2026/).

Si quieres saber exactamente cuánto cuesta cada modelo por token, consulta mi [calculadora de precios de IA en 2026](/blog/calculadora-precios-ia-2026/). Y si prefieres usar las APIs directamente sin gastar, tengo una [guía para usar la API de ChatGPT y Claude gratis](/blog/usar-api-chatgpt-claude-gratis-2026/).

¿Buscas la mejor IA para código? Mira mi [ranking de los mejores modelos de IA para programar](/blog/mejores-modelos-ia-para-programar-2026/).

---

*¿Quieres ver cómo uso estas herramientas en proyectos reales de desarrollo? Visita [mi portfolio](/) para ver mis proyectos y experiencia.*]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Sobre Mí: Fran Cobos — Desarrollador Full-Stack e IA]]></title>
      <link>https://francobosg.netlify.app/blog/sobre-mi-fran-cobos-desarrollador-fullstack-ia/</link>
      <description><![CDATA[De estudiar SMR y DAW a construir SaaS e integrar IA en producción. +24 certificaciones, aprendizaje continuo, inglés autodidacta y proyectos reales.]]></description>
      <pubDate>Thu, 22 Jan 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/sobre-mi-fran-cobos-desarrollador-fullstack-ia/</guid>
      <category>Sobre Mí</category>
      <category>Carrera</category>
      <category>IA</category>
      <category>Aprendizaje</category>
      <category>Full-Stack</category>
      <content:encoded><![CDATA[## TL;DR

Soy Fran Cobos, desarrollador full-stack especializado en IA, con formación en DAW y SMR, 5 empresas en el currículum, más de 24 certificaciones y 6 proyectos reales en producción. Actualmente trabajo en **COVAP** como desarrollador de aplicaciones e inteligencia artificial, colaborando en **CovitIA**, la plataforma IA de la cooperativa. Este artículo es mi historia: cómo empecé, qué he aprendido, por qué no paro de formarme y cuál es mi filosofía sobre la tecnología.

---

## De dónde vengo: los fundamentos

Mi camino en la informática empezó pronto. No fue una epifanía, fue curiosidad pura: quería entender cómo funcionaban las cosas por dentro.

### Educación Secundaria (2015 – 2019)

Antes de tocar una línea de código, pasé por el **IES Los Pedroches** cursando la ESO. Lo relevante aquí no es el título, sino que fue donde descubrí que la tecnología me atraía más que cualquier otra cosa.

### SMR: Sistemas Microinformáticos y Redes (2019 – 2021)

Mi primer contacto formal con la informática fue en el **IES Jerez y Caballero**, cursando el ciclo de **Técnico en Sistemas Microinformáticos y Redes**. Aquí aprendí los cimientos que muchos desarrolladores pasan por alto:

- **Administración de sistemas**: Windows Server, Linux, Active Directory
- **Redes**: TCP/IP, DHCP, DNS, VPN, subnetting, firewalls
- **Seguridad**: Políticas de grupo, permisos, cifrado, fortificación de sistemas
- **Virtualización**: VirtualBox, VMware, contenedores

Esta base me dio algo que el bootcamp promedio no da: **entender qué pasa debajo del código**. Cuando hoy configuro un servidor con Docker y Traefik, sé exactamente qué está haciendo cada capa de red. Esto es algo que recomiendo en mi [guía para estudiantes de DAW, DAM, SMR y ASIR](/blog/guia-estudiantes-daw-dam-smr-2026/) — los fundamentos importan más de lo que crees.

### DAW: Desarrollo de Aplicaciones Web (2022 – 2024)

Después de SMR, di el salto al desarrollo con el ciclo de **Técnico en Desarrollo de Aplicaciones Web** en el **IES Trassierra**. Aquí es donde todo cobró sentido:

- **Frontend**: HTML5, CSS3, JavaScript, React, Next.js
- **Backend**: Java, PHP, Node.js, Laravel, Symfony
- **Bases de datos**: MySQL, PostgreSQL, MongoDB
- **APIs**: Diseño REST, autenticación, versionado
- **DevOps básico**: Git, CI/CD, despliegues

Lo más valioso del DAW no fue solo aprender tecnologías — fue aprender a **pensar como desarrollador**: descomponer problemas, diseñar soluciones y escribir código que otros puedan mantener. Si estás considerando este camino, tengo un [listado de ideas de proyectos finales para DAW y DAM](/blog/ideas-proyecto-final-daw-dam-2026/) que ojalá hubiera tenido yo.

---

## Experiencia profesional: de la teoría al código en producción

### Olbia System — Primer contacto laboral (2021)

Mi primera experiencia profesional fue en **Olbia System**, una empresa de desarrollo web donde construí sitios corporativos y e-commerce a medida. WordPress, Shopify, PrestaShop, PHP... fue mi primer contacto con clientes reales, deadlines reales y problemas reales.

**Lo que aprendí**: que un código bonito no sirve si el cliente no puede usarlo. La experiencia de usuario y el producto final importan más que la elegancia técnica.

### Rovimatica — Automatización industrial (2024)

En **Rovimatica** entré en un mundo completamente diferente: **automatización industrial**. Programación de PLC, interfaces HMI, integración de maquinaria con bases de datos. Creé paneles de monitorización CRUD para seguimiento de procesos industriales en tiempo real.

**Lo que aprendí**: que el software no solo vive en navegadores. Hay máquinas que dependen de tu código, y un bug no es "refrescar la página" — es parar una línea de producción.

### WIN Innovación + Corsify — ERP, CRM e IA (2025 – 2026)

En **WIN Innovación** (y su marca tecnológica **Corsify**) me especialicé en módulos avanzados de ERP y CRM con **Dolibarr**. Aquí nacieron varios de mis proyectos más desafiantes:

- Un [sistema GPS de flota con Traccar y Leaflet](/blog/caso-real-gps-flota-vehiculos-dolibarr/) — seleccioné el hardware, lo instalé físicamente en los vehículos y desarrollé la integración completa
- Una [red de sensores IoT con ESP32](/blog/caso-real-iot-sensores-esp32-dolibarr/) — firmware propio, sensores a menos de 10€ por punto, 12+ meses en producción sin fallos
- Un [módulo de comisiones automatizado](/blog/caso-real-modulo-comisiones-dolibarr/) — de 2 días de cálculo manual en Excel a 5 minutos automáticos

También implementé automatización de reportes con IA que redujo tiempo manual en un 60%, y sincronización en tiempo real entre ERP y CRM con APIs externas.

### iautomatiza — SaaS, IA y automatización (febrero – abril 2026)

En **iautomatiza** (junto con **iPrevención**) es donde todo convergió. Aquí construí mis proyectos más ambiciosos:

- **[Atrapaclientes](/blog/caso-real-saas-atrapaclientes-nestjs-react/)**: un SaaS multitenant completo con NestJS, React, React Native, 75+ endpoints, RBAC granular y despliegue con Docker + Coolify. Elegí [NestJS sobre Express por razones concretas](/blog/por-que-nestjs-sobre-express-saas-2026/) que detallo en otro artículo.
- **[Ecosistema IA para reuniones](/blog/caso-real-ia-reuniones-gemini-supabase/)**: dos apps conectadas con Supabase Realtime, transcripción con diarización usando Gemini 1.5 Pro, análisis semántico con Gemini 2.0-Flash y generación automática de tickets.
- Pipelines de IA que automatizaron clasificación de datos en producción
- Infraestructura Docker + Coolify con deploy continuo

### COVAP — Inteligencia Artificial y desarrollo de aplicaciones (mayo 2026 – Actual)

Mi etapa actual. **COVAP** es una cooperativa agroalimentaria líder, y dentro de su departamento **IT/OT** estoy contribuyendo al desarrollo e innovación tecnológica de la empresa.

El proyecto estrella es **CovitIA**, la plataforma de Inteligencia Artificial propia de COVAP. CovitIA integra modelos de lenguaje (LLM) para procesar datos internos y ofrecer asistencia directa a los socios ganaderos desde la app **COVAPPs**. El objetivo: optimizar la gestión ganadera, automatizar tareas rutinarias y mejorar la eficiencia en toda la cadena agroalimentaria.

Mis responsabilidades aquí:

- **CovitIA**: desarrollo y evolución de la plataforma IA con LLM para asistencia a ganaderos y socios
- **Aplicaciones .NET**: desarrollo y mantenimiento de aplicaciones adaptadas a las necesidades del negocio
- **ERP (JD Edwards)**: mantenimiento, integraciones y desarrollos de módulos ERP
- **Integraciones**: WebServices, bases de datos SQL/Oracle y conexión con sistemas internos
- **Ganadería de precisión**: colaboración en sistemas de monitorización inteligente de animales, incluyendo cerdo ibérico
- **Ciclo completo de software**: desde la concepción del proyecto hasta la entrega y soporte postlanzamiento

Es un paso natural en mi carrera: combino todo lo aprendido en SaaS, IA, integraciones y ERP en un entorno donde la tecnología tiene impacto directo en el sector primario y la sostenibilidad.

---

## +24 certificaciones: la prueba del aprendizaje constante

No me quedo con lo que sé. Cada semana consumo contenido nuevo, hago cursos, pruebo herramientas y aplico lo aprendido en proyectos reales. Aquí van mis certificaciones verificables organizadas por área:

### IA y Agentes (la especialización fuerte)

| Certificación | Plataforma | Año |
|---|---|---|
| Desarrollo con IA: de 0 a Producción | bigschool | 2026 |
| Curso de Iniciación al Desarrollo con IA | bigschool | 2025 |
| Building AI Applications With Haystack | DeepLearning.AI | 2025 |
| LLMs as Operating Systems: Agent Memory | DeepLearning.AI | 2025 |
| AI Agents in LangGraph | DeepLearning.AI | 2025 |
| Functions, Tools and Agents with LangChain | DeepLearning.AI | 2025 |
| Evaluating AI Agents | DeepLearning.AI | 2025 |
| Practical Multi AI Agents with crewAI | DeepLearning.AI | 2025 |
| Multi AI Agent Systems with crewAI | DeepLearning.AI | 2025 |
| Building toward Computer Use with Anthropic | DeepLearning.AI | 2025 |

Son 10 certificaciones solo en agentes de IA y frameworks como LangChain, crewAI, LangGraph y Haystack. No es teoría — he aplicado todo esto en producción, como puedes ver en mi [tutorial para crear un agente de IA con LangChain](/blog/crear-agente-ia-langchain-nodejs-tutorial/) o en el [chatbot RAG con OpenAI](/blog/crear-chatbot-rag-openai-tutorial-2026/).

### Prompt Engineering y Modelos

| Certificación | Plataforma | Año |
|---|---|---|
| ChatGPT Prompt Engineering for Developers | DeepLearning.AI | 2025 |
| Introducing Multimodal Llama 3.2 | DeepLearning.AI | 2025 |
| Pair Programming with a Large Language Model | DeepLearning.AI | 2025 |
| Safe and Reliable AI via Guardrails | DeepLearning.AI | 2025 |
| Open Source Models with Hugging Face | DeepLearning.AI | 2025 |

El prompt engineering es la base de todo lo que hago con IA. No basta con pedir cosas a un modelo — hay que saber **cómo pedirlas**. Si te interesa, tengo los [20 mejores prompts para programar con IA](/blog/mejores-prompts-programar-ia-2026/) que uso a diario.

### RAG, Embeddings y Recuperación

| Certificación | Plataforma | Año |
|---|---|---|
| Advanced Retrieval for AI with Chroma | DeepLearning.AI | 2025 |
| LangChain Chat with Your Data | DeepLearning.AI | 2025 |
| LangChain for LLM Application Development | DeepLearning.AI | 2025 |

RAG (Retrieval-Augmented Generation) es lo que separa un chatbot genérico de uno que sabe de tu negocio. He aplicado esto en el [chatbot RAG con OpenAI que explico paso a paso](/blog/crear-chatbot-rag-openai-tutorial-2026/).

### Deep Learning y Transformers

| Certificación | Plataforma | Año |
|---|---|---|
| Attention in Transformers: Concepts and Code in PyTorch | DeepLearning.AI | 2025 |
| AI Python for Beginners | DeepLearning.AI | 2025 |

No solo uso modelos — entiendo cómo funcionan por dentro. Saber qué es self-attention o cómo funcionan los embeddings me permite tomar mejores decisiones sobre qué modelo usar para cada tarea. Comparo los [mejores modelos de IA para programar](/blog/mejores-modelos-ia-para-programar-2026/) y explico las diferencias reales en [Claude 4 vs GPT-4.1 para Python](/blog/claude-vs-gpt-programar-python-2026/).

### Desarrollo Full-Stack

| Certificación | Plataforma | Año |
|---|---|---|
| The Complete Full-Stack Web Development Bootcamp (62h) | Udemy | 2025 |

62 horas de HTML, CSS, JavaScript, Node.js, React, PostgreSQL, REST y Git. No es que necesitara aprender React desde cero — es que siempre hay algo nuevo que absorber. Incluso la tecnología que "dominas" evoluciona.

### Ciberseguridad y Productividad

| Certificación | Plataforma | Año |
|---|---|---|
| Introduction to Cybersecurity | Cisco | 2025 |
| Inteligencia Artificial y Productividad | Santander × Google | 2025 |
| Python | Santander Open Academy | 2025 |

La seguridad no es opcional. Desde la certificación de Cisco hasta la práctica diaria de CORS, autenticación JWT y sanitización de inputs — es algo que aplico en cada proyecto. De hecho, tengo un artículo sobre [errores comunes con CORS](/blog/errores/cors-access-control-allow-origin/) que nació de problemas reales.

---

## El stack actual: lo que uso cada día

```
Frontend:  React 19 · Next.js 15 · Tailwind CSS · TypeScript
Backend:   NestJS · Node.js · PHP · Python
Bases:     PostgreSQL · MySQL · MongoDB · Redis · Supabase
IA:        Gemini · GPT · Claude · LangChain · Ollama · RAG
DevOps:    Docker · Coolify · Traefik · GitHub · CI/CD
IoT:       ESP32 · Arduino · Sensores · MQTT
Otros:     n8n · Dolibarr · REST APIs · WebSockets
```

Cada herramienta de este stack la he usado en producción, no en tutoriales de YouTube. Si quieres comparar herramientas de IA para código, tengo una [comparativa de Cursor vs Copilot vs Windsurf](/blog/cursor-vs-copilot-vs-windsurf-2026/) basada en uso real.

---

## Por qué no paro de aprender (y tú tampoco deberías)

En informática hay una verdad incómoda: **si dejas de aprender, te quedas obsoleto**. No es una frase motivacional — es un hecho técnico. Las tecnologías que dominabas hace 3 años ya tienen reemplazo. Los frameworks se deprecan. Las mejores prácticas evolucionan. Los modelos de IA que eran punteros hace 6 meses ya son lentos y caros.

Mi rutina de aprendizaje:

1. **Cada día** consumo contenido técnico: artículos, changelogs, releases de frameworks, papers de IA
2. **Cada semana** pruebo alguna herramienta o concepto nuevo en un mini-proyecto
3. **Cada mes** completo al menos una certificación o curso enfocado
4. **Cada proyecto** es una oportunidad para aplicar algo que no he usado antes

No se trata de coleccionar certificados por LinkedIn. Se trata de **tener las herramientas mentales** para resolver problemas que aún no conoces. Cuando un cliente me dice "necesito que la app transcriba reuniones y genere tickets automáticos", puedo decir "sé exactamente cómo hacerlo" porque ya he estudiado RAG, agentes, diarización y procesamiento de audio.

### El inglés: una inversión que cambió mi carrera

Hay una barrera invisible que frena a muchos developers en España: el inglés. La documentación técnica, los papers de IA, las conferencias, los changelogs, los repositorios — **todo está en inglés**. Si no lo dominas, estás leyendo traducciones desactualizadas de segunda mano.

Aprendí inglés por mi cuenta, con disciplina y constancia. No fue fácil, pero fue una de las mejores inversiones de mi carrera. Escribí una [guía completa de inglés para desarrolladores](/blog/aprender-ingles-para-developers-2026/) donde explico exactamente cómo lo hice y qué recursos funcionan de verdad.

### IA local y privacidad

No todo tiene que pasar por APIs de pago. Uso [Ollama para ejecutar IA en local](/blog/ollama-ia-local-gratis-sin-api-2026/) cuando necesito privacidad o cuando quiero experimentar sin gastar. Llama 4, DeepSeek V3, Phi-4 — todos pueden correr en tu máquina y son [alternativas gratis a ChatGPT](/blog/alternativas-gratis-chatgpt-2026/) perfectamente viables.

Y para automatización, combino [n8n y Make con IA](/blog/automatizar-tareas-ia-n8n-make-2026/) para crear workflows que hacen en segundos lo que antes tardaba horas.

---

## Los números concretos

| Métrica | Valor |
|---|---|
| **Años en informática** | 7+ (desde 2019) |
| **Empresas** | 5 (Olbia, Rovimatica, WIN Innovación, iautomatiza, COVAP) |
| **Proyectos en producción** | 6 |
| **Certificaciones verificables** | 24+ |
| **Endpoints de API desarrollados** | 75+ (solo Atrapaclientes) |
| **Entidades de base de datos** | 18+ (solo Atrapaclientes) |
| **Meses de sistema IoT funcionando** | 12+ (sin fallos críticos) |
| **Artículos técnicos publicados** | 27+ |

---

## Mi filosofía como desarrollador

1. **Resuelve problemas reales** — El código es un medio, no un fin. Si tu solución técnicamente perfecta no resuelve el problema del usuario, no sirve.

2. **Entiende las capas** — De redes a frontend, si sabes lo que pasa debajo puedes debuggear lo que sea. Por eso valoro haber hecho SMR antes de DAW.

3. **Invierte en ti** — Cada curso, cada certificación, cada proyecto personal es una inversión que da retorno. Mi inversión en [aprender inglés](/blog/aprender-ingles-para-developers-2026/) multiplicó mis oportunidades.

4. **Documenta y comparte** — Este blog existe porque creo que compartir conocimiento te hace mejor profesional. Explicar algo te obliga a entenderlo de verdad.

5. **Usa IA, pero entiéndela** — No soy de los que pegan prompts sin entender la respuesta. Estudio cómo funcionan los modelos, los [mejores prompts](/blog/mejores-prompts-programar-ia-2026/) y [cuánto cuestan realmente](/blog/calculadora-precios-ia-2026/).

---

## ¿Qué sigue?

Sigo construyendo. Sigo aprendiendo. Sigo publicando. Si tienes un proyecto donde necesitas desarrollo web, integración de IA, módulos ERP o aplicaciones móviles, [mira mis servicios](/blog/servicios/) o echa un vistazo a mis [casos reales](/blog/caso-real-saas-atrapaclientes-nestjs-react/) para ver cómo trabajo.

Si eres estudiante de DAW, DAM o SMR, espero que mi [guía para estudiantes](/blog/guia-estudiantes-daw-dam-smr-2026/) y las [ideas de proyectos finales](/blog/ideas-proyecto-final-daw-dam-2026/) te ayuden en tu camino. El sector necesita gente con ganas de aprender.

Y si simplemente quieres aprender sobre IA, desarrollo o herramientas — quédate por el blog. Publico contenido nuevo cada semana.

---

*¿Tienes un proyecto en mente o quieres saber más? Consulta mis [servicios de desarrollo](/blog/servicios/) o explora el resto de artículos del [blog](/blog/).*]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Cómo Hice mi Portfolio con Vite, Tailwind y Netlify]]></title>
      <link>https://francobosg.netlify.app/blog/como-hice-mi-portfolio-vite-tailwind/</link>
      <description><![CDATA[Cómo construí mi portfolio con Vite, Tailwind CSS y Netlify desde cero. Arquitectura, errores reales, dark mode, i18n y cómo saqué 98+ en Lighthouse.]]></description>
      <pubDate>Thu, 15 Jan 2026 00:00:00 GMT</pubDate>
      <author>contacto@francobosg.netlify.app (Fran Cobos)</author>
      <guid>https://francobosg.netlify.app/blog/como-hice-mi-portfolio-vite-tailwind/</guid>
      <category>Código</category>
      <category>Tutorial</category>
      <content:encoded><![CDATA[Este es el "making of" de mi propio portfolio. Llevo iterándolo más de un año y aquí comparto las decisiones técnicas, trucos y errores que cometí para que tú no los repitas.

<video
  src="/videos/porfolio.mp4"
  controls
  muted
  playsinline
  class="w-full rounded-xl my-6 shadow-lg"
  style="max-height: 480px; object-fit: cover;"
>
  Tu navegador no soporta vídeo HTML5.
</video>

## El stack (y por qué lo elegí)

| Tecnología | Por qué |
|------------|---------|
| **Vite** | Build ultrarrápido (< 2s), HMR instantáneo, zero config |
| **Tailwind CSS** | Utility-first, dark mode trivial, no escribo CSS custom |
| **Flowbite** | Componentes UI accesibles sobre Tailwind |
| **Vanilla JS** | Sin framework — un portfolio no necesita React |
| **Netlify** | Deploy automático con git push, SSL gratis, CDN global |

### ¿Por qué no React/Next.js?

Un portfolio es contenido estático. No necesita estado global, routing SPA, ni hidratación. Con Vanilla JS + Vite tengo:
- **Lighthouse Performance 98+** sin esfuerzo
- **0 KB de framework** en el bundle
- **Build en 1.8 segundos**

Si necesitara un blog dinámico, usaría Astro (spoiler: es lo que usa este blog).

## Dark mode con localStorage (sin FOUC)

El truco más importante: prevenir el "flash" de tema incorrecto al cargar.

```html
<!-- En el <head>, ANTES de cualquier CSS -->
<script>
  if (localStorage.getItem('color-theme') === 'dark' ||
      (!('color-theme' in localStorage) &&
       matchMedia('(prefers-color-scheme: dark)').matches)) {
    document.documentElement.classList.add('dark');
  }
</script>
```

Este script es **inline y síncrono** — se ejecuta antes de que el navegador pinte nada. Así el usuario nunca ve un flash blanco si tiene modo oscuro configurado.

## i18n sin librerías (ES/EN)

No necesitas `i18next` ni `react-intl` para un sitio bilingüe sencillo. Mi enfoque:

1. El texto en español va directo en el HTML
2. Un atributo `data-i18n` marca los elementos traducibles
3. Un diccionario JS mapea texto español → inglés
4. Una función recorre los elementos y reemplaza el texto

```javascript
const en = {
  'Sobre mí': 'About me',
  'Proyectos': 'Projects',
  'Contacto': 'Contact',
  // ...
};

function applyTranslations(lang) {
  document.querySelectorAll('[data-i18n]').forEach(el => {
    const key = el.getAttribute('data-i18n');
    el.textContent = lang === 'en' ? (en[key] || key) : key;
  });
}
```

**Ventaja**: Cero dependencias, funciona con cualquier framework (o sin ninguno), el HTML es legible en español.

## Estructura de archivos

```
├── index.html          ← Todo el portfolio en un solo HTML
├── assets/
│   ├── css/style.css   ← Tailwind + custom styles
│   └── js/
│       ├── main.js     ← Entry point (imports)
│       ├── i18n.js     ← Sistema de traducción
│       ├── themeToggle.js
│       ├── techSkills.js  ← Skills renderizadas dinámicamente
│       └── ...
├── public/
│   ├── images/         ← Imágenes estáticas
│   ├── robots.txt      ← SEO
│   └── sw.js           ← Service Worker (PWA)
└── blog/               ← Astro (este blog)
```

## Performance: cómo conseguí 98+ en Lighthouse

**Las 5 cosas que más impacto tuvieron:**

1. **Lazy loading de imágenes** — `loading="lazy"` en toda imagen below the fold
2. **Font Awesome diferido** — Cargado con `defer` para no bloquear el renderizado
3. **Preconnect** a Google Fonts — `<link rel="preconnect" href="https://fonts.googleapis.com">`
4. **Imágenes WebP** — 60-80% menos peso que JPEG/PNG
5. **Service Worker** — Cachea assets estáticos para visitas repetidas

```html
<!-- Mal: bloquea el render -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/all.min.css">

<!-- Bien: no bloquea -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/all.min.css" media="print" onload="this.media='all'">
```

## Cookie consent (RGPD)

Si usas Google Analytics o AdSense en España, necesitas consentimiento **previo**. Mi solución:

1. GA4 y AdSense **NO se cargan** por defecto
2. Un banner aparece (sutil, esquina inferior)
3. Si el usuario acepta → `localStorage.setItem('cookie-consent', 'accepted')` → se cargan los scripts
4. Si rechaza → no se carga nada analítico

```javascript
function loadAnalytics() {
  if (localStorage.getItem('cookie-consent') !== 'accepted') return;
  // Inyectar GA4 dinámicamente
  const s = document.createElement('script');
  s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXX';
  s.async = true;
  document.head.appendChild(s);
}
```

## Errores Comunes al Crear un Portfolio con Vite (y Soluciones)

| Error | Solución |
|-------|----------|
| Cargar GA4 antes del consentimiento | Bloquear scripts hasta aceptación |
| FOUC en dark mode | Script inline síncrono en `<head>` |
| i18n keys como `cookie_text` | Usar texto español como key |
| Service Worker cacheando todo | Excluir HTML, solo cachear assets |
| 404 genérica fea | Página 404 custom con Netlify |

## Deploy en Netlify

```toml
# netlify.toml
[build]
  command = "npm run build"
  publish = "dist"

[[headers]]
  for = "/*.html"
  [headers.values]
    Cache-Control = "public, max-age=0, must-revalidate"

[[headers]]
  for = "/assets/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"
```

**Truco clave**: HTML con `max-age=0` (siempre revalida) y assets con hash en el nombre con `max-age=1 año` (inmutables). Así el usuario siempre ve la última versión.

Si estás empezando en el mundo del desarrollo, no te pierdas mi [guía para estudiantes de DAW, DAM, SMR y ASIR](/blog/guia-estudiantes-daw-dam-smr-2026/) donde explico qué aprender y cómo destacar.

---

*¿Te ha servido? Puedes ver el resultado final en [mi portfolio](/) y si quieres algo similar para ti, [cuéntame tu proyecto](/blog/servicios/).*]]></content:encoded>
    </item>
  </channel>
</rss>