Web & Frontend · Funcional
Smile Forever
Sitio de clínica bilingüe y dirigido por contenido como código: SSG de Astro sobre CMS en Git.
- Astro 5
- i18n
- SSG
- YAML + PagesCMS
Una clínica dental necesitaba un sitio en dos idiomas que un no-programador pudiera editar, y que viviera en código en vez de en un theme alquilado. Lo construí como un SSG de Astro sobre un CMS de archivos en Git: el inglés y el español salen del mismo dato, y el contenido es editable sin tocar el markup.
Stack: Astro 5 · i18n · SSG · mini-CMS data-driven (YAML + PagesCMS) · Estado: Funcional · Rol: diseño + desarrollo (solo)
El problema / por qué en código y no Shopify
Es una clínica, no una tienda: el “producto” es agendar una consulta, y la conversión ocurre por WhatsApp, no en un carrito. Un theme de Shopify habría arrastrado un checkout, un modelo de productos y un panel que aquí no sirven para nada — y además cobra renta mensual por infraestructura que no se usa. El caso de Luppo en este portafolio es el camino contrario: cuando sí hay catálogo y checkout, Shopify gana. Aquí no los hay.
Las dos restricciones que mandaron la arquitectura:
- Bilingüe de verdad (es/en), no un plugin de traducción. Cada página existe en ambos idiomas con su propia URL, su
hreflangy su markup idéntico. El contenido tenía que estar emparejado, no duplicado en dos sitios que luego se desincronizan. - Editable por el cliente sin que me llame. Los textos, los precios de los tratamientos y la galería cambian; yo no quiero ser el cuello de botella de cada cambio de copy. Pero tampoco quería un CMS con base de datos y servidor que mantener.
La respuesta es un sitio estático (sin backend, sin DB, sin runtime que vigilar) generado desde archivos de contenido versionados en Git, con una capa de edición visual encima para el cliente.
Qué construí
Un sitio de una sola landing (más sus páginas legales) que compila a HTML estático y se sirve desde un CDN:
- Arquitectura de componentes modular. La página se ensambla a partir de piezas independientes — Hero, tabla comparativa de tratamientos, tarjeta de financiación, galería de casos, sección “sobre nosotros”, contacto — y cada componente recibe el idioma y el dato como props. La página en sí (
src/pages/es/index.astro,src/pages/en/index.astro) es solo el orden de las secciones; no contiene texto. - Dos builds, una fuente.
/esy/ense generan en el build a partir del mismo archivo de contenido. No hay traducción en runtime ni detección por navegador: cada idioma es HTML real, indexable, con su URL canónica. - Separación contenido/presentación estricta. Todo el texto, los tratamientos y los ítems de la galería viven en YAML (
content/pages/home.yml,content/globals/site.yml). Los componentes.astrono tienen ni una frase hardcodeada; leen el dato y lo pintan. Cambiar un precio o reordenar un tratamiento es editar el YAML, no el código. - Mini-CMS data-driven en dos capas. Una capa de carga (
src/lib/content.ts:loadYaml/loadCollection, ~25 líneas conjs-yaml) que lee los archivos en build; y.pages.yml, un esquema de PagesCMS que le da al cliente un editor visual sobre esos mismos archivos — los cambios entran como commits de Git, sin panel propio que mantener. - SEO técnico de serie.
sitemap.xml.tsgenera el sitemap con anotacioneshreflang; cada página emite canónica, alternates y Open Graph; y un JSON-LDDentist(horario, dirección, idiomas) sale del archivo global. - Financiación y contacto como CTAs, no como integraciones. El sitio ofrece pago a plazos vía Klarna y un botón flotante de WhatsApp. Ambos son enlaces salientes: la tarjeta de Klarna es un wordmark + deeplink, y cada CTA de WhatsApp abre
wa.mecon un mensaje pre-rellenado por tratamiento. No hay SDK de pagos, ni checkout, ni claves — nada sensible vive en el frontend.
Decisiones técnicas interesantes
i18n con una sola fuente de verdad para rutas e idioma. El núcleo es deliberadamente pequeño. El contenido bilingüe se modela con sufijos en la misma clave (title_es / title_en) y un helper pick(obj, 'title', lang) que resuelve el idioma con fallback — el componente nunca hace if (lang === 'es') para el texto; pide la clave base y pick decide. El emparejamiento de URLs vive en un único PAGE_PAIRS ({ privacy: { en:'/en/privacy/', es:'/es/privacidad/' } }), y de ahí salen todas las cosas que normalmente se desincronizan: el selector de idioma, los hreflang del <head>, la canónica, y el sitemap. Añadir una página es una entrada en esa tabla; no hay tres sitios que actualizar a mano.
La separación contenido/presentación habilita un dato que se referencia a sí mismo. Como el contenido es estructurado y no markup, los componentes pueden cruzarlo. Ejemplo real: la galería de casos etiqueta cada ítem con el tipo de tratamiento, pero no guarda el nombre del tratamiento — guarda un id que busca en la tabla comparativa de productos y de ahí saca el nombre a mostrar. Si el cliente renombra un tratamiento en el CMS, las etiquetas de la galería se actualizan solas, porque hay un único lugar donde ese nombre existe. Ese tipo de integridad referencial es trivial cuando el contenido es dato, e imposible cuando es HTML copiado. (La galería se renderiza en abstracto aquí: son ítems dirigidos por datos con etiqueta por tratamiento; el material clínico de pacientes no se describe.)
SSG porque el contenido es casi estático y el coste operativo importa. El sitio cambia cuando el cliente edita, no en cada request. Pre-renderizar a HTML da el mejor TTFB posible (CDN, sin cómputo), cero superficie de ataque de servidor, y un coste de hosting de prácticamente cero — que para una clínica pequeña es parte del argumento, no un detalle. El sitemap es el único endpoint dinámico, y se deriva de PAGE_PAIRS, así que tampoco se desincroniza.
Lo que se dejó fuera, a propósito. Sin framework de UI ni JS de cliente salvo lo mínimo (el menú del selector de idioma es un <script> inline). Sin i18n library de terceros: el problema es lo bastante acotado para que ~50 líneas propias sean más claras que una dependencia. Las imágenes se preprocesan a WebP con sharp en scripts de build, con preload por breakpoint para el hero. La complejidad se gastó en la arquitectura de contenido, no en el runtime.
Qué demuestra
Que sé elegir la herramienta por la forma del problema, no por costumbre. El mismo portafolio tiene Luppo, que es el camino Shopify hecho bien; este es el caso donde la plataforma sobra y construir en código sale ganando — y la decisión está razonada, no es preferencia. Demuestra además i18n real (rutas, hreflang, una sola fuente de verdad) y una separación contenido/presentación lo bastante limpia como para que el dato se referencie a sí mismo y para que un no-programador edite el sitio sin tocar el markup. Y la disciplina de no meterle backend, base de datos ni dependencias a algo que no los necesita.
Estado y siguiente paso
Funcional y desplegado: build estático servido desde CDN, las dos versiones de idioma en vivo, SEO técnico y datos estructurados en su sitio, y el cliente editando vía el CMS visual sobre Git. El siguiente paso natural es mover la galería y los tratamientos a colecciones (loadCollection ya está escrito para eso) para que cada caso sea su propio archivo, y migrar las imágenes al pipeline de astro:assets para tener srcset responsive automático en vez de los WebP pre-generados a mano.