DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Arquitectura moderna de SSR en AWS con CloudFront Lambda Edge y S3

Cuando empiezas a construir una aplicación frontend que necesita SSR en AWS, la primera decisión es elegir entre Amplify, App Runner, ECS, Lambda o una combinación. Después de varios años desplegando frontends en AWS, quiero mostrarte la arquitectura que he terminado adoptando como default para proyectos serios de Next.js, Nuxt y SvelteKit: CloudFront + Lambda@Edge + S3 + Lambda regional.

Este artículo cubre el diseño completo, los trade-offs, el código de la infraestructura con CDK y las trampas que te vas a encontrar en producción.

El problema con los despliegues "simples"

La mayoría de tutoriales te dicen: "usa Amplify, un clic y ya". Eso funciona para demos. Pero cuando el proyecto crece te topas con:

  • El runtime de Amplify SSR es una caja negra. Si algo falla, debuguearlo es un dolor.
  • Los cold starts son agresivos cuando el tráfico no es constante.
  • El caché por defecto es demasiado conservador.
  • Integrar Lambda@Edge para redirects o A/B testing implica salirse del modelo Amplify.

La arquitectura que voy a mostrarte te da control total a cambio de un poco más de setup inicial.

La arquitectura objetivo

flowchart TB
    User[Usuario] --> CF[CloudFront Distribution]
    CF -->|Request edge| LE[Lambda Edge - Viewer Request]
    LE -->|Static assets| S3[S3 Bucket<br/>_next/static /]
    LE -->|SSR path| OR[Origin Request Lambda]
    OR --> LR[Lambda Regional<br/>SSR Handler]
    LR --> DB[(DynamoDB / RDS)]
    LR --> API[Other APIs]
    LR -->|HTML response| CF
    S3 -->|Cached assets| CF
    CF -->|Response| User

    style LE fill:#ff9900,color:#000
    style LR fill:#ff9900,color:#000
    style CF fill:#146eb4,color:#fff
    style S3 fill:#569A31,color:#fff
Enter fullscreen mode Exit fullscreen mode

