← Todos los artículos

Cómo construí este blog: estático, sin servidor y sin coste de hosting

Por qué elegí Nuxt y Markdown en lugar de WordPress, cómo separo el código privado del sitio publicado y qué se rompió en el primer despliegue.

Quería un sitio donde enseñar mis proyectos y explicar cómo los resuelvo por dentro. Las condiciones eran sencillas: que cargue rápido, que no necesite servidor ni base de datos y que publicar un artículo sea escribir un Markdown y hacer push.

Este artículo cuenta las decisiones que tomé, por qué, y los problemas que aparecieron al desplegarlo.

Portada del blog

Por qué no WordPress

Trabajo a menudo con WordPress, así que era la opción obvia. La descarté porque para un blog personal es desproporcionado: necesita un servidor PHP, una base de datos, actualizaciones de seguridad y un hosting que pagar cada mes. Todo eso para servir unas páginas que cambian cuando yo publico, no cuando alguien las visita.

Un sitio estático resuelve lo mismo con mucho menos:

  • Coste cero. Se aloja en GitHub Pages, que solo sirve archivos.
  • Rápido. Cada página es HTML ya generado; no hay consultas ni renderizado en el servidor.
  • Sin superficie de ataque. No hay panel de administración, ni base de datos, ni plugins que mantener al día.
  • El contenido vive en Git. Cada artículo es un .md versionado junto al código.

El stack es Nuxt 4 con Nuxt Content para el Markdown y Tailwind CSS v4 para los estilos. npm run generate prerenderiza todas las rutas a HTML estático, incluido el feed RSS y el sitemap.

Código privado, sitio público

El código del blog está en un repositorio privado. Lo que se publica va a otro repositorio, blog-public, que solo contiene el resultado generado. Lo hago así por tres motivos:

  • El código fuente sigue siendo privado, aunque el sitio sea público. Es el mismo patrón que uso en la mayoría de mis proyectos: el desarrollo en un repo privado y un repo -public que solo sirve la versión compilada.
  • El despliegue queda separado del desarrollo. El repo público no tiene historial de código, ramas ni configuración; solo archivos listos para servir.
  • El hosting puede vivir en otra cuenta sin dar acceso al código.

Un workflow de GitHub Actions lo une todo. En cada push a main pasa los tests, genera el sitio y sube .output/public a la rama prod del repo público con un token de despliegue:

Cargando diagrama...

Si un test falla, no se despliega nada.

Lo que se rompió en el primer despliegue

En local todo funcionaba. En CI, la generación falló. Estos fueron los problemas y cómo los resolví.

Las imágenes Open Graph devolvían un error 500

Cada artículo genera su propia imagen para cuando se comparte en redes, con el módulo nuxt-og-image. Durante el prerender, esas rutas devolvían un 500 y la generación abortaba.

Reproduje el fallo en local con las mismas variables de entorno que el CI y aparecieron dos causas encadenadas:

  1. La plantilla de la imagen venía de un paquete de plantillas de la comunidad. En desarrollo se resuelve sola, pero para producción el módulo exige copiarla al proyecto. La copié a app/components/OgImage/BlogPost.takumi.vue, lo que además me deja adaptarla al diseño del blog.
  2. Con la plantilla ya en su sitio, seguía fallando: faltaba el motor que convierte la plantilla en imagen, @takumi-rs/core. No se instala automáticamente con el módulo. Añadirlo como dependencia lo resolvió.

Las URLs no sabían que el sitio vive en /blog-public/

GitHub Pages sirve el sitio en abdell1999.github.io/blog-public/, no en la raíz del dominio. Nuxt lo gestiona con NUXT_APP_BASE_URL=/blog-public/, pero no todo lo que genera URLs absolutas lo tiene en cuenta:

  • El RSS enlazaba a abdell1999.github.io/posts/..., sin la subcarpeta. El endpoint construía las URLs solo con la URL del sitio. Ahora combina las dos:
    const siteUrl = config.public.siteUrl
      ? withoutTrailingSlash(joinURL(config.public.siteUrl, config.app.baseURL))
      : ''
    
  • El sitemap incluía la ruta /blog-public como si fuera una página más, y la primera configuración del CI tenía la subcarpeta también en la URL del sitio, así que aparecía duplicada. La solución fue dejar la URL del sitio en la raíz del dominio y excluir la ruta base del sitemap.

La lección: si el sitio no se va a servir en la raíz, hay que probar la generación con esa configuración desde el principio, no descubrirlo en el primer despliegue.

Detalles que cuidé

Tema claro y oscuro sin tocar componentes. Los colores son variables CSS definidas dos veces, para :root y para .dark. Los componentes solo usan var(--fg), var(--accent), etc., así que cambiar de tema es cambiar una clase en <html>. Por defecto sigue la preferencia del sistema.

Portada en modo oscuro

Índice que sigue la lectura. Nuxt Content genera el árbol de encabezados de cada artículo. El índice lateral lo pinta y usa un IntersectionObserver para marcar la sección que estás leyendo, sin escuchar el scroll en cada frame.

Artículo con el índice lateral

Bloques de código y diagramas. Un único componente decide cómo pintar cada bloque: si es mermaid, dibuja el diagrama en el navegador (como el del despliegue de arriba); si no, muestra el código con un botón de copiar.

Diagrama renderizado dentro de un artículo

Tests. Los componentes con lógica (índice, bloques de código, selector de tema, tarjetas y etiquetas) tienen tests con Vitest, y el CI los ejecuta antes de cada despliegue.

Qué haría distinto y qué viene

Empezaría generando el sitio con la configuración de producción desde el primer día, con la subcarpeta incluida. Los dos problemas del despliegue eran fáciles de arreglar, pero solo aparecían en ese escenario.

También añadiría al CI una comprobación de enlaces sobre el HTML generado. Los tests de componentes no detectan un enlace roto ni una URL sin la subcarpeta, y eso es justo lo que falló.

Lo siguiente es el contenido: una página propia para cada proyecto, con capturas, enlaces y los artículos técnicos sobre cómo está hecho. El primero es ABReader, un lector de libros electrónicos que funciona entero en el navegador.