Por Hugo Cayón Laso
Cómo construí mi blog con Astro: arquitectura, componentes y trucos
Crear este blog con Astro ha sido una de las mejores decisiones que he tomado como desarrollador. En este artículo quiero compartir cómo lo construí, qué decisiones tomé a nivel técnico y partes del código que se utiliza en mi estructura actual. Mi objetivo es que cualquier persona pueda replicarlo o inspirarse para crear el suyo.
¿Por qué Astro?
Elegí Astro por varias razones:
- Generación estática ultra rápida.
- Soporte integrado de Markdown + MDX.
- Posibilidad de usar componentes de React, Svelte, Vue… solo donde hace falta.
- Su arquitectura de islas, que optimiza la carga y el rendimiento.
- Un sistema de contenido: astro:content.
Estructura general del proyecto
src/
components/
layouts/
pages/
blog/
tags/
content/
posts/
A continuación explico qué papel juega cada carpeta y por qué la organicé así:
- src/components/ Aquí guardo todos los componentes reutilizables que voy creando para el blog: tarjetas de posts, botones, navegación, componentes UI, etc. Mi objetivo es que cualquier pieza visual que se repita en varias páginas esté centralizada en este directorio.
- src/layouts/ Esta carpeta contiene los layouts del blog. Un layout define la estructura general de una página (cabecera, pie, estilos base, tipografía…), así que tenerlos separados me permite reutilizar la misma estructura en todas las publicaciones sin duplicar código.
- src/pages/
Astro utiliza un sistema de enrutado basado en archivos, así que todo lo que esté aquí se convierte automáticamente en una ruta pública.
- pages/blog/ Incluye el listado de posts, la paginación y la lógica que genera las páginas individuales del blog.
- pages/tags/ Las páginas de etiquetas se generan desde aquí. Me interesa tenerlas separadas para mejorar la navegación del blog y permitir al lector filtrar contenido por temas.
- src/content/
Esta es la parte donde vive todo el contenido del blog.
- content/posts/ Aquí es donde guardo cada post como un archivo Markdown independiente. Cada post está escrito en Markdown y validado con Content Collections de Astro, lo que me asegura tipado, metadatos coherentes y un sistema más ordenado para gestionar las publicaciones.
Gracias a esta estructura, el proyecto se mantiene limpio y escalable. Cada pieza tiene su lugar y puedo añadir nuevas funciones o componentes sin complicar la organización general.
1. Arquitectura de Datos: Content Collections
El corazón del blog es el sistema de Content Collections. No solo guardamos archivos Markdown, sino que definimos un contrato estricto mediante un esquema de Zod. Esto nos permite validar que cada post tenga título, fecha y etiquetas antes de que el sitio se construya, evitando errores en producción.
// src/content/config.ts
import { defineCollection, z } from "astro:content";
const posts = defineCollection({
type: "content",
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
date: z.date(),
author: z.string(),
tags: z.array(z.string()).optional(),
cover: image().optional(),
draft: z.boolean().default(false),
}),
});
export const collections = { posts };
2. Flujo de Generación y Rutado Dinámico
Astro genera el blog de forma estática (SSG) para un rendimiento imbatible. Para las páginas de cada post (src/pages/blog/[slug].astro), utilizamos getStaticPaths para mapear cada archivo de la colección a una URL única.
Este es un ejemplo real de cómo gestionamos la lógica de Navegación Relativa (Anterior/Siguiente) y el cálculo del Tiempo de Lectura in-situ:
// src/pages/blog/[slug].astro
---
import { getCollection } from "astro:content";
import PostLayout from "@/layouts/PostLayout.astro";
import { calculateReadingTime } from "@/utils/readingTime";
export async function getStaticPaths() {
const posts = (await getCollection("posts"))
.filter((post) => !post.data.draft)
.sort((a, b) => a.data.date.getTime() - b.data.date.getTime());
return posts.map((post, index) => ({
params: { slug: post.slug },
props: {
post,
prev: posts[index - 1] ?? null,
next: posts[index + 1] ?? null,
},
}));
}
const { post, prev, next } = Astro.props;
const { Content } = await post.render();
---
<PostLayout
{...post.data}
prev={prev}
next={next}
readingTime={post.body ? calculateReadingTime(post.body) : undefined}
>
<Content />
</PostLayout>
3. Jerarquía de Layouts y Herencia de Metadatos
La estructura visual sigue una jerarquía de composición clara:
main.astro: El layout “Shell”. Configura el HTML base, fuentes,ClientRoutery los metadatos globales (OpenGraph, Twitter).BlogLayout.astro: Layout intermedio para listados y categorías.PostLayout.astro: El layout especializado. Gestiona la lógica de artículos, inyecta JSON-LD para SEO semántico y maneja componentes complejos como la galería de imágenes conastro:assets.
3. Jerarquía de Layouts: La estructura del Blog
La arquitectura visual se divide en tres niveles de responsabilidad, utilizando la composición de Astro para maximizar la reutilización y el rendimiento.
3.1. El Layout “Shell” (main.astro)
Este es el punto de entrada de todas las páginas. Gestiona el document HTML, el SEO global, las fuentes y el sistema de transiciones.
// src/layouts/main.astro
---
import "../styles/global.css";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import { ClientRouter } from "astro:transitions";
const { title, description, image = "/og-image.png", type = "website" } = Astro.props;
const imageUrl = new URL(image, Astro.site).toString();
---
<html lang="es" class="scroll-smooth">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<meta name="robots" content="index, follow" />
<link rel="canonical" href={Astro.url} />
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content={type} />
<meta property="og:url" content={Astro.url} />
<meta property="og:image" content={imageUrl} />
<meta property="og:image:alt" content={title} />
<meta property="og:locale" content="es_ES" />
<meta property="og:site_name" content="DevLog" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={imageUrl} />
<!-- Favicons -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<!-- RSS Feed Discovery -->
<link rel="alternate" type="application/rss+xml" title="DevLog RSS Feed" href="/rss.xml" />
<title>{title}</title>
<slot name="head" />
<ClientRouter />
</head>
<body class="min-h-screen grid grid-rows-[auto_1fr_auto] bg-background text-foreground antialiased font-sans">
<Header />
<main transition:animate="fade">
<slot />
</main>
<Footer />
</body>
</html>
3.2. El Layout de Listado (BlogLayout.astro)
Un pequeño wrapper que simplifica la creación de páginas de índice y etiquetas, heredando del Shell.
// src/layouts/BlogLayout.astro
---
import BaseLayout from "@/layouts/main.astro";
const { title = "Blog", description = "Últimos artículos", image, type = "website" } = Astro.props;
---
<BaseLayout title={title} description={description} image={image} type={type}>
<slot />
</BaseLayout>
3.3. El Layout de Artículo (PostLayout.astro)
Este es el componente más rico. Gestiona el SEO semántico, la portada optimizada, la barra de lectura y la navegación entre artículos.
// src/layouts/PostLayout.astro
---
import BaseLayout from "@/layouts/main.astro";
import type { CollectionEntry } from "astro:content";
import { Badge } from "@/components/ui/badge";
import { ChevronLeft, ChevronRight, Link2, Check } from "@lucide/astro";
import { Image } from "astro:assets";
import { tagUrl } from "@/utils/tag";
const { title, description, date, author, tags, cover, prev, next, readingTime } = Astro.props;
const formattedDate = date
? new Date(date).toLocaleDateString("es-ES", { dateStyle: "long" })
: "Fecha desconocida";
const coverImage = typeof cover === "string" ? cover : cover?.src;
---
<BaseLayout title={title} description={description} type="article" image={coverImage}>
<Fragment slot="head">
<script type="application/ld+json" set:html={JSON.stringify({
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": title,
"description": description,
"datePublished": date ? new Date(date).toISOString() : undefined,
"url": Astro.url.toString(),
...(author && { "author": { "@type": "Person", "name": author } }),
})} />
</Fragment>
<article class="max-w-4xl mx-auto px-4 py-20 reveal">
{cover && (
<figure class="mb-12 max-h-[400px] overflow-hidden rounded-xl">
<Image
src={coverImage as any}
alt={`Imagen de portada: ${title}`}
width={1200}
height={630}
class="w-full object-cover"
/>
</figure>
)}
<header class="max-w-3xl mx-auto mb-12 pb-6 border-b border-gray-200/50 dark:border-gray-700/50">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm text-muted-foreground mb-4">
<time class="tabular-nums">Publicado el {formattedDate} {readingTime && `• ${readingTime} min de lectura`}</time>
{author && <p class="font-medium">Por <strong>{author}</strong></p>}
</div>
<h1 class="text-4xl sm:text-5xl font-extrabold tracking-tight mb-4">{title}</h1>
<div class="flex flex-wrap gap-2">
{(tags ?? []).map((tag) => (
<a
href={`/tags/${tagUrl(tag)}`}
class="relative z-20 hover:opacity-80 transition-all duration-300 hover:scale-105 active:scale-95"
>
<Badge variant="secondary">{tag}</Badge>
</a>
))}
</div>
</header>
<div class="max-w-3xl mx-auto prose prose-lg dark:prose-invert">
<slot />
</div>
<!-- Compartir -->
<div class="max-w-3xl mx-auto mt-8 py-5 border-t border-gray-200/50 dark:border-gray-700/50 flex flex-col sm:flex-row items-center justify-between gap-4">
<p class="font-medium text-foreground">¿Te ha gustado? ¡Compártelo!</p>
<div class="flex gap-2">
<a href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(Astro.url.toString())}&title=${encodeURIComponent(title)}`} target="_blank" rel="noopener noreferrer" class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-input bg-background hover:bg-accent transition-colors group">
<svg class="h-4 w-4 transition-transform group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path>
</svg>
</a>
<button id="copy-link-btn" data-url={Astro.url.toString()} class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-input bg-background hover:bg-accent transition-colors">
<Link2 class="h-4 w-4" id="copy-icon-link" />
<Check class="h-4 w-4 text-green-500 hidden" id="copy-icon-check" />
</button>
</div>
</div>
<!-- Navegación -->
<nav class="max-w-3xl mx-auto mt-8 pt-8 border-t border-gray-200/50 dark:border-gray-700/50 flex justify-between gap-4">
{prev && (
<a href={`/blog/${prev.slug}`} class="group block flex-1 rounded-lg border border-border bg-card p-5 transition-all duration-500 ease-premium hover:shadow-premium hover:border-primary/30 hover:-translate-y-1 hover:scale-[1.05] max-w-sm">
<span class="text-xs text-muted-foreground">Anterior</span>
<h3 class="text-lg font-semibold group-hover:text-primary">{prev.data.title}</h3>
</a>
)}
{next && (
<a href={`/blog/${next.slug}`} class="group block flex-1 rounded-lg border border-border bg-card p-5 transition-all duration-500 ease-premium hover:shadow-premium hover:border-primary/30 hover:-translate-y-1 hover:scale-[1.05] text-right max-w-sm">
<span class="text-xs text-muted-foreground">Siguiente</span>
<h3 class="text-lg font-semibold group-hover:text-primary">{next.data.title}</h3>
</a>
)}
</nav>
</article>
</BaseLayout>
4. Componentes e Islas de Interactividad
El blog es casi 100% estático, lo que garantiza una puntuación de 100 en Lighthouse. Cargamos JavaScript solo mediante Islas de Interactividad:
- Interactividad Reactiva: El
HeaderMenu.tsxusa React para un menú móvil fluido, activado conclient:load. - Scripts Nativos: Para funciones como “Copiar Enlace”, usamos scripts ligeros que se reinicializan con el evento
astro:page-load, asegurando compatibilidad con los View Transitions.
5. El Corazón de la Estética: Tailwind v4s
El diseño visual no es solo “cosmético”; es una parte integral de la arquitectura. Utilizamos Tailwind CSS v4 con el espacio de color OKLCH, lo que permite una gestión del Modo Oscuro basada en la percepción real de la luz:
/* src/styles/global.css */
@theme inline {
--ease-premium: cubic-bezier(0.16, 1, 0.3, 1);
--animate-slide-up: slide-up 0.8s var(--ease-premium) forwards;
--shadow-premium: 0 20px 25px -5px var(--shadow-color), 0 8px 10px -6px var(--shadow-color);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--shadow-color: oklch(from var(--primary) l c h / 0.1);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--shadow-color: oklch(from var(--primary) l c h / 0.25);
}
@layer utilities {
.reveal {
opacity: 0;
animation: var(--animate-slide-up);
}
}
6. Automatización y Build
Para que el blog sea mantenible, automatizamos las tareas repetitivas:
- RSS Feed: Generado automáticamente en
src/pages/rss.xml.tsincluyendo el contenido completo de los posts. - Imágenes: El componente
<Image />de Astro redimensiona y convierte las imágenes de portada a formatos modernos como WebP/AVIF en tiempo de construcción. - Sitemap: Generado automáticamente para mejorar el rastreo de Google.
Este blog esta diseñado para ser rápido, accesible y fácil de mantener. Aprovechamos lo mejor de Astro para el rendimiento y la flexibilidad de React y Tailwind v4 para una experiencia de usuario premium.