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
Los flujos principales son:
-
Assets estáticos (
_next/static/*, imágenes, fonts): CloudFront los sirve desde S3 con TTL largo. - Rutas SSR: CloudFront llama a una Lambda regional que ejecuta el handler de Next.js/Nuxt/SvelteKit.
- Lambda@Edge maneja lógica que debe correr en el edge: redirects por geolocalización, A/B tests, headers de seguridad.
- 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 | Sí |
| 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,
});
}
}
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('?');
}
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));
}
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)
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]
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)