Cuándo Usar
- Landing pages y sitios de marketing
- Sitios corporativos
- Sitios con mucho contenido (blogs, documentación)
- Cualquier proyecto donde el SEO y el rendimiento sean críticos
- Sitios mayormente estáticos con mínima interactividad
- Sitios que necesitan carga inicial rápida y Core Web Vitals
NO para aplicaciones web completas — usa vue-frontend para aplicaciones interactivas (paneles, apps CRUD, apps en tiempo real).
Reglas Críticas
- Zero JS por defecto — Astro NO envía JavaScript a menos que añadas una directiva cliente
- TypeScript SIEMPRE — igual que todos los proyectos de Vera Sesiom, modo estricto
- Usa Colecciones de Contenido para contenido estructurado — posts de blog, documentación, casos de estudio
- Arquitectura de Islas — componentes Vue/React solo donde se necesita interactividad
- Salida estática (SSG) por defecto — SSR solo cuando el contenido dinámico lo requiere
- Tailwind CSS para estilos — igual que el frontend Vue
- Screaming Architecture — las carpetas gritan el dominio, no el framework
Criterios de Decisión: Astro vs Vue
| Cuándo usar Astro | Cuándo usar Vue |
|---|---|
| Landing page, sitio de marketing | Aplicación web (panel, admin) |
| Mucho contenido (blog, docs) | UI altamente interactiva |
| SEO es crítico | Se necesitan funciones en tiempo real |
| Contenido mayormente estático | Gestión de estado compleja |
| Interactividad mínima (formularios, carruseles) | Navegación SPA completa |
| Zero JS es una característica | Los beneficios del ecosistema Vue importan |
Regla: Si el sitio es contenido que la gente LEE → Astro. Si es una app que la gente USA → Vue.
Estructura del Proyecto (Screaming Architecture)
apps/landing/ (o apps/website/)
├── src/
│ ├── pages/ # Enrutamiento basado en archivos (.astro)
│ │ ├── index.astro # Página de inicio
│ │ ├── about.astro # Página 'Acerca de'
│ │ ├── pricing.astro # Página de precios
│ │ ├── blog/
│ │ │ ├── index.astro # Lista de blogs
│ │ │ └── [slug].astro # Post de blog (dinámico)
│ │ └── contact.astro
│ ├── layouts/ # Layouts de página
│ │ ├── BaseLayout.astro # Estructura HTML base
│ │ ├── BlogLayout.astro # Layout de post de blog
│ │ └── LandingLayout.astro # Layout de landing page
│ ├── components/ # Componentes Astro (estáticos por defecto)
│ │ ├── sections/ # Secciones de página
│ │ │ ├── Hero.astro
│ │ │ ├── Features.astro
│ │ │ ├── Pricing.astro
│ │ │ ├── Testimonials.astro
│ │ │ └── CTA.astro
│ │ ├── ui/ # Átomos UI reutilizables
│ │ │ ├── Button.astro
│ │ │ ├── Card.astro
│ │ │ ├── Badge.astro
│ │ │ └── Input.astro
│ │ └── interactive/ # Islas Vue/React
│ │ ├── ContactForm.vue # Formulario con validación
│ │ ├── Carousel.vue # Carrusel de imágenes
│ │ └── MobileMenu.vue # Navegación móvil
│ ├── content/ # Colecciones de contenido
│ │ ├── blog/ # Posts de blog en Markdown/MDX
│ │ │ ├── post-1.md
│ │ │ └── post-2.mdx
│ │ └── config.ts # Esquema de colección (Zod)
│ ├── styles/ # Estilos globales
│ │ └── global.css # Importaciones de Tailwind
│ ├── assets/ # Imágenes optimizadas, fuentes
│ │ ├── images/
│ │ └── fonts/
│ └── utils/ # Funciones auxiliares
│ ├── date-format.ts
│ └── seo.ts
├── public/ # Assets estáticos (sin procesamiento)
│ ├── favicon.ico
│ ├── robots.txt
│ └── fonts/
├── astro.config.mjs # Configuración de Astro
├── tailwind.config.mjs # Configuración de Tailwind
├── tsconfig.json
└── package.json
Patrón de Componente Astro
Cada archivo .astro tiene dos partes: frontmatter (servidor) y template (HTML).
---
// src/components/ui/Button.astro
// Frontmatter: se ejecuta en el servidor, NO en el JS del navegador
export interface Props {
variant?: 'primary' | 'secondary' | 'outline'
size?: 'sm' | 'md' | 'lg'
href?: string
}
const {
variant = 'primary',
size = 'md',
href,
...rest
} = Astro.props
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors'
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50'
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
}
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`
---
{href ? (
<a href={href} class={classes} {...rest}>
<slot />
</a>
) : (
<button class={classes} {...rest}>
<slot />
</button>
)}
Puntos clave:
- El frontmatter se ejecuta en tiempo de compilación (o tiempo de petición para SSR)
- No se necesita
export default— el template ES la exportación <slot />para contenido hijo- Las props se tipan con interfaces de TypeScript
Patrón de Layout
---
// src/layouts/BaseLayout.astro
import Nav from '../components/Nav.astro'
import Footer from '../components/Footer.astro'
export interface Props {
title: string
description?: string
image?: string
}
const { title, description = '', image = '/og-default.png' } = Astro.props
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
---
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="canonical" href={canonicalURL} />
<title>{title}</title>
<meta name="description" content={description} />
<!-- OpenGraph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="og:url" href={canonicalURL} />
<meta property="og:type" content="website" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<slot name="head" />
</head>
<body class="bg-white text-gray-900 antialiased">
<Nav />
<main>
<slot />
</main>
<Footer />
</body>
</html>
Uso:
---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro'
import Hero from '../components/sections/Hero.astro'
---
<BaseLayout title="Vera Sesiom - Desarrollo de Software" description="Construimos software que importa.">
<Hero />
</BaseLayout>
Patrón de Colección de Contenido
Define contenido estructurado con validación de esquema Zod.
// src/content/config.ts
import { defineCollection, z } from 'astro:content'
const blogCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
author: z.string().default('Vera Sesiom'),
image: z.object({
url: z.string(),
alt: z.string()
}).optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false)
})
})
export const collections = {
blog: blogCollection
}
---
title: "Por Qué Elegimos Astro para Landing Pages"
description: "El rendimiento y la experiencia de desarrollo importan. Aquí está por qué Astro es nuestra opción para sitios de contenido."
pubDate: 2024-01-15
author: "Líder Técnico"
image:
url: "/images/blog/astro-landing.jpg"
alt: "Logotipo de Astro en un sitio web moderno"
tags: ["astro", "rendimiento", "arquitectura"]
---
El contenido va aquí...
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'
const posts = (await getCollection('blog'))
.filter(post => !post.data.draft)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
---
<BaseLayout title="Blog" description="Últimos artículos de Vera Sesiom">
<h1>Blog</h1>
<ul>
{posts.map(post => (
<li>
<a href={`/blog/${post.slug}`}>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
<time>{post.data.pubDate.toLocaleDateString()}</time>
</a>
</li>
))}
</ul>
</BaseLayout>
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'
export async function getStaticPaths() {
const posts = await getCollection('blog')
return posts.map(post => ({
params: { slug: post.slug },
props: { post }
}))
}
const { post } = Astro.props
const { Content } = await post.render()
---
<BaseLayout title={post.data.title} description={post.data.description}>
<article>
<header>
<h1>{post.data.title}</h1>
<time>{post.data.pubDate.toLocaleDateString()}</time>
<p>Por {post.data.author}</p>
</header>
<Content />
</article>
</BaseLayout>
Arquitectura de Islas (Componentes Vue)
Los componentes interactivos envían JS solo cuando es necesario. Usa directivas cliente para controlar la hidratación.
<!-- src/components/interactive/ContactForm.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const form = ref({
name: '',
email: '',
message: ''
})
const errors = ref<Record<string, string>>({})
const isValid = computed(() => {
return form.value.name &&
form.value.email.includes('@') &&
form.value.message.length > 10
})
async function submitForm() {
if (!isValid.value) return
// Enviar a API
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value)
})
if (response.ok) {
// Manejar éxito
form.value = { name: '', email: '', message: '' }
}
}
</script>
<template>
<form @submit.prevent="submitForm" class="space-y-4">
<div>
<label for="name">Nombre</label>
<input v-model="form.name" type="text" id="name" required />
</div>
<div>
<label for="email">Correo Electrónico</label>
<input v-model="form.email" type="email" id="email" required />
</div>
<div>
<label for="message">Mensaje</label>
<textarea v-model="form.message" id="message" rows="4" required />
</div>
<button type="submit" :disabled="!isValid">
Enviar Mensaje
</button>
</form>
</template>
Uso en Astro:
---
// src/pages/contact.astro
import BaseLayout from '../layouts/BaseLayout.astro'
import ContactForm from '../components/interactive/ContactForm.vue'
---
<BaseLayout title="Contáctanos">
<h1>Ponte en Contacto</h1>
<p>Nos encantaría saber de ti.</p>
<!-- client:visible = hidratar cuando se vea al hacer scroll (MEJOR PRÁCTICA) -->
<ContactForm client:visible />
</BaseLayout>
Árbol de Decisión de Directivas Cliente
| Directiva | Cuándo Usar | JS se Envía |
|---|---|---|
| Sin directiva | Contenido estático, sin interactividad | Zero JS |
client:visible | Interactividad bajo el pliegue (formularios, carruseles) — POR DEFECTO | Cuando se ve al hacer scroll |
client:load | Interactividad crítica sobre el pliegue | Inmediatamente |
client:idle | Mejoras no críticas (analíticas, chat) | Después de que la página esté inactiva |
client:media | Condicional por punto de ruptura (nav móvil) | Cuando el media query coincide |
client:only | Componentes tipo SPA (saltar SSR) | Inmediatamente, sin SSR |
Mejores Prácticas:
- Por defecto sin directiva — puramente estático
- Por defecto
client:visiblecuando se necesita interactividad - Usa
client:loadcon moderación — solo para elementos críticos sobre el pliegue - Usa
client:idlepara mejoras que no bloquean
---
<!-- Buenos ejemplos -->
import Hero from './Hero.astro' <!-- Estático, sin JS -->
import Carousel from './Carousel.vue'
import MobileMenu from './MobileMenu.vue'
import Chat from './Chat.vue'
import Stats from './Stats.astro' <!-- Contador estático = CSS -->
<Hero /> <!-- Estático, sin hidratación -->
<Carousel client:visible /> <!-- Hidratar cuando sea visible -->
<MobileMenu client:media="(max-width: 768px)" /> <!-- Solo móvil -->
<Chat client:idle /> <!-- Después de que la página se asiente -->
<Stats /> <!-- Animaciones CSS puras, no necesita JS -->
Optimización de Imágenes
Usa astro:assets para optimización automática.
---
// src/pages/index.astro
import { Image } from 'astro:assets'
import heroImage from '../assets/images/hero.png'
---
<Image
src={heroImage}
alt="Imagen principal"
width={800}
height={600}
loading="lazy"
format="webp"
/>
Para imágenes de CMS o fuentes externas:
---
import { Image } from 'astro:assets'
---
<Image
src="https://example.com/image.jpg"
alt="Imagen externa"
width={800}
height={600}
/>
Reglas:
- Siempre especifica
widthyheightpara prevenir CLS - Usa
loading="lazy"para imágenes bajo el pliegue - Prefiere formatos
webpoavif - Mantén las imágenes principales optimizadas y con el tamaño adecuado
Patrón SEO
---
// src/components/SEO.astro
export interface Props {
title: string
description: string
image?: string
article?: boolean
publishedTime?: Date
author?: string
tags?: string[]
}
const {
title,
description,
image = '/og-default.png',
article = false,
publishedTime,
author,
tags = []
} = Astro.props
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
const imageURL = new URL(image, Astro.site)
---
<!-- Meta Tags Primarios -->
<title>{title}</title>
<meta name="title" content={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={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={imageURL} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalURL} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={imageURL} />
<!-- Artículo específico -->
{article && publishedTime && (
<meta property="article:published_time" content={publishedTime.toISOString()} />
)}
{article && author && (
<meta property="article:author" content={author} />
)}
{article && tags.length > 0 && tags.map(tag => (
<meta property="article:tag" content={tag} />
))}
<!-- Breadcrumbs JSON-LD -->
<script type="application/ld+json" set:html={JSON.stringify({
'@context': 'https://schema.org',
'@type': article ? 'Article' : 'WebPage',
name: title,
description: description,
url: canonicalURL,
image: imageURL,
...(article && publishedTime && { datePublished: publishedTime.toISOString() }),
...(article && author && { author: { '@type': 'Person', name: author } })
})} />
Rendimiento y Core Web Vitals
Carga de Fuentes
---
// En layouts/BaseLayout.astro (dentro de <head>)
---
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Estrategia de fuentes: usar display=swap para FOUT -->
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<!-- Alternativa mejor: descargar fuentes y alojar localmente -->
<style is:global>
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Regular.woff2') format('woff2');
font-display: swap;
font-weight: 400;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Bold.woff2') format('woff2');
font-display: swap;
font-weight: 700;
}
</style>
Optimización CSS
/* src/styles/global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.container {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
.section {
@apply py-16 sm:py-24;
}
.prose {
@apply max-w-none text-gray-600;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
Métricas Clave
| Métrica | Objetivo | Cómo Lograrlo en Astro |
|---|---|---|
| LCP | < 2.5s | Imágenes optimizadas, fuentes alojadas localmente, HTML estático mínimo |
| FID | < 100ms | Mínimo JS, usar client:idle o client:visible |
| CLS | < 0.1 | Tamaños width/height en todas las imágenes, reserva de espacio para fuentes |
| TTFB | < 800ms | SSG, CDN, edge caching |
Estrategia de Despliegue
Construcción SSG Estándar
# Construcción de producción
pnpm build
# Los archivos estáticos se generan en dist/
# Desplegar dist/ a cualquier hosting estático:
# - Netlify
# - Vercel
# - Cloudflare Pages
# - AWS S3 + CloudFront
Configuración de Netlify (netlify.toml)
[build]
command = "pnpm build"
publish = "dist"
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
[[headers]]
for = "/*.html"
[headers.values]
Cache-Control = "public, max-age=0, must-revalidate"
[[redirects]]
from = "/blog/*"
to = "/blog/:splat"
status = 200
Migración de Contenido: Markdown Frontmatter
Esto es lo que usamos para contenido/formato de blog en Vera Sesiom.
---
title: "Título del Post de Blog"
description: "Descripción breve para SEO y tarjetas de vista previa"
pubDate: 2024-01-15
author: "Nombre del Autor"
image:
url: "/images/blog/post-image.jpg"
alt: "Texto alternativo descriptivo"
tags: ["astro", "rendimiento", "tutorial"]
draft: false
canonicalURL: "https://example.com/post-url" # Opcional
---
Resumen y Reglas de Oro
- Zero JS por defecto — No envíes JS a menos que sea esencial para la experiencia del usuario
- Arquitectura de Islas — Aísla la interactividad; solo se hidratan componentes específicos
- Colecciones de Contenido — Usa
astro:contentpara todo el contenido estructurado con esquemas Zod - Imágenes Optimizadas — Siempre usa
Imagedeastro:assetscon dimensiones explícitas - SEO Primero — Meta tags, OpenGraph, datos estructurados, URLs canónicas en cada página
- Screaming Architecture — Nombra las carpetas por dominio (sections/, ui/, interactive/), no por tipo de archivo
- TypeScript Estricto — Sin modo
anyostrict:false - Rendimiento — Apunta a < 50ms tiempo de bloqueo en dispositivos móviles
- Componentes Atómicos — UI atoms reutilizables (Button, Card, Badge) vs. secciones de página
- Caso de Uso Correcto — Contenido que se LEE → Astro. Aplicación que se USA → Vue
Referencia Rápida de Comandos
# Desarrollo
pnpm dev # Iniciar servidor de desarrollo
# Construcción
pnpm build # Construcción de producción
pnpm preview # Vista previa de la construcción
# Gestión de Contenido
pnpm astro check # Verificar tipos
pnpm astro sync # Sincronizar tipos de colecciones de contenido
# Utilidades
pnpm astro --help # Ver comandos de CLI