🚀 Astro 2026 — Tutorial de 0 a Profesional
Tutorial
1. ¿Qué es Astro y por qué usarlo en 2026?
Astro es un framework MPA (Multi-Page Application) orientado a contenido que genera HTML estático por defecto, con hidratación parcial y selectiva de componentes interactivos. En 2026, con Astro v5+, las principales ventajas son:
- Zero JS by default — el cliente no recibe nada que no sea necesario.
- Islands Architecture — solo los componentes que necesitan JS son hidratados.
- View Transitions API nativa — navegación con animaciones sin SPA complejo.
- Content Layer API — gestión tipada de contenido Markdown/MDX/JSON/CMS externo.
- Server Actions — formularios y mutaciones sin API routes boilerplate.
- Agnostic a UI frameworks — puedes mezclar React, Svelte, Vue, Solid en el mismo proyecto.
¿Cuándo elegir Astro sobre Next.js?
| Criterio | Astro | Next.js |
|---|---|---|
| Blog / contenido estático | ✅ Ideal | ⚠️ Funciona pero es overkill |
| App con mucho estado cliente | ⚠️ Posible | ✅ Ideal |
| Core Web Vitals | ✅ Excelente | ⚠️ Requiere optimización |
| Tiempo de build | ✅ Muy rápido | ⚠️ Más lento en proyectos grandes |
| Bundle size | ✅ Mínimo | ⚠️ Mayor por defecto |
2. Instalación y configuración del proyecto
2.1 Requisitos previos
# Node.js 20 LTS o superior (recomendado: 22 LTS)
node --version # v22.x.x
# pnpm como gestor de paquetes (más rápido y eficiente que npm/yarn)
npm install -g pnpm
pnpm --version # 9.x.x
2.2 Crear el proyecto
# Crear proyecto con el wizard de Astro
pnpm create astro@latest mi-blog
# El wizard te preguntará:
# ✓ Template: Blog (recomendado para empezar)
# ✓ TypeScript: Yes (Strict)
# ✓ Install dependencies: Yes
# ✓ Initialize git: Yes
cd mi-blog
2.3 Añadir integraciones esenciales
# React para componentes interactivos
pnpm astro add react
# Tailwind CSS para estilos
pnpm astro add tailwind
# MDX para posts con componentes
pnpm astro add mdx
# Sitemap automático
pnpm astro add sitemap
# Compresión de imágenes y formatos modernos
pnpm astro add @astrojs/image # ya incluido en Astro v5 como built-in
2.4 Configuración astro.config.mjs
// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://mi-blog.com', // ← tu dominio real, necesario para sitemap y SEO
integrations: [
react({
// Activar React Compiler (nuevo en 2025/2026)
experimentalReactChildren: true,
}),
tailwind({
// Aplicar estilos base de Tailwind a componentes Astro
applyBaseStyles: false,
}),
mdx({
// Plugins de remark y rehype para enriquecer el Markdown
remarkPlugins: [],
rehypePlugins: [],
}),
sitemap(),
],
image: {
// Dominios externos permitidos para optimización de imágenes
domains: ['images.unsplash.com', 'res.cloudinary.com'],
remotePatterns: [{ protocol: 'https' }],
},
markdown: {
shikiConfig: {
// Syntax highlighting con temas claros y oscuros
themes: {
light: 'github-light',
dark: 'github-dark',
},
wrap: true,
},
},
vite: {
// Alias para importaciones limpias
resolve: {
alias: {
'@': '/src',
'@components': '/src/components',
'@layouts': '/src/layouts',
'@utils': '/src/utils',
'@styles': '/src/styles',
},
},
},
});
2.5 tsconfig.json estricto
{
"extends": "astro/tsconfigs/strictest",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"],
"@utils/*": ["src/utils/*"],
"@styles/*": ["src/styles/*"],
"@types/*": ["src/types/*"]
}
}
}
3. Estructura de carpetas profesional
Una estructura escalable y mantenible para un blog real:
mi-blog/
├── public/ # Archivos estáticos (favicon, robots.txt, etc.)
│ ├── favicon.svg
│ ├── robots.txt
│ └── og-default.png
│
├── src/
│ ├── components/ # Componentes reutilizables
│ │ ├── ui/ # Componentes UI genéricos (Button, Card, Badge...)
│ │ │ ├── Button/
│ │ │ │ ├── Button.astro
│ │ │ │ ├── Button.tsx # Versión React si necesita interactividad
│ │ │ │ └── index.ts # Barrel export
│ │ │ └── ...
│ │ ├── blog/ # Componentes específicos del blog
│ │ │ ├── PostCard.astro
│ │ │ ├── PostHeader.astro
│ │ │ ├── TableOfContents.tsx # React - necesita scroll spy
│ │ │ ├── ReadingProgress.tsx # React - necesita scroll events
│ │ │ └── SearchPosts.tsx # React - estado de búsqueda
│ │ ├── layout/ # Componentes estructurales
│ │ │ ├── Header.astro
│ │ │ ├── Footer.astro
│ │ │ ├── Navigation.astro
│ │ │ └── ThemeToggle.tsx # React - estado del tema
│ │ └── seo/
│ │ └── SEO.astro
│ │
│ ├── content/ # Content Collections (tipadas)
│ │ ├── config.ts # ← Esquemas Zod para validación
│ │ ├── blog/ # Posts en Markdown/MDX
│ │ │ ├── primer-post.mdx
│ │ │ └── segundo-post.md
│ │ └── authors/ # Datos de autores en JSON
│ │ └── tu-nombre.json
│ │
│ ├── layouts/ # Layouts de página
│ │ ├── BaseLayout.astro # Layout raíz (HTML, head, body)
│ │ ├── BlogLayout.astro # Layout para posts del blog
│ │ └── PageLayout.astro # Layout para páginas estáticas
│ │
│ ├── pages/ # File-based routing
│ │ ├── index.astro # Página de inicio
│ │ ├── blog/
│ │ │ ├── index.astro # Listado de posts
│ │ │ └── [slug].astro # Post individual (ruta dinámica)
│ │ ├── about.astro
│ │ ├── tags/
│ │ │ └── [tag].astro # Posts por etiqueta
│ │ ├── rss.xml.ts # Feed RSS
│ │ └── 404.astro # Página de error personalizada
│ │
│ ├── styles/ # Estilos globales
│ │ ├── global.css # Variables CSS, reset, tipografía
│ │ └── prose.css # Estilos para contenido Markdown
│ │
│ ├── types/ # TypeScript types globales
│ │ ├── index.ts
│ │ └── blog.ts
│ │
│ └── utils/ # Funciones utilitarias puras
│ ├── date.ts
│ ├── reading-time.ts
│ ├── string.ts
│ └── blog.ts
│
├── astro.config.mjs
├── tailwind.config.mjs
├── tsconfig.json
├── package.json
└── .env # Variables de entorno (nunca al repo)
Regla de oro sobre la estructura
Colocation: mantén lo relacionado junto. Un componente que solo usa
PostCardno necesita estar enui/, puede vivir enblog/. Mueve aui/solo cuando sea genuinamente reutilizable en múltiples dominios.
4. Fundamentos de Astro: páginas, layouts y componentes
4.1 Sintaxis Astro — el frontmatter
Todo .astro tiene dos zonas:
---
// ZONA SERVIDOR: Se ejecuta en build time (o en cada request si es SSR)
// Aquí puedes hacer fetch, importar módulos Node.js, leer archivos...
import { getCollection } from 'astro:content';
import BaseLayout from '@layouts/BaseLayout.astro';
import PostCard from '@components/blog/PostCard.astro';
// TypeScript nativo, sin config extra
interface Props {
title: string;
description?: string;
}
const { title, description = 'Mi blog personal' } = Astro.props;
const posts = await getCollection('blog');
const sortedPosts = posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<!-- ZONA HTML: Template con JSX-like syntax -->
<BaseLayout title={title} description={description}>
<main>
<h1>{title}</h1>
<ul>
{sortedPosts.map((post) => (
<PostCard post={post} />
))}
</ul>
</main>
</BaseLayout>
<style>
/* CSS con scope automático al componente */
main {
max-width: 800px;
margin: 0 auto;
}
</style>
4.2 BaseLayout — la base de todo
---
// src/layouts/BaseLayout.astro
import SEO from '@components/seo/SEO.astro';
import Header from '@components/layout/Header.astro';
import Footer from '@components/layout/Footer.astro';
import '@styles/global.css';
interface Props {
title: string;
description?: string;
image?: string;
article?: boolean;
}
const {
title,
description = 'Mi blog sobre desarrollo web',
image,
article = false,
} = Astro.props;
---
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<SEO
title={title}
description={description}
image={image}
article={article}
/>
<!-- View Transitions API nativa de Astro -->
<ViewTransitions />
</head>
<body>
<Header />
<slot /> <!-- Aquí se inyecta el contenido de cada página -->
<Footer />
</body>
</html>
4.3 BlogLayout para posts
---
// src/layouts/BlogLayout.astro
import BaseLayout from './BaseLayout.astro';
import TableOfContents from '@components/blog/TableOfContents';
import ReadingProgress from '@components/blog/ReadingProgress';
import type { CollectionEntry } from 'astro:content';
interface Props {
post: CollectionEntry<'blog'>;
}
const { post } = Astro.props;
const { title, description, pubDate, heroImage, tags } = post.data;
---
<BaseLayout title={title} description={description} image={heroImage} article>
<!-- Barra de progreso de lectura (componente React hidratado) -->
<ReadingProgress client:load />
<article class="prose">
<header>
<time datetime={pubDate.toISOString()}>
{pubDate.toLocaleDateString('es-ES', { dateStyle: 'long' })}
</time>
<h1>{title}</h1>
{tags && (
<ul class="tags">
{tags.map(tag => (
<li><a href={`/tags/${tag}`}>#{tag}</a></li>
))}
</ul>
)}
</header>
<div class="layout">
<!-- Tabla de contenidos con scroll spy (React hidratado) -->
<aside>
<TableOfContents client:idle />
</aside>
<div class="content">
<slot />
</div>
</div>
</article>
</BaseLayout>
5. Content Collections — el corazón del blog
5.1 Definir el esquema de contenido
// src/content/config.ts
import { defineCollection, z, reference } from 'astro:content';
// Esquema para los posts del blog
const blogCollection = defineCollection({
type: 'content', // 'content' para MD/MDX, 'data' para JSON/YAML
schema: ({ image }) =>
z.object({
title: z.string().min(1).max(100),
description: z.string().min(10).max(200),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: image().optional(), // Validación de imagen local con optimización
tags: z.array(z.string()).default([]),
author: reference('authors'), // Referencia tipada a otra colección
draft: z.boolean().default(false),
featured: z.boolean().default(false),
// Tiempo de lectura se calcula automáticamente, pero puedes sobreescribirlo
readingTime: z.number().optional(),
}),
});
// Esquema para los autores
const authorsCollection = defineCollection({
type: 'data',
schema: z.object({
name: z.string(),
bio: z.string(),
avatar: z.string().url(),
twitter: z.string().optional(),
github: z.string().optional(),
website: z.string().url().optional(),
}),
});
export const collections = {
blog: blogCollection,
authors: authorsCollection,
};
5.2 Escribir un post en MDX
---
# src/content/blog/primer-post.mdx
title: "Cómo construí mi blog con Astro en 2026"
description: "Un recorrido por las decisiones técnicas y lecciones aprendidas al migrar mi blog a Astro."
pubDate: 2026-01-15
heroImage: "../../assets/blog/primer-post-hero.jpg"
tags: ["astro", "web", "performance"]
author: "tu-nombre"
featured: true
---
import CodeComparison from '../../components/blog/CodeComparison.tsx';
import Callout from '../../components/ui/Callout.astro';
# {frontmatter.title}
Este es el primer párrafo del post. Puedes usar **Markdown** normal aquí.
<Callout type="info">
MDX te permite importar y usar componentes React o Astro directamente en tu contenido.
</Callout>
## Comparación de código
<CodeComparison
before={`// Antes: Next.js con mucho JS`}
after={`// Después: Astro con zero JS`}
client:visible
/>
## Conclusión
Astro es una elección excelente para blogs en 2026.
5.3 Página dinámica de posts
---
// src/pages/blog/[slug].astro
import { getCollection, getEntry, render } from 'astro:content';
import BlogLayout from '@layouts/BlogLayout.astro';
// getStaticPaths es NECESARIO para rutas dinámicas en modo estático
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => {
// Filtrar borradores en producción
return import.meta.env.PROD ? !data.draft : true;
});
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
// render() convierte el MD/MDX a HTML y extrae headings para TOC
const { Content, headings, remarkPluginFrontmatter } = await render(post);
---
<BlogLayout post={post}>
<Content />
</BlogLayout>
5.4 Página de listado con paginación
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import BaseLayout from '@layouts/BaseLayout.astro';
import PostCard from '@components/blog/PostCard.astro';
// Obtener posts ordenados y filtrados
const allPosts = await getCollection('blog', ({ data }) => !data.draft);
const sortedPosts = allPosts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const featuredPosts = sortedPosts.filter((p) => p.data.featured);
const regularPosts = sortedPosts.filter((p) => !p.data.featured);
---
<BaseLayout title="Blog" description="Todos los artículos">
<main>
{featuredPosts.length > 0 && (
<section>
<h2>Destacados</h2>
<div class="featured-grid">
{featuredPosts.map((post) => (
<PostCard post={post} variant="featured" />
))}
</div>
</section>
)}
<section>
<h2>Todos los artículos</h2>
<div class="posts-grid">
{regularPosts.map((post) => (
<PostCard post={post} />
))}
</div>
</section>
</main>
</BaseLayout>
6. React en Astro — Islands Architecture
6.1 Cuándo usar React vs Astro
Regla fundamental: Usa componentes Astro por defecto. Cambia a React solo cuando necesites:
- Estado del cliente (
useState,useReducer) - Efectos que dependen del DOM (
useEffect,useRef) - Contexto de React para compartir estado
- Librerías que requieren React (react-query, framer-motion, etc.)
---
// ✅ Correcto: Header sin interactividad = componente Astro
import Header from '@components/layout/Header.astro';
// ✅ Correcto: Búsqueda con estado = componente React
import SearchPosts from '@components/blog/SearchPosts.tsx';
// ✅ Correcto: Toggle de tema = componente React
import ThemeToggle from '@components/layout/ThemeToggle.tsx';
---
<Header />
<SearchPosts client:load posts={posts} />
<ThemeToggle client:load />
6.2 Directivas de cliente (client directives)
<!-- Se hidrata inmediatamente al cargar la página -->
<SearchBar client:load />
<!-- Se hidrata cuando el navegador está idle (para no bloquear LCP) -->
<TableOfContents client:idle />
<!-- Se hidrata cuando el componente entra en el viewport -->
<CommentSection client:visible />
<!-- Se hidrata solo en mobile (media query) -->
<MobileMenu client:media="(max-width: 768px)" />
<!-- Solo se renderiza en cliente, sin SSR (útil para localStorage) -->
<ThemeToggle client:only="react" />
Cuándo usar cada directiva:
| Directiva | Cuándo usarla |
|---|---|
client:load | Componentes críticos visibles al inicio (search, nav interactiva) |
client:idle | Componentes importantes pero no críticos (TOC, widgets) |
client:visible | Componentes below the fold (comentarios, related posts) |
client:media | Componentes solo necesarios en ciertos breakpoints |
client:only | Componentes que usan APIs solo disponibles en cliente |
6.3 Pasar datos de Astro a React
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import SearchPosts from '@components/blog/SearchPosts';
const posts = await getCollection('blog', ({ data }) => !data.draft);
// Serializar solo los datos necesarios (no el contenido completo)
const searchableData = posts.map((post) => ({
slug: post.slug,
title: post.data.title,
description: post.data.description,
tags: post.data.tags,
pubDate: post.data.pubDate.toISOString(),
}));
---
<!-- Solo pasamos datos serializables a React -->
<SearchPosts posts={searchableData} client:load />
7. React moderno 2026 — hooks, patterns y best practices
7.1 React 19+ — Novedades clave
En 2026 trabajamos con React 19+. Los cambios más importantes:
// React 19: use() hook para promises y context
import { use } from 'react';
function PostContent({ contentPromise }: { contentPromise: Promise<string> }) {
// use() puede leer promesas directamente (suspense automático)
const content = use(contentPromise);
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
// React 19: useActionState para formularios
import { useActionState } from 'react';
function ContactForm() {
const [state, submitAction, isPending] = useActionState(
async (prevState: FormState, formData: FormData) => {
const email = formData.get('email') as string;
// Lógica del servidor...
return { success: true, message: 'Email enviado' };
},
{ success: false, message: '' }
);
return (
<form action={submitAction}>
<input name="email" type="email" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Enviando...' : 'Enviar'}
</button>
{state.message && <p>{state.message}</p>}
</form>
);
}
7.2 React Compiler — memo automático
En 2026, React Compiler (ex React Forget) está disponible y elimina la necesidad de useMemo, useCallback y React.memo manuales en la mayoría de casos:
// ❌ Antes: Optimizaciones manuales verbosas
const ExpensiveList = React.memo(({ items, onSelect }: Props) => {
const sortedItems = useMemo(
() => items.sort((a, b) => a.title.localeCompare(b.title)),
[items]
);
const handleSelect = useCallback((id: string) => {
onSelect(id);
}, [onSelect]);
return <ul>{sortedItems.map(item => (
<li key={item.id} onClick={() => handleSelect(item.id)}>{item.title}</li>
))}</ul>;
});
// ✅ Con React Compiler: el compilador hace esto automáticamente
function ExpensiveList({ items, onSelect }: Props) {
const sortedItems = items.sort((a, b) => a.title.localeCompare(b.title));
return (
<ul>
{sortedItems.map((item) => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.title}
</li>
))}
</ul>
);
}
Nota: Aunque el compilador gestiona la mayoría de memoizaciones, entiende qué hace por debajo. Si tienes cálculos extremadamente costosos o caches explícitas,
useMemosigue siendo válido.
7.3 Custom Hooks — encapsular lógica
// src/hooks/useTheme.ts
import { useState, useEffect } from 'react';
type Theme = 'light' | 'dark' | 'system';
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
// Leer del localStorage solo en cliente
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme) ?? 'system';
});
const resolvedTheme =
theme === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme;
useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-theme', resolvedTheme);
localStorage.setItem('theme', theme);
}, [theme, resolvedTheme]);
return { theme, resolvedTheme, setTheme } as const;
}
// src/hooks/useScrollProgress.ts
import { useState, useEffect } from 'react';
export function useScrollProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
function updateProgress() {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
setProgress(docHeight > 0 ? (scrollTop / docHeight) * 100 : 0);
}
// Usar passive event listener para mejor performance
window.addEventListener('scroll', updateProgress, { passive: true });
return () => window.removeEventListener('scroll', updateProgress);
}, []);
return progress;
}
// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
7.4 Componente de búsqueda completo
// src/components/blog/SearchPosts.tsx
import { useState, useMemo } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
interface SearchablePost {
slug: string;
title: string;
description: string;
tags: string[];
pubDate: string;
}
interface Props {
posts: SearchablePost[];
}
export default function SearchPosts({ posts }: Props) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 200);
const filteredPosts = useMemo(() => {
if (!debouncedQuery.trim()) return posts;
const normalizedQuery = debouncedQuery.toLowerCase();
return posts.filter(
(post) =>
post.title.toLowerCase().includes(normalizedQuery) ||
post.description.toLowerCase().includes(normalizedQuery) ||
post.tags.some((tag) => tag.toLowerCase().includes(normalizedQuery))
);
}, [posts, debouncedQuery]);
return (
<div className="search-container">
<label htmlFor="search" className="sr-only">
Buscar artículos
</label>
<input
id="search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar artículos..."
aria-label="Buscar artículos"
aria-controls="search-results"
className="search-input"
/>
<div
id="search-results"
role="region"
aria-label="Resultados de búsqueda"
aria-live="polite"
>
{debouncedQuery && (
<p className="results-count">
{filteredPosts.length === 0
? 'No se encontraron resultados'
: `${filteredPosts.length} resultado${filteredPosts.length !== 1 ? 's' : ''}`}
</p>
)}
{filteredPosts.length > 0 && (
<ul className="results-list">
{filteredPosts.map((post) => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>
<h3>{post.title}</h3>
<p>{post.description}</p>
<div className="tags">
{post.tags.map((tag) => (
<span key={tag} className="tag">
#{tag}
</span>
))}
</div>
</a>
</li>
))}
</ul>
)}
</div>
</div>
);
}
7.5 Context con TypeScript estricto
// src/context/ThemeContext.tsx
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { useTheme } from '@/hooks/useTheme';
interface ThemeContextValue {
theme: 'light' | 'dark' | 'system';
resolvedTheme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark' | 'system') => void;
}
// Error explícito si se usa fuera del Provider
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const themeValue = useTheme();
return (
<ThemeContext.Provider value={themeValue}>
{children}
</ThemeContext.Provider>
);
}
// Hook con error tipado — nunca retorna undefined
export function useThemeContext(): ThemeContextValue {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useThemeContext debe usarse dentro de ThemeProvider');
}
return context;
}
8. Estilos — CSS Modules, Tailwind y design tokens
8.1 Variables CSS como design tokens
/* src/styles/global.css */
/* Design Tokens — única fuente de verdad para el diseño */
:root {
/* Colores */
--color-primary-50: #eff6ff;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-900: #1e3a8a;
/* Tipografía */
--font-sans: 'Inter Variable', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono Variable', 'Fira Code', monospace;
--font-size-sm: clamp(0.875rem, 0.8rem + 0.2vw, 0.9rem);
--font-size-base: clamp(1rem, 0.9rem + 0.3vw, 1.125rem);
--font-size-lg: clamp(1.125rem, 1rem + 0.5vw, 1.25rem);
--font-size-xl: clamp(1.25rem, 1.1rem + 0.8vw, 1.5rem);
--font-size-2xl: clamp(1.5rem, 1.3rem + 1vw, 2rem);
--font-size-3xl: clamp(2rem, 1.7rem + 1.5vw, 3rem);
/* Espaciado (escala 4px) */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--space-16: 4rem;
/* Radios */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-full: 9999px;
/* Sombras */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
/* Transiciones */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-slow: 350ms ease;
/* Layout */
--content-width: 65ch;
--wide-width: 85ch;
--full-width: 100%;
/* Tema claro (por defecto) */
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f3f4f6;
--text-primary: #111827;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--border-color: #e5e7eb;
--link-color: var(--color-primary-600);
}
/* Tema oscuro */
[data-theme='dark'] {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border-color: #334155;
--link-color: #60a5fa;
}
/* Preferencia del sistema */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--bg-primary: #0f172a;
/* ... mismas variables que [data-theme='dark'] */
}
}
/* Reset moderno */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: var(--font-sans);
font-size: var(--font-size-base);
color: var(--text-primary);
background-color: var(--bg-primary);
/* Scroll suave respetando preferencias de usuario */
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
/* Tipografía fluida */
body {
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* Focus visible para accesibilidad */
:focus-visible {
outline: 2px solid var(--link-color);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
8.2 Tailwind config con design tokens
// tailwind.config.mjs
import defaultTheme from 'tailwindcss/defaultTheme';
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
// Sincronizar con data-theme attribute
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
fontFamily: {
sans: ['Inter Variable', ...defaultTheme.fontFamily.sans],
mono: ['JetBrains Mono Variable', ...defaultTheme.fontFamily.mono],
},
colors: {
primary: {
50: 'var(--color-primary-50)',
500: 'var(--color-primary-500)',
600: 'var(--color-primary-600)',
900: 'var(--color-primary-900)',
},
},
maxWidth: {
content: '65ch',
wide: '85ch',
},
typography: (theme) => ({
DEFAULT: {
css: {
maxWidth: '65ch',
color: 'var(--text-primary)',
a: {
color: 'var(--link-color)',
textDecoration: 'underline',
textUnderlineOffset: '2px',
},
'code::before': { content: '""' },
'code::after': { content: '""' },
},
},
}),
},
},
plugins: [
require('@tailwindcss/typography'),
require('@tailwindcss/container-queries'),
],
};
9. SEO, Open Graph y performance
9.1 Componente SEO completo
---
// src/components/seo/SEO.astro
interface Props {
title: string;
description: string;
image?: string;
article?: boolean;
pubDate?: Date;
tags?: string[];
}
const {
title,
description,
image = '/og-default.png',
article = false,
pubDate,
tags = [],
} = Astro.props;
const siteUrl = Astro.site?.toString() ?? '';
const canonicalURL = new URL(Astro.url.pathname, siteUrl);
const socialImage = new URL(image, siteUrl);
const fullTitle = title === 'Inicio' ? 'Mi Blog' : `${title} — Mi Blog`;
---
<!-- Meta base -->
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
<!-- Open Graph -->
<meta property="og:type" content={article ? 'article' : 'website'} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:image" content={socialImage} />
<meta property="og:image:alt" content={description} />
<meta property="og:site_name" content="Mi Blog" />
<meta property="og:locale" content="es_ES" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@tu_usuario" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={socialImage} />
<!-- Artículo específico -->
{article && pubDate && (
<meta property="article:published_time" content={pubDate.toISOString()} />
)}
{article && tags.map((tag) => (
<meta property="article:tag" content={tag} />
))}
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{JSON.stringify(
article
? {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: title,
description,
image: socialImage.toString(),
url: canonicalURL.toString(),
datePublished: pubDate?.toISOString(),
author: {
'@type': 'Person',
name: 'Tu Nombre',
url: siteUrl,
},
publisher: {
'@type': 'Organization',
name: 'Mi Blog',
url: siteUrl,
},
}
: {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Mi Blog',
url: siteUrl,
potentialAction: {
'@type': 'SearchAction',
target: `${siteUrl}/blog?q={search_term_string}`,
'query-input': 'required name=search_term_string',
},
}
)}
</script>
9.2 Optimización de imágenes
---
// Astro tiene Image optimization built-in
import { Image, Picture } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<!-- Image básico con optimización automática -->
<Image
src={heroImage}
alt="Descripción descriptiva de la imagen"
width={800}
height={400}
format="webp"
quality={85}
loading="eager" // Para LCP images
fetchpriority="high"
/>
<!-- Picture para Art Direction (diferentes imágenes según viewport) -->
<Picture
src={heroImage}
alt="Hero image"
formats={['avif', 'webp']}
widths={[400, 800, 1200]}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1200px"
/>
9.3 Feed RSS
// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
export async function GET(context: APIContext) {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const sortedPosts = posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
return rss({
title: 'Mi Blog',
description: 'Artículos sobre desarrollo web moderno',
site: context.site!,
items: sortedPosts.map((post) => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `/blog/${post.slug}/`,
categories: post.data.tags,
})),
customData: '<language>es-es</language>',
});
}
10. Routing avanzado y páginas dinámicas
10.1 Página de tags
---
// src/pages/tags/[tag].astro
import { getCollection } from 'astro:content';
import BaseLayout from '@layouts/BaseLayout.astro';
import PostCard from '@components/blog/PostCard.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
// Extraer todos los tags únicos
const allTags = [...new Set(posts.flatMap((post) => post.data.tags))];
return allTags.map((tag) => ({
params: { tag },
props: {
tag,
posts: posts.filter((post) => post.data.tags.includes(tag)),
},
}));
}
const { tag, posts } = Astro.props;
const sortedPosts = posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<BaseLayout
title={`#${tag}`}
description={`Artículos sobre ${tag}`}
>
<main>
<h1>#{tag}</h1>
<p>{sortedPosts.length} artículo{sortedPosts.length !== 1 ? 's' : ''}</p>
<div class="posts-grid">
{sortedPosts.map((post) => <PostCard post={post} />)}
</div>
</main>
</BaseLayout>
10.2 View Transitions para navegación fluida
---
// src/layouts/BaseLayout.astro
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<!-- Los elementos con view-transition-name se animan entre páginas -->
<slot />
</body>
</html>
---
// En PostCard.astro — nombrar elementos para animaciones
---
<article>
<!-- Esta imagen se anima fluidamente al navegar al post -->
<img
transition:name={`hero-${post.slug}`}
src={post.data.heroImage}
alt=""
/>
<h2 transition:name={`title-${post.slug}`}>
{post.data.title}
</h2>
</article>
11. Modularidad y arquitectura escalable
11.1 Barrel exports
// src/components/ui/index.ts
// Centralizar exports para importaciones limpias
export { default as Button } from './Button/Button.astro';
export { default as Badge } from './Badge/Badge.astro';
export { default as Card } from './Card/Card.astro';
export { default as Callout } from './Callout/Callout.astro';
// Uso:
// import { Button, Badge, Card } from '@components/ui';
// En vez de:
// import Button from '@components/ui/Button/Button.astro';
// import Badge from '@components/ui/Badge/Badge.astro';
11.2 Utilidades puras y testeables
// src/utils/date.ts
// Funciones puras: sin efectos secundarios, fáciles de testear
/**
* Formatea una fecha para mostrar al usuario
* @example formatDate(new Date('2026-01-15')) → "15 de enero de 2026"
*/
export function formatDate(
date: Date,
locale: string = 'es-ES',
options: Intl.DateTimeFormatOptions = { dateStyle: 'long' }
): string {
return new Intl.DateTimeFormat(locale, options).format(date);
}
/**
* Formatea fecha relativa ("hace 3 días")
*/
export function formatRelativeDate(date: Date, locale: string = 'es-ES'): string {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const diffMs = date.getTime() - Date.now();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
if (Math.abs(diffDays) < 1) return 'hoy';
if (Math.abs(diffDays) < 30) return rtf.format(diffDays, 'day');
if (Math.abs(diffDays) < 365) return rtf.format(Math.round(diffDays / 30), 'month');
return rtf.format(Math.round(diffDays / 365), 'year');
}
// src/utils/reading-time.ts
/**
* Calcula el tiempo de lectura estimado de un texto
* @param text - Contenido en texto plano
* @param wpm - Palabras por minuto (adulto promedio: 200-250)
*/
export function calculateReadingTime(text: string, wpm: number = 225): number {
const words = text.trim().split(/\s+/).length;
return Math.max(1, Math.ceil(words / wpm));
}
export function formatReadingTime(minutes: number): string {
return `${minutes} min de lectura`;
}
11.3 Types centralizados
// src/types/blog.ts
import type { CollectionEntry } from 'astro:content';
// Re-exportar tipos de Astro con nombres descriptivos
export type Post = CollectionEntry<'blog'>;
export type PostData = CollectionEntry<'blog'>['data'];
export type Author = CollectionEntry<'authors'>;
// Types de UI
export interface PostCardProps {
post: Post;
variant?: 'default' | 'featured' | 'compact';
}
// Type helpers
export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
11.4 Patrón de composición en componentes Astro
---
// src/components/blog/PostCard.astro
// Componente flexible con variantes via props
import type { Post } from '@/types/blog';
import { Image } from 'astro:assets';
import { formatDate, formatRelativeDate } from '@utils/date';
import { formatReadingTime } from '@utils/reading-time';
import Badge from '@components/ui/Badge/Badge.astro';
interface Props {
post: Post;
variant?: 'default' | 'featured' | 'compact';
showImage?: boolean;
}
const { post, variant = 'default', showImage = true } = Astro.props;
const { title, description, pubDate, heroImage, tags, readingTime } = post.data;
---
<article class:list={['post-card', `post-card--${variant}`]}>
{showImage && heroImage && (
<div class="post-card__image">
<a href={`/blog/${post.slug}`} tabindex="-1" aria-hidden="true">
<Image
src={heroImage}
alt=""
width={variant === 'featured' ? 800 : 400}
height={variant === 'featured' ? 400 : 200}
transition:name={`hero-${post.slug}`}
/>
</a>
</div>
)}
<div class="post-card__content">
{tags.length > 0 && (
<div class="post-card__tags">
{tags.slice(0, 3).map((tag) => (
<Badge href={`/tags/${tag}`}>{tag}</Badge>
))}
</div>
)}
<h2 class="post-card__title">
<a href={`/blog/${post.slug}`} transition:name={`title-${post.slug}`}>
{title}
</a>
</h2>
{variant !== 'compact' && (
<p class="post-card__description">{description}</p>
)}
<footer class="post-card__meta">
<time datetime={pubDate.toISOString()}>
{formatDate(pubDate)}
</time>
{readingTime && (
<span>{formatReadingTime(readingTime)}</span>
)}
</footer>
</div>
</article>
12. Testing — Unit, Integration y E2E
12.1 Setup con Vitest
pnpm add -D vitest @vitest/coverage-v8 happy-dom @testing-library/react @testing-library/user-event
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['src/test/**', '**/*.d.ts'],
},
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@utils': resolve(__dirname, './src/utils'),
'@components': resolve(__dirname, './src/components'),
},
},
});
12.2 Tests unitarios para utilidades
// src/utils/date.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate, formatRelativeDate } from './date';
describe('formatDate', () => {
it('formatea una fecha en español por defecto', () => {
const date = new Date('2026-01-15');
expect(formatDate(date)).toBe('15 de enero de 2026');
});
it('acepta opciones de formato personalizadas', () => {
const date = new Date('2026-01-15');
const result = formatDate(date, 'es-ES', { month: 'short', year: 'numeric' });
expect(result).toBe('ene 2026');
});
});
describe('formatRelativeDate', () => {
it('devuelve "hoy" para fechas del mismo día', () => {
const today = new Date();
expect(formatRelativeDate(today)).toBe('hoy');
});
});
// src/utils/reading-time.test.ts
import { describe, it, expect } from 'vitest';
import { calculateReadingTime, formatReadingTime } from './reading-time';
describe('calculateReadingTime', () => {
it('calcula 1 minuto para textos cortos', () => {
const shortText = 'palabra '.repeat(50);
expect(calculateReadingTime(shortText)).toBe(1);
});
it('calcula correctamente para textos largos', () => {
const longText = 'palabra '.repeat(450); // 450 palabras a 225wpm = 2 min
expect(calculateReadingTime(longText)).toBe(2);
});
it('nunca devuelve menos de 1 minuto', () => {
expect(calculateReadingTime('')).toBe(1);
expect(calculateReadingTime('hola')).toBe(1);
});
});
12.3 Tests de componentes React
// src/components/blog/SearchPosts.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import SearchPosts from './SearchPosts';
const mockPosts = [
{ slug: 'post-1', title: 'Intro a Astro', description: 'Un tutorial básico', tags: ['astro'], pubDate: '2026-01-01' },
{ slug: 'post-2', title: 'React avanzado', description: 'Patrones modernos', tags: ['react'], pubDate: '2026-01-02' },
{ slug: 'post-3', title: 'TypeScript tips', description: 'Mejores prácticas', tags: ['typescript', 'react'], pubDate: '2026-01-03' },
];
describe('SearchPosts', () => {
it('muestra todos los posts inicialmente', () => {
render(<SearchPosts posts={mockPosts} />);
expect(screen.getByText('Intro a Astro')).toBeInTheDocument();
expect(screen.getByText('React avanzado')).toBeInTheDocument();
});
it('filtra posts al escribir en el buscador', async () => {
const user = userEvent.setup();
render(<SearchPosts posts={mockPosts} />);
await user.type(screen.getByRole('searchbox'), 'react');
expect(screen.queryByText('Intro a Astro')).not.toBeInTheDocument();
expect(screen.getByText('React avanzado')).toBeInTheDocument();
expect(screen.getByText('TypeScript tips')).toBeInTheDocument(); // tiene tag react
});
it('muestra mensaje cuando no hay resultados', async () => {
const user = userEvent.setup();
render(<SearchPosts posts={mockPosts} />);
await user.type(screen.getByRole('searchbox'), 'xyzabcnotexists');
expect(screen.getByText('No se encontraron resultados')).toBeInTheDocument();
});
it('es accesible — tiene roles ARIA correctos', () => {
render(<SearchPosts posts={mockPosts} />);
expect(screen.getByRole('searchbox')).toHaveAccessibleName('Buscar artículos');
expect(screen.getByRole('region', { name: 'Resultados de búsqueda' })).toBeInTheDocument();
});
});
12.4 E2E con Playwright
pnpm add -D @playwright/test
npx playwright install chromium
// e2e/blog.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Blog navigation', () => {
test('puede navegar al blog y ver posts', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Blog' }).click();
await expect(page).toHaveURL('/blog');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
test('la búsqueda filtra posts en tiempo real', async ({ page }) => {
await page.goto('/blog');
const search = page.getByRole('searchbox', { name: 'Buscar artículos' });
await search.fill('astro');
// Los posts no relevantes deben desaparecer
await expect(page.locator('.post-card')).not.toHaveCount(0);
});
test('la navegación con View Transitions funciona', async ({ page }) => {
await page.goto('/blog');
const firstPost = page.locator('.post-card').first();
const postTitle = await firstPost.locator('h2').textContent();
await firstPost.locator('a').first().click();
await expect(page.getByRole('heading', { level: 1 })).toHaveText(postTitle!);
});
});
test.describe('Accesibilidad básica', () => {
test('la página de inicio tiene landmark regions', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('banner')).toBeVisible(); // <header>
await expect(page.getByRole('main')).toBeVisible();
await expect(page.getByRole('contentinfo')).toBeVisible(); // <footer>
});
test('las imágenes tienen texto alternativo', async ({ page }) => {
await page.goto('/blog');
const images = page.locator('img:not([aria-hidden="true"])');
for (const img of await images.all()) {
const alt = await img.getAttribute('alt');
expect(alt).not.toBeNull();
}
});
});
13. Accesibilidad (a11y) de producción
13.1 Componente Button accesible
---
// src/components/ui/Button/Button.astro
interface Props {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
href?: string;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
ariaLabel?: string;
class?: string;
}
const {
variant = 'primary',
size = 'md',
href,
type = 'button',
disabled = false,
ariaLabel,
class: className,
} = Astro.props;
const Tag = href ? 'a' : 'button';
---
<!-- Usa <a> para enlaces, <button> para acciones -->
<Tag
class:list={['btn', `btn--${variant}`, `btn--${size}`, className]}
href={href}
type={!href ? type : undefined}
disabled={!href ? disabled : undefined}
aria-disabled={href && disabled ? 'true' : undefined}
aria-label={ariaLabel}
>
<slot />
</Tag>
<style>
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-weight: 500;
text-decoration: none;
cursor: pointer;
border: 2px solid transparent;
transition: all var(--transition-fast);
}
.btn:focus-visible {
outline: 2px solid var(--link-color);
outline-offset: 2px;
}
.btn[disabled],
.btn[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.btn--primary {
background-color: var(--color-primary-600);
color: white;
}
.btn--primary:hover:not([disabled]) {
background-color: var(--color-primary-500);
}
</style>
13.2 Skip navigation link
---
// src/layouts/BaseLayout.astro — dentro del <body>, antes que todo
---
<!-- Visible solo al recibir focus — mejora la navegación por teclado -->
<a href="#main-content" class="skip-link">
Saltar al contenido principal
</a>
<Header />
<main id="main-content" tabindex="-1">
<slot />
</main>
<style>
.skip-link {
position: absolute;
top: -100%;
left: var(--space-4);
z-index: 100;
padding: var(--space-2) var(--space-4);
background: var(--color-primary-600);
color: white;
border-radius: 0 0 var(--radius-md) var(--radius-md);
font-weight: 600;
text-decoration: none;
transition: top var(--transition-fast);
}
.skip-link:focus {
top: 0;
}
</style>
13.3 Live regions para contenido dinámico
// src/components/blog/SearchPosts.tsx
// Los aria-live regions anuncian cambios a lectores de pantalla
export default function SearchPosts({ posts }: Props) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 200);
const filteredPosts = /* ... */;
return (
<div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-controls="results"
aria-label="Buscar artículos"
/>
{/* aria-live="polite" anuncia cambios sin interrumpir */}
{/* aria-atomic="true" anuncia el resultado completo, no fragmentos */}
<p
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{debouncedQuery
? `${filteredPosts.length} resultados para "${debouncedQuery}"`
: ''}
</p>
<ul id="results">
{/* ... */}
</ul>
</div>
);
}
14. Deploy y CI/CD profesional
14.1 Deploy en Vercel / Netlify
# Vercel (recomendado para SSR/serverless)
pnpm add @astrojs/vercel
# Actualizar astro.config.mjs:
# import vercel from '@astrojs/vercel/serverless';
# output: 'server', adapter: vercel()
# Netlify
pnpm add @astrojs/netlify
Para un blog estático (sin SSR):
pnpm build
# Genera la carpeta /dist lista para deploy en cualquier CDN
# Vercel, Netlify, Cloudflare Pages, GitHub Pages, etc.
14.2 GitHub Actions CI/CD
# .github/workflows/ci.yml
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
quality:
name: Quality checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check
run: pnpm astro check
- name: Lint
run: pnpm lint
- name: Unit tests
run: pnpm test --coverage
- name: Build
run: pnpm build
e2e:
name: E2E tests
runs-on: ubuntu-latest
needs: quality
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm playwright install --with-deps chromium
- run: pnpm build
- run: pnpm e2e
14.3 Variables de entorno
# .env (local, nunca en git)
PUBLIC_SITE_URL=http://localhost:4321
NEWSLETTER_API_KEY=tu-clave-secreta
# .env.example (sí en git, como documentación)
PUBLIC_SITE_URL=https://tu-dominio.com
NEWSLETTER_API_KEY=
// src/env.d.ts — tipos para las variables de entorno
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_SITE_URL: string;
readonly NEWSLETTER_API_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
15. Patrones avanzados y trucos pro
15.1 Generación de OG images dinámicas
// src/pages/og/[slug].png.ts
import { getCollection } from 'astro:content';
import type { APIRoute } from 'astro';
// Puedes usar @vercel/og o satori para generar imágenes con React
import satori from 'satori';
import { html } from 'satori-html';
import sharp from 'sharp';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({ params: { slug: post.slug }, props: { post } }));
}
export const GET: APIRoute = async ({ props }) => {
const { post } = props;
const markup = html`
<div style="display:flex;background:#0f172a;width:1200px;height:630px;padding:60px;flex-direction:column;justify-content:space-between;">
<div style="display:flex;flex-direction:column;gap:20px;">
<p style="color:#60a5fa;font-size:18px;font-weight:600;">${post.data.tags[0] ?? 'Blog'}</p>
<h1 style="color:white;font-size:52px;font-weight:800;line-height:1.1;max-width:800px;">${post.data.title}</h1>
</div>
<p style="color:#94a3b8;font-size:22px;">Mi Blog • ${post.data.pubDate.toLocaleDateString('es-ES')}</p>
</div>
`;
const svg = await satori(markup, {
width: 1200,
height: 630,
fonts: [], // añadir fuentes si es necesario
});
const png = await sharp(Buffer.from(svg)).png().toBuffer();
return new Response(png, {
headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000' },
});
};
15.2 Modo SSR para funciones dinámicas
Si necesitas contenido completamente dinámico (formularios, autenticación):
// astro.config.mjs
export default defineConfig({
output: 'hybrid', // 'static' por defecto, rutas específicas pueden ser 'server'
});
---
// src/pages/api/newsletter.ts
export const prerender = false; // Esta ruta es dinámica (server-side)
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const data = await request.json();
const { email } = data;
// Aquí puedes llamar a tu API de newsletter (Mailchimp, ConvertKit, etc.)
// usando import.meta.env.NEWSLETTER_API_KEY
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
15.3 Lazy loading de módulos React pesados
// Para librerías grandes, usar dynamic imports
import { lazy, Suspense } from 'react';
// El bundle de react-syntax-highlighter solo se carga cuando el componente es visible
const CodeBlock = lazy(() => import('./CodeBlock'));
export function Post({ content }: { content: string }) {
return (
<Suspense fallback={<pre>{content}</pre>}>
<CodeBlock code={content} />
</Suspense>
);
}
15.4 Checklist final antes de hacer deploy
Rendimiento:
✅ Lighthouse score >90 en todos los Core Web Vitals
✅ Imágenes con formato WebP/AVIF y dimensiones correctas
✅ Fuentes con font-display: swap o variable fonts
✅ No hay JavaScript no necesario en el cliente
SEO:
✅ Cada página tiene <title> y <meta description> únicos
✅ Sitemap.xml generado y correcto
✅ robots.txt configurado
✅ RSS feed disponible en /rss.xml
✅ Open Graph images para todas las páginas
✅ JSON-LD structured data en posts
Accesibilidad:
✅ Todas las imágenes tienen alt text (o alt="" si son decorativas)
✅ La página es navegable solo con teclado
✅ Skip navigation link presente
✅ Contraste de color suficiente (WCAG AA mínimo)
✅ Formularios tienen labels asociados
✅ Los aria-live regions anuncian contenido dinámico
Código:
✅ TypeScript strict sin errores
✅ Tests unitarios pasan (cobertura >80% en utils)
✅ Tests E2E pasan en CI
✅ Variables de entorno secretas no están en el repo
✅ .env.example está actualizado
Recursos y próximos pasos
Documentación oficial:
- docs.astro.build — siempre la fuente de verdad
- react.dev — React docs con hooks modernos
- vitejs.dev — Vite (el bundler detrás de Astro)
Para profundizar:
- Astro DB — base de datos SQLite integrada para comentarios, likes, etc.
- Astro Actions — Server Actions tipados sin necesidad de API routes
- Content Layer API — integración con CMSs externos (Contentful, Sanity, etc.)
- Astro Starlight — si además quieres documentación técnica
Tutorial actualizado para Astro v5+ y React 19+ | 2026