Astro + Sanity + Vercel
Lecciones aprendidas integrando un CMS headless con un generador de sitios estáticos y despliegue automatizado.
Hace unos días tuve que montar una web para un cliente (no técnico) el cual quería actualizar el contenido de forma regular.
Para construir dicha web usé un combo ganador como es Astro, Sanity y Vercel, pero mientras lo construía me encontré una serie de problemas que no esperaba.
Los he documentado para que, si también quieres montar algo con este tándem y luego lo despliegas en Vercel, puedas tener una ayuda para no entrar en los bucles con los que me encontré yo :)
1. Arquitectura general
Sanity Studio (CMS) → Sanity API → Astro (SSG) → Vercel (hosting)
↓ ↑
Webhook ──────────────────────────────────────── Deploy HookSanity es donde se guarda y gestiona todo el contenido: textos, imágenes, documentos.
Astro genera las páginas HTML del sitio web en el momento de la construcción (build), pidiendo los datos a Sanity.
Vercel aloja el sitio y lo reconstruye automáticamente cada vez que Sanity le avisa de que algo ha cambiado.
Por qué generar páginas estáticas (SSG) y no renderizar en servidor (SSR)
Para webs donde el contenido no cambia constantemente (corporativas, portfolios, blogs, sitios de proyecto), generar páginas estáticas es la mejor opción:
SSG (PÁGINAS PRE-GENERADAS)SSR (GENERADAS EN CADA VISITA)VelocidadInstantáneo, el HTML ya está listoCada visita hace una consulta a la APICosteGratis o casi gratisCada visita consume recursos de servidorResilienciaSi Sanity se cae, la web sigue funcionandoSi Sanity se cae, la web deja de funcionarContenido actualizadoTarda ~30-40 segundos en reconstruirseInmediato
Regla general: si el contenido cambia pocas veces al día, las páginas estáticas son suficientes. Solo necesitas renderizado en servidor si tu contenido cambia constantemente (tiendas online, dashboards, feeds en tiempo real).
2. Configuración del cliente Sanity
import { createClient } from '@sanity/client';
export const sanityClient = createClient({
projectId: 'tu-project-id',
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: false, // ← CRÍTICO
});La trampa de la caché (useCdn)
Este es probablemente el error más difícil de detectar en toda la integración.
El problema: con useCdn: true, Sanity devuelve datos desde su caché en lugar de ir a buscar los más recientes. Cuando un editor publica contenido y se dispara la reconstrucción del sitio, el build pide datos a Sanity, pero la caché todavía tiene la versión anterior. El resultado: el sitio se reconstruye con datos desactualizados.
Cómo se manifiesta:
Los cambios hechos en Sanity no aparecen en la web después de reconstruir
Los tiempos de construcción de las páginas son sospechosamente rápidos (~15ms en vez de ~3s)
No hay ningún error en los registros del sistema: simplemente los datos son viejos
Solución: usar useCdn: false para los builds. En sitios estáticos no hay penalización real, porque las consultas solo se ejecutan una vez durante la construcción, no en cada visita del usuario.
Regla: useCdn: true es para aplicaciones que renderizan en servidor con mucho tráfico, donde la velocidad de respuesta importa mucho. Para sitios estáticos, siempre useCdn: false.
3. Esquemas de contenido
Singletons vs. Colecciones
Singletons: documentos que solo existen una vez (la sección hero de la home, la página “sobre nosotros”, el formulario de contacto, la configuración general). Se crean una vez y después solo se editan.
Colecciones: documentos que se repiten (miembros del equipo, entidades, publicaciones del blog).
// Singleton: se le asigna un _id fijo para que no se pueda duplicar
defineType({
name: 'heroSection',
type: 'document',
// ...
})
// En sanity.config.ts: se impide que los editores creen o borren singletons
document: {
actions: (input, context) =>
singletonTypes.has(context.schemaType)
? input.filter(({ action }) =>
action && ['publish', 'discardChanges', 'restore'].includes(action))
: input,
}Internacionalización a nivel de campo
En vez de crear un documento separado por cada idioma, se crean campos que contienen ambos idiomas dentro del mismo documento:
// localeString: un campo corto con versión en cada idioma
defineType({
name: 'localeString',
type: 'object',
fields: [
{ name: 'es', type: 'string', title: 'Español' },
{ name: 'en', type: 'string', title: 'English' },
],
})Hay tres variantes según el tipo de texto:
localeString para textos cortos (títulos, etiquetas)
localeText para textos largos (párrafos, descripciones)
localeBlockContent para texto enriquecido (con negritas, cursivas, enlaces)
Ventaja: el editor ve los dos idiomas en el mismo formulario, sin tener que cambiar de documento. Esto reduce errores y hace mucho más fácil mantener ambos idiomas sincronizados.
4. Patrón de respaldo (fallback)
Nunca dependas al 100% de una fuente de datos externa. Si Sanity no está disponible o el contenido todavía no se ha cargado, la web tiene que seguir mostrando algo.
// En el componente Astro
let hero, about, settings;
try {
const data = await getHomePageData();
hero = data.hero;
about = data.about;
settings = data.settings;
} catch {
// Sanity no está disponible, usamos los textos de respaldo
}
// Función auxiliar que decide de dónde sacar el texto
function s(
sanityValue: { es?: string; en?: string } | null,
fallbackKey: string,
lang: string
): string {
if (sanityValue) {
return sanityValue[lang] ?? sanityValue.es ?? t(fallbackKey, lang);
}
return t(fallbackKey, lang); // textos de respaldo estáticos
}Regla: mantén un archivo de textos estáticos (translations.ts) como red de seguridad. Sanity es la fuente principal de datos; los textos estáticos son el plan B.
5. Consultas GROQ
Pide solo lo que necesitas
Siempre especifica qué campos quieres en lugar de traerte el documento entero:
// ❌ Malo: trae todo, incluidos campos internos de Sanity que no usas
*[_type == "teamMember"]
// ✅ Bueno: solo los campos que vas a usar
*[_type == "teamMember"] | order(order asc){
_id,
name,
role,
institution,
"photoUrl": photo.asset->url,
initials,
order
}Resuelve las referencias de imágenes en la propia consulta
Sanity guarda las imágenes como referencias internas. Puedes convertirlas en URLs directamente dentro de la consulta:
"logoUrl": logo.asset->url
"photoUrl": photo.asset->urlCarga en paralelo
Usa Promise.all para pedir todos los datos a la vez, en lugar de uno detrás de otro:
const [hero, about, team, entities] = await Promise.all([
getHeroSection(),
getAboutSection(),
getTeamMembers(),
getEntities(),
]);6. Reconstrucción automática: Vercel + Sanity Webhooks
Configuración
En Vercel: crear un Deploy Hook en Settings → Git → Deploy Hooks. Esto genera una URL del tipo
https://api.vercel.com/v1/integrations/deploy/...En Sanity: crear un Webhook en manage.sanity.io → API → Webhooks
URL: la del Deploy Hook que acabas de crear en Vercel
Disparadores: Crear, Actualizar, Eliminar
Filtro: vacío (cualquier cambio dispara la reconstrucción)
Flujo completo
El editor publica algo en Sanity
→ Sanity envía una notificación (webhook) a Vercel
→ Vercel descarga el código y ejecuta la construcción del sitio
→ Astro pide los datos a Sanity (con useCdn: false)
→ Se genera el HTML con los datos actualizados
→ El sitio está online (~30-40 segundos en total)Problemas habituales
La caché te da datos viejos (ya explicado arriba): usar
useCdn: false.Condición de carrera en el webhook: el webhook se dispara inmediatamente al publicar. Si Sanity tarda unos milisegundos en propagar los datos internamente, la construcción puede capturar datos a medio actualizar. En la práctica es raro, pero si ocurre, una segunda reconstrucción lo soluciona.
Actualizar el panel de edición no es lo mismo que actualizar la web:
npm run sanity:deployactualiza el Sanity Studio (la herramienta de edición). Solo hace falta cuando cambias la estructura de los datos (esquemas).El webhook de Vercel reconstruye la web pública. Se dispara cuando cambia el contenido.
7. Sanity Studio: buenas prácticas
Organizar el panel de navegación
Agrupa los documentos en el menú lateral para que los editores encuentren todo fácil:
structureTool({
structure: (S) =>
S.list().title('Contenido').items([
S.listItem().title('Inicio').child(
S.list().title('Secciones de inicio').items([
S.listItem().title('Hero').child(/* singleton */),
S.listItem().title('Sobre el proyecto').child(/* singleton */),
S.listItem().title('Contacto').child(/* singleton */),
])
),
S.divider(),
S.listItem().title('Equipo').child(/* lista filtrada */),
S.listItem().title('Entidades').child(/* lista filtrada */),
// ...
]),
})Proteger los singletons
Impide que los editores puedan crear duplicados o borrar documentos que deberían ser únicos:
document: {
actions: (input, context) =>
singletonTypes.has(context.schemaType)
? input.filter(({ action }) =>
action && ['publish', 'discardChanges', 'restore'].includes(action))
: input,
}Acceso y permisos
Para iniciar sesión en local:
npx sanity login(nosanity logindirectamente, puede que no esté en el PATH)Los colaboradores acceden al Studio desde el navegador, a través de la URL donde lo hayas desplegado
Los permisos de cada usuario se gestionan en manage.sanity.io → Members
8. Imágenes
Proporciones y recorte
Define las proporciones en CSS y deja que el navegador se encargue del recorte:
.team-photo {
aspect-ratio: 3/4; /* Proporción vertical tipo retrato */
object-fit: cover; /* Recorta sin deformar la imagen */
width: 100%;
}Indicación para editores: subir fotos de al menos 600×800px para retratos. Si son más grandes no pasa nada, el CSS se encarga de ajustarlas.
Transformación de imágenes con URL builder
Sanity permite redimensionar y recortar imágenes automáticamente a través de URLs:
import { createImageUrlBuilder } from '@sanity/image-url';
const builder = createImageUrlBuilder(sanityClient);
export function urlFor(source) {
return builder.image(source);
}
// Ejemplo: urlFor(photo).width(400).height(533).url()Nota: usa createImageUrlBuilder (importación con nombre), no la importación por defecto que está marcada como obsoleta.
9. Carga masiva de contenido
Para no tener que introducir todo el contenido inicial a mano en el Studio, crea un script de carga:
import { createClient } from '@sanity/client';
const client = createClient({
projectId: 'tu-project-id',
dataset: 'production',
token: process.env.SANITY_TOKEN, // token con permisos de escritura
apiVersion: '2024-01-01',
useCdn: false,
});
// createOrReplace: se puede ejecutar muchas veces sin crear duplicados
await client.createOrReplace({
_id: 'heroSection',
_type: 'heroSection',
tag: { es: 'Proyecto de investigación', en: 'Research project' },
// ...
});Importante: usa createOrReplace en vez de create. Así el script se puede ejecutar varias veces sin duplicar contenido (es idempotente).
Seguridad: nunca subas tokens de API al repositorio. Guárdalos como variables de entorno y revócalos cuando ya no los necesites.
10. Checklist de integración
useCdn: falseen el cliente Sanity para builds estáticosTextos de respaldo si Sanity no responde
Deploy Hook de Vercel configurado
Webhook de Sanity apuntando al Deploy Hook
Singletons protegidos para que no se puedan duplicar ni borrar
Campos bilingües (ES/EN) en todos los textos
Consultas GROQ pidiendo solo los campos necesarios
Carga de datos en paralelo con
Promise.allScript de carga inicial para el contenido
README con instrucciones de setup, comandos y flujo de despliegue
Studio desplegado para que los colaboradores puedan acceder (
npm run sanity:deploy)Proporciones de imagen definidas en CSS con
aspect-ratio+object-fit: cover
Resumen
La combinación Astro + Sanity + Vercel funciona muy bien para sitios donde el contenido lo gestiona un equipo no técnico:
Sanity da a los editores una interfaz cómoda para gestionar contenido.
Astro genera HTML rápido y barato.
Vercel despliega automáticamente con webhooks.
Los puntos críticos son:
useCdn: falsepara que los builds siempre obtengan datos actualizados.Textos de respaldo para que la web nunca se quede en blanco.
Entender que actualizar el Studio y actualizar la web son procesos distintos con disparadores distintos.
Con estas prácticas, el flujo es: el editor cambia contenido en Sanity → la web se actualiza sola en ~30 segundos → sin intervención técnica.

