Skip to content

🚀 Astro 2026 — Tutorial de 0 a Profesional

Tutorial

45 min read

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?

CriterioAstroNext.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 PostCard no necesita estar en ui/, puede vivir en blog/. Mueve a ui/ 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:

DirectivaCuándo usarla
client:loadComponentes críticos visibles al inicio (search, nav interactiva)
client:idleComponentes importantes pero no críticos (TOC, widgets)
client:visibleComponentes below the fold (comentarios, related posts)
client:mediaComponentes solo necesarios en ciertos breakpoints
client:onlyComponentes 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, useMemo sigue 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>
---
// 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:

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