DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

SEO técnico para SPAs en AWS, prerendering on-demand con Lambda

Tengo un cliente con un SPA Angular viejo que no se puede migrar a SSR. El negocio es catálogo de productos, el SEO importa, y Google lleva años diciendo que rastrea JavaScript pero en la práctica sigue siendo un dolor de cabeza. Bing y redes sociales directamente no ejecutan JS. El cliente necesitaba que Meta leyera los Open Graph tags cuando alguien comparte un link en WhatsApp.

La solución clásica de SSR implica reescribir todo. La alternativa pragmática es prerendering on-demand con Lambda: un bot pide la página, Lambda abre Chromium headless, renderiza el HTML final, lo devuelve. El humano sigue viendo el SPA normal.

La arquitectura

flowchart LR
    R[Request] --> E[Lambda@Edge<br/>User-Agent check]
    E -->|Bot| P[Prerender Lambda<br/>Chromium + Puppeteer]
    E -->|Humano| S[S3 SPA<br/>index.html]
    P --> CDN[CloudFront Cache<br/>TTL 24h por URL]
    CDN --> R
    S --> R
Enter fullscreen mode Exit fullscreen mode

La clave son dos Lambdas distintas. Una corre en edge (viewer-request) y decide a dónde mandar el tráfico basándose en User-Agent. La otra corre en región (us-east-1 en mi caso) y es la que hace el trabajo pesado con Chromium. Chromium pesa 250MB, no cabe en edge.

Detección de bots en Lambda@Edge

// edge-router/index.ts
import type { CloudFrontRequestEvent, CloudFrontRequestResult } from 'aws-lambda';

const BOT_PATTERNS = [
  /googlebot/i,
  /bingbot/i,
  /yandex/i,
  /baiduspider/i,
  /duckduckbot/i,
  /slurp/i,
  /facebookexternalhit/i,
  /twitterbot/i,
  /linkedinbot/i,
  /whatsapp/i,
  /telegrambot/i,
  /discordbot/i,
  /slackbot/i,
  /applebot/i,
];

const PRERENDER_ORIGIN = 'prerender.internal.ittal.co';

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

  const userAgent = headers['user-agent']?.[0]?.value || '';
  const isBot = BOT_PATTERNS.some((pattern) => pattern.test(userAgent));

  if (!isBot) {
    return request;
  }

  const extension = request.uri.split('.').pop()?.toLowerCase();
  const staticExtensions = ['js', 'css', 'png', 'jpg', 'svg', 'woff2', 'ico'];
  if (extension && staticExtensions.includes(extension)) {
    return request;
  }

  request.origin = {
    custom: {
      domainName: PRERENDER_ORIGIN,
      port: 443,
      protocol: 'https',
      path: '',
      sslProtocols: ['TLSv1.2'],
      readTimeout: 30,
      keepaliveTimeout: 5,
      customHeaders: {},
    },
  };

  headers['host'] = [{ key: 'host', value: PRERENDER_ORIGIN }];
  headers['x-original-uri'] = [{ key: 'x-original-uri', value: request.uri }];

  return request;
};
Enter fullscreen mode Exit fullscreen mode

Bundle de Lambda@Edge tiene límite de 1MB comprimido. Por eso mantengo la lista de bots hardcoded en lugar de cargarla de DynamoDB o similar. El array de regex se compila una vez y vive en memoria caliente.

Lambda de prerendering

// prerender/index.ts
import type { APIGatewayProxyHandler } from 'aws-lambda';
import chromium from '@sparticuz/chromium';
import puppeteer from 'puppeteer-core';
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({ region: 'us-east-1' });
const CACHE_BUCKET = process.env.CACHE_BUCKET!;
const BASE_URL = process.env.BASE_URL!;
const CACHE_TTL_SECONDS = 60 * 60 * 24;

async function getCached(key: string): Promise<string | null> {
  try {
    const result = await s3.send(
      new GetObjectCommand({ Bucket: CACHE_BUCKET, Key: key })
    );
    const lastModified = result.LastModified?.getTime() ?? 0;
    const age = (Date.now() - lastModified) / 1000;
    if (age > CACHE_TTL_SECONDS) return null;
    return await result.Body!.transformToString();
  } catch {
    return null;
  }
}

