DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Astro y AWS sitios hibridos con islas de hidratacion sobre CloudFront

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Static: genera HTML/CSS/JS y los sube a S3. CloudFront los sirve. Cero servidor.
  2. 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'],
          },
        },
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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,
        },
      ],
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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' }
  )
);
Enter fullscreen mode Exit fullscreen mode

Esta isla se monta en cualquier página con:

<Cart client:load />
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Y en cualquier elemento:

<img
  src={post.cover}
  transition:name={`post-${post.id}`}
  transition:animate="slide"
/>
Enter fullscreen mode Exit fullscreen mode

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)