La mayoría de guías "despliega en Vercel" se quedan en darle al botón. Vercel en producción es una config que diseñas. Los cuatro archivos de abajo son los que enviamos: un vercel.json con headers, redirects y crons; un next.config.ts con la estrategia de cache escrita en claro; una route en edge que corre auth en menos de 50ms de first byte; y una Server Action que revalida la ruta correcta cuando el contenido cambia.
1. La config vercel.json
La config de producción vive en source control. Headers, redirects, crons y rewrites son revisables, diff-eables y atados al commit que los introdujo.
// vercel.json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Strict-Transport-Security",
"value": "max-age=63072000; includeSubDomains; preload"
},
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
}
]
},
{
"source": "/_next/static/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
],
"redirects": [
{
"source": "/blog/old-slug",
"destination": "/blog/new-slug",
"permanent": true
}
],
"crons": [
{
"path": "/api/cron/reconcile-stripe",
"schedule": "0 3 * * *"
},
{
"path": "/api/cron/refresh-sitemap",
"schedule": "0 4 * * *"
}
]
}
2. La estrategia de cache de Next.js
El next.config.ts hace explícita la estrategia de cache: ISR para páginas de contenido, sin cache para rutas autenticadas, optimización de imágenes ajustada a los breakpoints reales que usa el design system.
// next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [360, 640, 768, 1024, 1280, 1536, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
pathname: '/uploads/**',
},
],
},
experimental: {
// Cache ISR de larga duración para el blog; flush on-demand vía webhook.
isrFlushToDisk: true,
},
async headers() {
return [
{
source: '/api/:path*',
headers: [{ key: 'Cache-Control', value: 'no-store' }],
},
]
},
}
export default config
3. La route en edge
El auth check corre en edge en milisegundos. La route lee la cookie de sesión, la valida contra una clave pública JWT (sin round-trip a la base de datos), y o reescribe a la aplicación o redirige a login.
// app/api/auth/check/route.ts
import { NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
export const runtime = 'edge'
const PUBLIC_KEY = process.env.SESSION_PUBLIC_KEY!
export async function GET(request: Request) {
const cookie = request.headers.get('cookie')
const token = cookie?.match(/session=([^;]+)/)?.[1]
if (!token) return NextResponse.json({ ok: false }, { status: 401 })
try {
const { payload } = await jwtVerify(
token,
new TextEncoder().encode(PUBLIC_KEY),
)
return NextResponse.json({ ok: true, userId: payload.sub })
} catch {
return NextResponse.json({ ok: false }, { status: 401 })
}
}
4. Revalidación on-demand desde una Server Action
Cuando un editor de contenido publica un post, la Server Action escribe en la base de datos y llama a revalidatePath para la route exacta. La versión cacheada cae; la siguiente petición reconstruye y vuelve a cachear. Sin rebuild nocturno.
// app/admin/posts/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { createServerClient } from '@/lib/supabase/server'
export async function publishPost(postId: string): Promise<{ ok: true } | { ok: false; error: string }> {
const supabase = await createServerClient()
const { data, error } = await supabase
.from('posts')
.update({ status: 'published', published_at: new Date().toISOString() })
.eq('id', postId)
.select('slug, locale')
.single()
if (error) return { ok: false, error: error.message }
// Tira la cache para este post exacto y para el índice del locale.
revalidatePath(`/${data.locale}/blog/${data.slug}`)
revalidatePath(`/${data.locale}/blog`)
return { ok: true }
}
5. Qué te compra esto
El despliegue es un git push. La preview es una URL que aparece en la PR. El auth check responde en 30 milisegundos. La cache del blog se regenera en el momento en que un editor pulsa publish, no en un cron nocturno. La factura aterriza donde la esperas porque cada decisión de cache vive en source control donde puedes auditarla.
El Vercel que se gana mala reputación es el configurado por casualidad. El Vercel que se gana su sitio es el diseñado, con intención, tres o cuatro archivos cada vez.