async function setCached(key: string, html: string): Promise<void> {
  await s3.send(
    new PutObjectCommand({
      Bucket: CACHE_BUCKET,
      Key: key,
      Body: html,
      ContentType: 'text/html; charset=utf-8',
      CacheControl: `max-age=${CACHE_TTL_SECONDS}`,
    })
  );
}

export const handler: APIGatewayProxyHandler = async (event) => {
  const path = event.headers['x-original-uri'] || event.path || '/';
  const cacheKey = `prerender${path === '/' ? '/index' : path}.html`;

  const cached = await getCached(cacheKey);
  if (cached) {
    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'text/html; charset=utf-8',
        'X-Prerender-Cache': 'HIT',
      },
      body: cached,
    };
  }

  const browser = await puppeteer.launch({
    args: chromium.args,
    executablePath: await chromium.executablePath(),
    headless: true,
  });

  try {
    const page = await browser.newPage();
    await page.setUserAgent('Mozilla/5.0 Prerender');
    await page.setViewport({ width: 1280, height: 800 });

    await page.setRequestInterception(true);
    page.on('request', (req) => {
      const type = req.resourceType();
      if (['image', 'stylesheet', 'font', 'media'].includes(type)) {
        req.abort();
      } else {
        req.continue();
      }
    });

    const url = `${BASE_URL}${path}`;
    await page.goto(url, {
      waitUntil: 'networkidle0',
      timeout: 25000,
    });

    await page.evaluate(() => {
      const scripts = document.querySelectorAll('script');
      scripts.forEach((s) => s.remove());
    });

    const html = await page.content();
    await setCached(cacheKey, html);

    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'text/html; charset=utf-8',
        'X-Prerender-Cache': 'MISS',
      },
      body: html,
    };
  } finally {
    await browser.close();
  }
};
Enter fullscreen mode Exit fullscreen mode

El truco de interceptar requests y bloquear imágenes, CSS y fuentes reduce el tiempo de renderizado de 8 segundos a 2-3. Al bot no le importa el CSS, solo quiere el HTML con los metadatos.

Quitar los scripts del HTML final evita que el bot intente ejecutar el SPA encima del contenido prerenderizado. Algunos bots se confunden si encuentran ambas cosas.

Caché en dos niveles

Tengo caché en S3 con TTL de 24h y CloudFront con TTL de 1h. Parece redundante pero no lo es. CloudFront puede invalidarse manualmente cuando actualizo un producto, S3 es el fallback durable. Si CloudFront no tiene la página, va a S3, si S3 tampoco, entonces sí abrimos Chromium.

Request bot → CloudFront → HIT rápido
Request bot → CloudFront → MISS → Lambda
  Lambda → S3 cache → HIT, devuelve sin Chromium
  Lambda → S3 cache → MISS → Chromium → guarda en S3
Enter fullscreen mode Exit fullscreen mode

El primer bot que visita una URL nueva paga el costo de Chromium (2-3 segundos). Los siguientes 24 horas comen del caché.

Infraestructura con CDK