Los flujos principales son:

  1. Assets estáticos (_next/static/*, imágenes, fonts): CloudFront los sirve desde S3 con TTL largo.
  2. Rutas SSR: CloudFront llama a una Lambda regional que ejecuta el handler de Next.js/Nuxt/SvelteKit.
  3. Lambda@Edge maneja lógica que debe correr en el edge: redirects por geolocalización, A/B tests, headers de seguridad.
  4. ISR/revalidation: las páginas generadas se guardan en S3 y CloudFront las sirve hasta que expire el TTL.

Por qué Lambda regional y no siempre Lambda@Edge

Lambda@Edge tiene limitaciones importantes que la gente descubre tarde:

Característica Lambda@Edge Lambda regional
Tamaño máximo del bundle 1 MB (viewer) / 50 MB (origin) 250 MB unzipped
Timeout 5s (viewer) / 30s (origin) 15 min
Variables de entorno No soportadas Soportadas
VPC No
Memoria máxima 10 GB 10 GB

Un bundle de Next.js 15 con todas sus dependencias pasa fácil los 50 MB. Por eso la separación: Lambda@Edge para lógica ligera de routing y rewrite, Lambda regional para el SSR real.

El código de la infraestructura con CDK

Voy a mostrar el stack completo. Este es el código que uso en producción:

// lib/frontend-ssr-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';

interface FrontendSSRStackProps extends cdk.StackProps {
  domainName: string;
  environment: 'staging' | 'production';
}

export class FrontendSSRStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: FrontendSSRStackProps) {
    super(scope, id, props);

    // Bucket para assets estáticos
    const assetsBucket = new s3.Bucket(this, 'AssetsBucket', {
      bucketName: `frontend-${props.environment}-assets`,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      versioned: true,
      lifecycleRules: [
        {
          id: 'expire-old-versions',
          noncurrentVersionExpiration: cdk.Duration.days(30),
        },
      ],
    });

    // Bucket para páginas ISR cacheadas
    const isrBucket = new s3.Bucket(this, 'ISRBucket', {
      bucketName: `frontend-${props.environment}-isr`,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
    });

    // Lambda regional para el SSR
    const ssrFunction = new lambda.Function(this, 'SSRHandler', {
      functionName: `frontend-${props.environment}-ssr`,
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'server.handler',
      code: lambda.Code.fromAsset('../.next-lambda'),
      memorySize: 1024,
      timeout: cdk.Duration.seconds(30),
      environment: {
        NODE_ENV: 'production',
        ISR_BUCKET: isrBucket.bucketName,
        REGION: this.region,
      },
      logRetention: cdk.aws_logs.RetentionDays.ONE_MONTH,
    });

    isrBucket.grantReadWrite(ssrFunction);

    // Function URL para que CloudFront la invoque
    const ssrUrl = ssrFunction.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
      invokeMode: lambda.InvokeMode.RESPONSE_STREAM,
    });

    // Lambda@Edge para rewrites y headers
    const edgeFunction = new cloudfront.experimental.EdgeFunction(this, 'EdgeHandler', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'edge.handler',
      code: lambda.Code.fromAsset('./edge-functions'),
      memorySize: 128,
      timeout: cdk.Duration.seconds(5),
    });

    // Origin Access Control para S3
    const oac = new cloudfront.S3OriginAccessControl(this, 'OAC', {
      signing: cloudfront.Signing.SIGV4_ALWAYS,
    });

    // Distribución CloudFront
    const distribution = new cloudfront.Distribution(this, 'Distribution', {
      defaultBehavior: {
        origin: new origins.FunctionUrlOrigin(ssrUrl),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
        cachePolicy: new cloudfront.CachePolicy(this, 'SSRCachePolicy', {
          cachePolicyName: `${props.environment}-ssr-cache`,
          defaultTtl: cdk.Duration.seconds(0),
          minTtl: cdk.Duration.seconds(0),
          maxTtl: cdk.Duration.days(1),
          headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
            'Authorization',
            'CloudFront-Viewer-Country',
            'Accept-Language'
          ),
          queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),
          cookieBehavior: cloudfront.CacheCookieBehavior.allowList('session'),
          enableAcceptEncodingGzip: true,
          enableAcceptEncodingBrotli: true,
        }),
        edgeLambdas: [
          {
            functionVersion: edgeFunction.currentVersion,
            eventType: cloudfront.LambdaEdgeEventType.VIEWER_REQUEST,
            includeBody: false,
          },
        ],
      },
      additionalBehaviors: {
        '/_next/static/*': {
          origin: origins.S3BucketOrigin.withOriginAccessControl(assetsBucket, {
            originAccessControl: oac,
          }),
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        },
        '/_next/image': {
          origin: new origins.FunctionUrlOrigin(ssrUrl),
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          cachePolicy: new cloudfront.CachePolicy(this, 'ImageCachePolicy', {
            defaultTtl: cdk.Duration.days(30),
            queryStringBehavior: cloudfront.CacheQueryStringBehavior.allowList('url', 'w', 'q'),
          }),
        },
      },
      priceClass: props.environment === 'production'
        ? cloudfront.PriceClass.PRICE_CLASS_ALL
        : cloudfront.PriceClass.PRICE_CLASS_100,
      httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
      minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
    });

    new cdk.CfnOutput(this, 'DistributionDomain', {
      value: distribution.distributionDomainName,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

El handler de la Lambda regional

La parte interesante es cómo adaptar el server de Next.js para correr en Lambda con Response Streaming. Next.js 15 no lo hace automáticamente, hay que wrappearlo:

// server/handler.ts
import { awslambda } from 'aws-lambda';
import NextServer from 'next/dist/server/next-server';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { Readable } from 'stream';

const s3 = new S3Client({ region: process.env.REGION });

const nextServer = new NextServer({
  hostname: 'localhost',
  port: 3000,
  dir: __dirname,
  dev: false,
  conf: {
    distDir: '.next',
  },
});

const requestHandler = nextServer.getRequestHandler();

export const handler = awslambda.streamifyResponse(
  async (event: any, responseStream: any, context: any) => {
    const { rawPath, rawQueryString, headers, body, requestContext } = event;

    const url = `https://${headers.host}${rawPath}${rawQueryString ? '?' + rawQueryString : ''}`;

    // Intentar servir desde cache ISR primero
    const cached = await tryGetFromISRCache(rawPath);
    if (cached) {
      responseStream.setContentType('text/html');
      responseStream.write(cached);
      responseStream.end();
      return;
    }

    // Preparar request compatible con Node HTTP
    const req = createNodeRequest(event);
    const res = createNodeResponse(responseStream);

    // Ejecutar Next.js handler
    await requestHandler(req, res);

    // Si es ISR, guardar en S3 para siguientes requests
    if (shouldCacheISR(rawPath, res.statusCode)) {
      await saveToISRCache(rawPath, res.getBuffer());
    }
  }
);

async function tryGetFromISRCache(path: string): Promise<Buffer | null> {
  try {
    const key = `isr${path === '/' ? '/index' : path}.html`;
    const result = await s3.send(
      new GetObjectCommand({
        Bucket: process.env.ISR_BUCKET,
        Key: key,
      })
    );

    // Verificar TTL por metadata
    const maxAge = parseInt(result.Metadata?.['max-age'] || '0', 10);
    const lastModified = result.LastModified?.getTime() || 0;
    const ageSeconds = (Date.now() - lastModified) / 1000;

    if (ageSeconds > maxAge) return null;

    const chunks: Buffer[] = [];
    for await (const chunk of result.Body as Readable) {
      chunks.push(chunk as Buffer);
    }
    return Buffer.concat(chunks);
  } catch {
    return null;
  }
}

async function saveToISRCache(path: string, body: Buffer): Promise<void> {
  const key = `isr${path === '/' ? '/index' : path}.html`;
  await s3.send(
    new PutObjectCommand({
      Bucket: process.env.ISR_BUCKET,
      Key: key,
      Body: body,
      ContentType: 'text/html',
      Metadata: {
        'max-age': '3600',
      },
    })
  );
}

function shouldCacheISR(path: string, statusCode: number): boolean {
  if (statusCode !== 200) return false;
  if (path.startsWith('/api/')) return false;
  if (path.includes('_next/data')) return true;
  return !path.includes('?');
}
Enter fullscreen mode Exit fullscreen mode

La Lambda@Edge para lógica ligera

En el edge ejecuto cosas que no dependen de estado y deben ser rapidísimas:

// edge-functions/edge.ts
import { CloudFrontRequestEvent, CloudFrontRequestHandler } from 'aws-lambda';

export const handler: CloudFrontRequestHandler = async (event) => {
  const request = event.Records[0].cf.request;
  const headers = request.headers;

  // Redirect por país
  const country = headers['cloudfront-viewer-country']?.[0]?.value;
  if (country === 'CO' && request.uri === '/') {
    return {
      status: '302',
      statusDescription: 'Found',
      headers: {
        location: [{ key: 'Location', value: '/co' }],
      },
    };
  }

  // A/B testing basado en cookie
  const cookieHeader = headers.cookie?.[0]?.value || '';
  const hasExperimentCookie = cookieHeader.includes('experiment=');

  if (!hasExperimentCookie && request.uri === '/') {
    const variant = Math.random() > 0.5 ? 'A' : 'B';
    request.uri = `/experiments/home-${variant}`;
    request.headers['x-experiment-variant'] = [{ key: 'X-Experiment-Variant', value: variant }];
  }

  // Bloquear bots sospechosos
  const userAgent = headers['user-agent']?.[0]?.value || '';
  if (isSuspiciousBot(userAgent)) {
    return {
      status: '403',
      statusDescription: 'Forbidden',
    };
  }

  return request;
};

function isSuspiciousBot(ua: string): boolean {
  const blocked = ['SemrushBot', 'AhrefsBot', 'PetalBot'];
  return blocked.some((bot) => ua.includes(bot));
}
Enter fullscreen mode Exit fullscreen mode

El flujo de request completo

Para que quede claro el orden de ejecución:

sequenceDiagram
    participant U as Usuario
    participant CF as CloudFront
    participant LE as Lambda@Edge<br/>(Viewer Request)
    participant S3 as S3 (assets)
    participant L as Lambda Regional<br/>(SSR)
    participant IS as S3 ISR Cache

    U->>CF: GET /productos
    CF->>LE: Viewer Request
    LE-->>CF: request modificado
    CF->>L: Origin Request (cache miss)
    L->>IS: Tiene cache?
    alt Cache hit
        IS-->>L: HTML cacheado
        L-->>CF: Response
    else Cache miss
        L->>L: Ejecutar SSR
        L->>IS: Guardar HTML
        L-->>CF: Response streaming
    end
    CF-->>U: HTML + assets
    U->>CF: GET /_next/static/chunks/main.js
    CF->>S3: Get object (cache miss)
    S3-->>CF: JS bundle
    CF-->>U: JS (cache 1 año)
Enter fullscreen mode Exit fullscreen mode

Lo que aprendí desplegando esto

1. El TTL de CloudFront por defecto te va a arruinar el día.

Durante dos meses estuve debuggeando por qué los deploys no se reflejaban en producción. El problema era que mi cache policy tenía defaultTtl: 24h. Las invalidations de CloudFront cuestan dinero después de las primeras 1000, así que la solución real es versionar los assets en el path (/_next/static/[hash]/) y dejar que el HTML no se cachee.

2. Lambda@Edge tarda en propagarse.

Cuando haces deploy de una Lambda@Edge, CloudFront tarda entre 5 y 15 minutos en propagar el cambio a todas las edge locations. Si estás en un flujo de desarrollo iterativo, esto te mata la productividad. Por eso muevo toda la lógica que puedo a la Lambda regional.

3. Response Streaming cambia las reglas.

Sin streaming, el TTFB de una página SSR con datos es la suma de todo: consulta a DB + render + envío. Con streaming, empiezas a mandar el HTML mientras esperas los datos. Mi P75 bajó de 1.8s a 600ms con el mismo código, solo activando streaming.

4. ISR casero es mejor que el de Next.js solo.

El ISR que viene en Next.js asume que el filesystem es persistente. En Lambda no lo es. Mi solución es guardar en S3 y revalidar con background tasks. Menos mágico pero 100% predecible.

5. Los logs de Lambda@Edge van a todas las regiones.

Esto parece obvio pero me tomó una tarde entender que mis logs no aparecían en us-east-1. Lambda@Edge loguea a la región donde se ejecutó, que depende de dónde está el usuario. Usa CloudWatch Logs Insights con búsqueda multi-región.

Cuándo no usar esta arquitectura

Esta arquitectura tiene más piezas que Amplify. Si tu proyecto es:

  • Un MVP que necesitas lanzar en 2 días.
  • Una landing page sin contenido dinámico.
  • Un equipo sin experiencia AWS.

Usa Amplify. En serio. La complejidad se justifica cuando tienes tráfico real, necesitas control sobre el caché, o quieres integrar Lambda@Edge para lógica propia.

Deploy y CI/CD

El pipeline que uso tiene estos pasos:

flowchart LR
    A[Git push] --> B[GitHub Actions]
    B --> C[npm run build]
    C --> D[Package Lambda]
    D --> E[cdk diff]
    E --> F{Diff OK?}
    F -->|Sí| G[cdk deploy]
    F -->|No| H[Manual review]
    G --> I[Sync S3 assets]
    I --> J[Invalidate /*]
    J --> K[Smoke tests]
Enter fullscreen mode Exit fullscreen mode

El sync de assets a S3 usa aws s3 sync --delete --cache-control "max-age=31536000,immutable" para los archivos con hash, y --cache-control "no-cache" para los que no tienen hash.

Cierre

Esta arquitectura te da:

  • Control sobre el caché a nivel granular.
  • Flexibilidad para mover lógica al edge cuando hace sentido.
  • Costos predecibles (pagas solo por invocaciones).
  • Observabilidad completa con CloudWatch.

Lo que pagas es más setup inicial y entender los trade-offs. Pero una vez que lo tienes andando, escalar es gratis y debuggear es claro.

En los próximos artículos voy a profundizar en pedazos específicos: streaming real, Lambda@Edge avanzado, multi-region, y casos de uso como micro-frontends. Si tienes un escenario específico que quieres que aborde, déjamelo en los comentarios.

Top comments (0)