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
2.2 Crear el proyecto
2.3 Añadir integraciones esenciales
2.4 Configuración astro.config.mjs
2.5 tsconfig.json estricto
3. Estructura de carpetas profesional
Una estructura escalable y mantenible para un blog real:
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:
4.2 BaseLayout — la base de todo
4.3 BlogLayout para posts
5. Content Collections — el corazón del blog
5.1 Definir el esquema de contenido
5.2 Escribir un post en MDX
5.3 Página dinámica de posts
5.4 Página de listado con paginación
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.)
6.2 Directivas de cliente (client directives)
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
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:
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:
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
7.4 Componente de búsqueda completo
7.5 Context con TypeScript estricto
8. Estilos — CSS Modules, Tailwind y design tokens
8.1 Variables CSS como design tokens
8.2 Tailwind config con design tokens
9. SEO, Open Graph y performance
9.1 Componente SEO completo
9.2 Optimización de imágenes
9.3 Feed RSS
10. Routing avanzado y páginas dinámicas
10.1 Página de tags
10.2 View Transitions para navegación fluida
11. Modularidad y arquitectura escalable
11.1 Barrel exports
11.2 Utilidades puras y testeables
11.3 Types centralizados
11.4 Patrón de composición en componentes Astro
12. Testing — Unit, Integration y E2E
12.1 Setup con Vitest
12.2 Tests unitarios para utilidades
12.3 Tests de componentes React
12.4 E2E con Playwright
13. Accesibilidad (a11y) de producción
13.1 Componente Button accesible
13.2 Skip navigation link
13.3 Live regions para contenido dinámico
14. Deploy y CI/CD profesional
14.1 Deploy en Vercel / Netlify
Para un blog estático (sin SSR):
14.2 GitHub Actions CI/CD
14.3 Variables de entorno
15. Patrones avanzados y trucos pro
15.1 Generación de OG images dinámicas
15.2 Modo SSR para funciones dinámicas
Si necesitas contenido completamente dinámico (formularios, autenticación):
15.3 Lazy loading de módulos React pesados
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
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
bash
# 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 pnpmpnpm --version # 9.x.x
bash
# Crear proyecto con el wizard de Astropnpm create astro@latest mi-blog# El wizard te preguntará:# ✓ Template: Blog (recomendado para empezar)# ✓ TypeScript: Yes (Strict)# ✓ Install dependencies: Yes# ✓ Initialize git: Yescd mi-blog
bash
# React para componentes interactivospnpm astro add react# Tailwind CSS para estilospnpm astro add tailwind# MDX para posts con componentespnpm astro add mdx# Sitemap automáticopnpm astro add sitemap# Compresión de imágenes y formatos modernospnpm astro add @astrojs/image # ya incluido en Astro v5 como built-in
javascript
// astro.config.mjsimport { 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', }, }, },});
---// 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 extrainterface 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>
astro
---// src/layouts/BaseLayout.astroimport 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>
astro
---// src/layouts/BlogLayout.astroimport 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>
typescript
// src/content/config.tsimport { defineCollection, z, reference } from 'astro:content';// Esquema para los posts del blogconst 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 autoresconst 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,};
mdx
---# src/content/blog/primer-post.mdxtitle: "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-15heroImage: "../../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ónAstro es una elección excelente para blogs en 2026.
astro
---// src/pages/blog/[slug].astroimport { getCollection, getEntry, render } from 'astro:content';import BlogLayout from '@layouts/BlogLayout.astro';// getStaticPaths es NECESARIO para rutas dinámicas en modo estáticoexport 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 TOCconst { Content, headings, remarkPluginFrontmatter } = await render(post);---<BlogLayout post={post}> <Content /></BlogLayout>
---// ✅ Correcto: Header sin interactividad = componente Astroimport Header from '@components/layout/Header.astro';// ✅ Correcto: Búsqueda con estado = componente Reactimport SearchPosts from '@components/blog/SearchPosts.tsx';// ✅ Correcto: Toggle de tema = componente Reactimport ThemeToggle from '@components/layout/ThemeToggle.tsx';---<Header /><SearchPosts client:load posts={posts} /><ThemeToggle client:load />
astro
<!-- 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" />
astro
---// src/pages/blog/index.astroimport { 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 />
---// Astro tiene Image optimization built-inimport { 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"/>
typescript
// src/pages/rss.xml.tsimport 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>', });}
astro
---// src/pages/tags/[tag].astroimport { 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>
astro
---// src/layouts/BaseLayout.astroimport { ViewTransitions } from 'astro:transitions';---<html> <head> <ViewTransitions /> </head> <body> <!-- Los elementos con view-transition-name se animan entre páginas --> <slot /> </body></html>
astro
---// 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>
typescript
// src/components/ui/index.ts// Centralizar exports para importaciones limpiasexport { 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';
typescript
// 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');}
typescript
// 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`;}
typescript
// src/types/blog.tsimport type { CollectionEntry } from 'astro:content';// Re-exportar tipos de Astro con nombres descriptivosexport type Post = CollectionEntry<'blog'>;export type PostData = CollectionEntry<'blog'>['data'];export type Author = CollectionEntry<'authors'>;// Types de UIexport interface PostCardProps { post: Post; variant?: 'default' | 'featured' | 'compact';}// Type helpersexport type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
// src/utils/date.test.tsimport { 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'); });});
typescript
// src/utils/reading-time.test.tsimport { 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); });});
typescript
// src/components/blog/SearchPosts.test.tsximport { 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(); });});
# .env (local, nunca en git)PUBLIC_SITE_URL=http://localhost:4321NEWSLETTER_API_KEY=tu-clave-secreta# .env.example (sí en git, como documentación)PUBLIC_SITE_URL=https://tu-dominio.comNEWSLETTER_API_KEY=
typescript
// 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;}
typescript
// src/pages/og/[slug].png.tsimport { getCollection } from 'astro:content';import type { APIRoute } from 'astro';// Puedes usar @vercel/og o satori para generar imágenes con Reactimport 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' }, });};
javascript
// astro.config.mjsexport default defineConfig({ output: 'hybrid', // 'static' por defecto, rutas específicas pueden ser 'server'});
astro
---// src/pages/api/newsletter.tsexport 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' }, });};
typescript
// Para librerías grandes, usar dynamic importsimport { lazy, Suspense } from 'react';// El bundle de react-syntax-highlighter solo se carga cuando el componente es visibleconst CodeBlock = lazy(() => import('./CodeBlock'));export function Post({ content }: { content: string }) { return ( <Suspense fallback={<pre>{content}</pre>}> <CodeBlock code={content} /> </Suspense> );}