Astro tiene una filosofía distinta al resto de frameworks: por defecto todo es HTML estático, y solo cargas JavaScript en las partes específicas que lo necesitan. Esto se llama "Islands Architecture" y es probablemente la mejor forma de construir sitios con contenido mixto (blog + tienda + dashboard ligero). Este artículo cubre el setup completo en AWS, con SSG puro, SSR híbrido, y ejemplos reales de islas.
El modelo de islas explicado
flowchart TB
subgraph Monolito[SPA tradicional]
M1[Todo el sitio es JS]
M2[Bundle enorme]
M3[Hidratacion total]
end
subgraph Islands[Astro Islands]
I1[HTML estatico por default]
I2[Islas interactivas aisladas]
I3[JS solo en islas]
end
subgraph Ejemplo[Ejemplo concreto]
E1[Header HTML] --> E2[Carrito - Isla React]
E3[Articulo HTML] --> E4[Boton Like - Isla Vue]
E5[Footer HTML]
end
style Islands fill:#ff5d01,color:#fff
style Ejemplo fill:#ffd8c0,color:#000
Cada isla es independiente. Una isla puede ser React, otra Svelte, otra Vue. Todas coexisten sin conflicto.
Configurando Astro para AWS
Astro tiene dos modos de output:
- Static: genera HTML/CSS/JS y los sube a S3. CloudFront los sirve. Cero servidor.
- Hybrid/Server: ciertas rutas se renderizan en servidor (Lambda), otras son estáticas.
Yo uso hybrid para la mayoría de sitios de contenido:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
import aws from 'astro-aws-adapter';
export default defineConfig({
output: 'hybrid',
adapter: aws({
mode: 'lambda',
architecture: 'arm64',
}),
site: 'https://miempresa.com',
integrations: [
react(),
tailwind({ applyBaseStyles: false }),
sitemap({
customPages: ['https://miempresa.com/custom'],
changefreq: 'weekly',
}),
],
build: {
inlineStylesheets: 'auto',
assets: '_astro',
},
image: {
domains: ['images.unsplash.com'],
remotePatterns: [{ protocol: 'https' }],
},
vite: {
build: {
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom'],
},
},
},
},
},
});
Estructura del proyecto
Una estructura que escala:
src/
components/
Header.astro # 100% HTML, sin JS
Footer.astro # 100% HTML, sin JS
ProductCard.astro # HTML con pequeño JS vanilla
islands/
AddToCart.tsx # Isla React con estado
SearchBox.svelte # Isla Svelte reactiva
LikeButton.vue # Isla Vue
layouts/
BaseLayout.astro
BlogLayout.astro
pages/
index.astro # Estática
blog/
[slug].astro # Estática con getStaticPaths
products/
[id].astro # Dinámica (SSR)
api/
cart.ts # API endpoint
content/
blog/
post-1.md
post-2.md
Una página estática optimizada
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';
import LikeButton from '../../components/islands/LikeButton.vue';
import RelatedPosts from '../../components/RelatedPosts.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();
---
<BlogLayout title={post.data.title} description={post.data.excerpt}>
<article class="prose max-w-3xl mx-auto">
<header>
<h1>{post.data.title}</h1>
<time datetime={post.data.date.toISOString()}>
{post.data.date.toLocaleDateString()}
</time>
</header>
<Content />
{/* Isla interactiva - hidrata en visible */}
<LikeButton
client:visible
postId={post.slug}
initialLikes={post.data.likes || 0}
/>
<RelatedPosts tags={post.data.tags} excludeSlug={post.slug} />
</article>
</BlogLayout>
Lo importante aquí son las directivas client::
-
client:load: hidrata al cargar la página. -
client:idle: hidrata cuando el navegador está idle. -
client:visible: hidrata cuando el elemento es visible (IntersectionObserver). -
client:media: hidrata solo si coincide un media query. -
client:only: renderiza solo en cliente (sin SSR).
client:visible es el más común para islas abajo del fold.
Content collections con validación
Astro tiene soporte first-class para colecciones con esquemas Zod:
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: ({ image }) => z.object({
title: z.string(),
excerpt: z.string().max(160),
date: z.coerce.date(),
author: z.string(),
tags: z.array(z.string()),
coverImage: image().optional(),
draft: z.boolean().default(false),
}),
});
const products = defineCollection({
type: 'data',
schema: z.object({
sku: z.string(),
name: z.string(),
price: z.number().positive(),
inventory: z.number().int(),
category: z.enum(['electronics', 'clothing', 'books']),
}),
});
export const collections = { blog, products };
Si intentas crear un post que no cumple el schema, el build falla con un error claro. Esto previene bugs en contenido.
Despliegue en AWS
Para el modo híbrido, el adapter genera dos outputs:
-
dist/client/: assets estáticos para S3. -
dist/server/: handler de Lambda.
El stack CDK:
// infra/astro-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
export class AstroStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
// Bucket para assets estáticos
const staticBucket = new s3.Bucket(this, 'StaticAssets', {
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
});
new s3deploy.BucketDeployment(this, 'DeployStatic', {
sources: [s3deploy.Source.asset('../dist/client')],
destinationBucket: staticBucket,
cacheControl: [
s3deploy.CacheControl.setPublic(),
s3deploy.CacheControl.maxAge(cdk.Duration.days(365)),
s3deploy.CacheControl.immutable(),
],
exclude: ['*.html'],
});
// Deploy HTML con cache corto
new s3deploy.BucketDeployment(this, 'DeployHtml', {
sources: [s3deploy.Source.asset('../dist/client', { exclude: ['**/*', '!*.html'] })],
destinationBucket: staticBucket,
cacheControl: [
s3deploy.CacheControl.setPublic(),
s3deploy.CacheControl.maxAge(cdk.Duration.minutes(5)),
],
prune: false,
});
// Lambda para SSR de rutas dinámicas
const ssrFunction = new lambda.Function(this, 'SSRFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'entry.handler',
code: lambda.Code.fromAsset('../dist/server'),
memorySize: 512,
timeout: cdk.Duration.seconds(15),
architecture: lambda.Architecture.ARM_64,
});
const ssrUrl = ssrFunction.addFunctionUrl({
authType: lambda.FunctionUrlAuthType.NONE,
invokeMode: lambda.InvokeMode.RESPONSE_STREAM,
});
const distribution = new cloudfront.Distribution(this, 'Distribution', {
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(staticBucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
},
additionalBehaviors: {
// Rutas dinámicas van al Lambda
'/products/*': {
origin: new origins.FunctionUrlOrigin(ssrUrl),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
},
'/api/*': {
origin: new origins.FunctionUrlOrigin(ssrUrl),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
},
},
defaultRootObject: 'index.html',
errorResponses: [
{
httpStatus: 404,
responsePagePath: '/404.html',
responseHttpStatus: 404,
},
],
});
}
}
Isla React con estado compartido
Un carrito de compras como isla que persiste en localStorage:
// src/components/islands/Cart.tsx
import { useEffect, useState } from 'react';
import { useCart } from './cart-store';
export function Cart() {
const { items, total, removeItem, updateQty } = useCart();
const [isOpen, setIsOpen] = useState(false);
if (items.length === 0) {
return (
<button className="cart-empty" onClick={() => setIsOpen(true)}>
<span>🛒</span>
<span>0</span>
</button>
);
}
return (
<div className="cart">
<button onClick={() => setIsOpen(!isOpen)}>
<span>🛒</span>
<span>{items.reduce((sum, item) => sum + item.qty, 0)}</span>
</button>
{isOpen && (
<div className="cart-dropdown">
{items.map(item => (
<div key={item.id} className="cart-item">
<span>{item.name}</span>
<input
type="number"
value={item.qty}
min={1}
onChange={(e) => updateQty(item.id, parseInt(e.target.value))}
/>
<span>${item.price * item.qty}</span>
<button onClick={() => removeItem(item.id)}>×</button>
</div>
))}
<div className="cart-total">Total: ${total}</div>
<a href="/checkout">Ir al checkout</a>
</div>
)}
</div>
);
}
// src/components/islands/cart-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface CartItem {
id: string;
name: string;
price: number;
qty: number;
}
interface CartStore {
items: CartItem[];
total: number;
addItem: (item: Omit<CartItem, 'qty'>) => void;
removeItem: (id: string) => void;
updateQty: (id: string, qty: number) => void;
}
export const useCart = create<CartStore>()(
persist(
(set, get) => ({
items: [],
get total() {
return get().items.reduce((sum, i) => sum + i.price * i.qty, 0);
},
addItem: (item) => {
set((state) => {
const existing = state.items.find(i => i.id === item.id);
if (existing) {
existing.qty += 1;
return { items: [...state.items] };
}
return { items: [...state.items, { ...item, qty: 1 }] };
});
},
removeItem: (id) => {
set((state) => ({ items: state.items.filter(i => i.id !== id) }));
},
updateQty: (id, qty) => {
set((state) => ({
items: state.items.map(i => i.id === id ? { ...i, qty } : i),
}));
},
}),
{ name: 'cart-storage' }
)
);
Esta isla se monta en cualquier página con:
<Cart client:load />
API endpoints en Astro
Para lógica de servidor simple, Astro tiene endpoints tipo Next.js API routes:
// src/pages/api/newsletter.ts
import type { APIRoute } from 'astro';
import { z } from 'zod';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
const dynamo = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const SubscribeSchema = z.object({
email: z.string().email(),
source: z.string().optional(),
});
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const validated = SubscribeSchema.safeParse(body);
if (!validated.success) {
return new Response(JSON.stringify({ error: 'Email inválido' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
try {
await dynamo.send(
new PutCommand({
TableName: process.env.SUBSCRIBERS_TABLE,
Item: {
email: validated.data.email,
source: validated.data.source ?? 'unknown',
subscribedAt: new Date().toISOString(),
},
ConditionExpression: 'attribute_not_exists(email)',
})
);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
if (error.name === 'ConditionalCheckFailedException') {
return new Response(JSON.stringify({ error: 'Ya estás suscrito' }), {
status: 409,
});
}
throw error;
}
};
View Transitions nativas
Astro 3+ incluye View Transitions para animaciones entre páginas sin necesidad de framework SPA:
---
// src/layouts/BaseLayout.astro
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>
Y en cualquier elemento:
<img
src={post.cover}
transition:name={`post-${post.id}`}
transition:animate="slide"
/>
El navegador anima la transición entre páginas sin JavaScript custom.
Lo que aprendí usando Astro en producción
1. El bundle de islas hay que vigilarlo.
Si metes React en una isla, se carga React entero. Si usas tres frameworks distintos, cargas los tres. Estandarízate en uno para islas si el peso importa.
2. Content collections son un diferencial real.
La validación Zod en tiempo de build atrapa errores en Markdown que en otros frameworks solo verías en runtime.
3. El partial hydration es hermoso pero confunde al equipo.
Los devs vienen de React pensando que todo es isomórfico. Hay que explicar que los componentes .astro solo corren en servidor, no en cliente.
4. Los deploys estáticos son baratísimos.
Un sitio con 500k visitas al mes me cuesta menos de $5 en CloudFront + S3. Agregar Lambda para rutas dinámicas sube poco más.
5. El desarrollo local es rápido.
astro dev arranca en menos de un segundo y HMR es instantáneo.
Cuándo NO usar Astro
- Si tu app es 80%+ dashboard interactivo, usa Next.js o similar.
- Si tu equipo no conoce HTML moderno, la curva inicial les va a doler.
- Si necesitas WebSockets o interactividad en toda la página, las islas se quedan cortas.
Cierre
Astro es la opción cuando el contenido manda y la interactividad es selectiva. Para blogs, documentación, marketing sites, e-commerce ligero, es difícil vencer su performance. El deploy en AWS es directo una vez que entiendes el modelo híbrido.
En el próximo artículo vamos con Angular 18 SSR: un framework que mucha gente subestima pero que en AWS funciona sorprendentemente bien con hidratación non-destructive.
Top comments (0)