// cdk/prerender-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
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 { Construct } from 'constructs';

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

    const cacheBucket = new s3.Bucket(this, 'PrerenderCache', {
      encryption: s3.BucketEncryption.S3_MANAGED,
      lifecycleRules: [
        {
          id: 'expire-old-cache',
          expiration: cdk.Duration.days(7),
        },
      ],
    });

    const prerenderFn = new lambda.Function(this, 'PrerenderFn', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('../prerender/dist'),
      memorySize: 2048,
      timeout: cdk.Duration.seconds(30),
      environment: {
        CACHE_BUCKET: cacheBucket.bucketName,
        BASE_URL: 'https://origin.mysite.com',
      },
      layers: [
        lambda.LayerVersion.fromLayerVersionArn(
          this,
          'ChromiumLayer',
          'arn:aws:lambda:us-east-1:XXX:layer:chromium:1'
        ),
      ],
    });

    cacheBucket.grantReadWrite(prerenderFn);

    const api = new apigateway.LambdaRestApi(this, 'PrerenderApi', {
      handler: prerenderFn,
      proxy: true,
      deployOptions: {
        throttlingBurstLimit: 100,
        throttlingRateLimit: 50,
      },
    });

    const edgeRouter = new cloudfront.experimental.EdgeFunction(
      this,
      'EdgeRouter',
      {
        runtime: lambda.Runtime.NODEJS_20_X,
        handler: 'index.handler',
        code: lambda.Code.fromAsset('../edge-router/dist'),
      }
    );

    new cloudfront.Distribution(this, 'SiteDistribution', {
      defaultBehavior: {
        origin: new origins.S3Origin(
          s3.Bucket.fromBucketName(this, 'SpaBucket', 'my-spa-bucket')
        ),
        edgeLambdas: [
          {
            eventType: cloudfront.LambdaEdgeEventType.VIEWER_REQUEST,
            functionVersion: edgeRouter.currentVersion,
          },
        ],
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

El Edge Function va en us-east-1 obligatoriamente (requisito de Lambda@Edge). La Lambda de prerender también la pongo en us-east-1 para reducir latencia entre ambas.

Estrategias que probé y descarté

Prerender.io como servicio externo. Funciona bien pero cuesta 300 USD/mes para volumen medio. Con Lambda pago menos de 10 USD/mes para el mismo tráfico de bots.

Rendertron de Google. Lo corrí en ECS Fargate. Más caro que Lambda porque tiene que estar siempre encendido. El tráfico de bots es spiky, Lambda es mejor fit.

Server-side rendering con Angular Universal. Toca reescribir componentes que acceden a window o document. En una app con 80 componentes existentes, el esfuerzo era enorme. Prerendering me dio 90% del beneficio con 10% del esfuerzo.

Lo que aprendí

1. Cloaking de Google no es un problema si devuelves el mismo contenido.
Google explícitamente dice que dynamic rendering (servir HTML prerenderizado a bots) está permitido. El pecado es servir contenido diferente al humano, no el render diferente. Yo devuelvo el mismo HTML, solo cambia quién lo renderiza.

2. networkidle0 puede colgarse en SPAs con polling.
Algunos frameworks hacen polling a endpoints de métricas o analytics. waitUntil: 'networkidle0' espera 500ms sin tráfico de red. Si tu app hace ping cada 200ms, nunca termina. Solución: networkidle2 (permite hasta 2 conexiones activas) o waitForSelector con un elemento que sé que aparece cuando la página está lista.

3. Chromium cold start es brutal.
Primera invocación tarda 4-5 segundos solo en arrancar Chromium. Con provisioned concurrency (1 instancia) lo bajé a 800ms. Cuesta 2-3 USD al mes y vale la pena porque Googlebot se impacienta.

4. Meta tags dinámicos funcionan, Open Graph también.
Probé compartiendo URLs del SPA prerenderizado en WhatsApp, Slack, Twitter y LinkedIn. Todos leyeron los og:image y og:description correctamente. Con el SPA puro, solo aparecía el título genérico del index.html.

5. El caché en S3 me salvó en un incidente.
Un viernes el Lambda de prerender empezó a fallar por timeout (una página tenía un bug que congelaba el renderizado). CloudFront seguía sirviendo desde S3 con el HTML viejo. Googlebot nunca vio errores 5xx. Resolví el lunes tranquilo.

Cuándo NO usar prerendering

No lo uses si tu stack permite SSR nativo. Next.js, Nuxt, Angular Universal, SvelteKit, todos hacen mejor trabajo que prerendering porque generan HTML fresh en cada request con datos actualizados. Prerendering es para SPAs legacy o cuando migrar no es opción.

Tampoco lo uses si tu contenido cambia cada pocos minutos. TTL de 24h no funciona para precios de stocks o resultados deportivos. Ahí necesitas SSR real o al menos ISR.

Si tu SPA es una app privada (dashboard interno, SaaS B2B con login), el SEO no importa y estás metiendo complejidad para nada.


El próximo artículo aborda monorepos grandes con NX en CodeBuild usando matrices para paralelizar builds. Spoiler: pasé de 40 minutos a 8 en un monorepo de 12 apps.

Top comments (0)