React Server Components cambiaron cómo pensamos el SSR. La diferencia con SSR tradicional es que los componentes se serializan como una representación especial (RSC payload), no como HTML, y se transfieren al cliente donde React los hidrata sin reenviar el JavaScript original. En AWS Lambda, esto tiene implicaciones específicas que nadie documenta bien. Este artículo es el setup completo que uso en producción.
La diferencia fundamental
flowchart TB
subgraph SSR[SSR tradicional]
S1[Request] --> S2[Server renderiza HTML]
S2 --> S3[Browser recibe HTML + JS completo]
S3 --> S4[Hidratación: re-ejecutar todo en cliente]
end
subgraph RSC[React Server Components]
R1[Request] --> R2[Server renderiza en RSC payload]
R2 --> R3[Browser recibe HTML + RSC payload<br/>+ solo JS de Client Components]
R3 --> R4[Hidratación parcial: solo Client Components]
end
style RSC fill:#149eca,color:#fff
Los beneficios en Lambda:
- Bundle JavaScript enviado al cliente es menor (no incluye código server-only).
- Los Server Components ejecutan lógica en el servidor sin APIs intermedias.
- Data fetching es síncrono desde el componente.
El trade-off: necesitas un runtime que entienda RSC y maneje streaming. Next.js 15 lo hace bien, pero desplegar eso en Lambda requiere configuración específica.
La arquitectura objetivo
flowchart LR
User[Usuario] --> CF[CloudFront]
CF -->|HTML/RSC| LF[Lambda Function URL<br/>Response Streaming]
LF --> N[Next.js 15 Server]
N -->|RSC payload| Stream[Streamed Response]
LF -->|Static assets| S3[S3 Bucket]
N -->|Data fetch| DB[(DynamoDB)]
N -->|Data fetch| API[APIs externas]
style LF fill:#ff9900,color:#000
style N fill:#000,color:#fff
Preparando el build de Next.js para Lambda
Next.js 15 con output: 'standalone' genera un servidor mínimo. Esto es fundamental porque Lambda tiene límites de tamaño:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
experimental: {
serverActions: {
bodySizeLimit: '2mb',
},
},
compress: false, // CloudFront se encarga
poweredByHeader: false,
images: {
loader: 'custom',
loaderFile: './lib/image-loader.ts',
},
async headers() {
return [
{
source: '/_next/static/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
],
},
];
},
};
module.exports = nextConfig;
El adapter de Lambda con Response Streaming
El truco está en convertir el server de Next.js en un handler de Lambda con streaming. Este es el código completo:
// server/lambda-handler.ts
import type { Readable } from 'node:stream';
import { IncomingMessage, ServerResponse } from 'node:http';
import { Socket } from 'node:net';
import NextServer from 'next/dist/server/next-server.js';
import path from 'node:path';
// Lambda streaming types
declare const awslambda: {
streamifyResponse: (handler: any) => any;
HttpResponseStream: {
from: (stream: any, metadata: any) => any;
};
};
const nextServer = new NextServer.default({
hostname: 'localhost',
port: 3000,
dir: path.join(__dirname, '..'),
dev: false,
conf: {
distDir: '.next',
compress: false,
},
});
const requestHandler = nextServer.getRequestHandler();
export const handler = awslambda.streamifyResponse(
async (event: any, responseStream: any, context: any) => {
const { rawPath, rawQueryString, headers, body, requestContext, isBase64Encoded } = event;
// Construir el IncomingMessage
const req = buildIncomingRequest({
method: requestContext.http.method,
path: rawPath,
queryString: rawQueryString,
headers,
body,
isBase64Encoded,
});
// Wrapper del responseStream como ServerResponse
const res = buildServerResponse(responseStream);
// Iniciar el pipeline de Next.js
try {
await requestHandler(req, res);
} catch (error) {
console.error('SSR error:', error);
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html');
res.end('<h1>Internal Server Error</h1>');
}
}
}
);
function buildIncomingRequest(params: {
method: string;
path: string;
queryString: string;
headers: Record<string, string>;
body?: string;
isBase64Encoded?: boolean;
}): IncomingMessage {
const socket = new Socket();
const req = new IncomingMessage(socket);
req.method = params.method;
req.url = params.path + (params.queryString ? `?${params.queryString}` : '');
req.headers = params.headers;
if (params.body) {
const buffer = params.isBase64Encoded
? Buffer.from(params.body, 'base64')
: Buffer.from(params.body);
req.push(buffer);
}
req.push(null);
return req;
}
function buildServerResponse(responseStream: any): ServerResponse {
const res = new ServerResponse(new IncomingMessage(new Socket()));
let statusCode = 200;
let headers: Record<string, string> = {};
let headersWritten = false;
const writeHeaders = () => {
if (headersWritten) return;
headersWritten = true;
const metadata = {
statusCode,
headers: {
...headers,
'Content-Type': headers['content-type'] || 'text/html; charset=utf-8',
},
};
responseStream = awslambda.HttpResponseStream.from(responseStream, metadata);
};
res.setHeader = function (name: string, value: any) {
headers[name.toLowerCase()] = String(value);
return this;
};
res.writeHead = function (code: number, ...args: any[]) {
statusCode = code;
if (typeof args[0] === 'object') {
Object.assign(headers, args[0]);
}
writeHeaders();
return this;
};
res.write = function (chunk: any) {
writeHeaders();
responseStream.write(chunk);
return true;
};
res.end = function (chunk?: any) {
writeHeaders();
if (chunk) responseStream.write(chunk);
responseStream.end();
};
Object.defineProperty(res, 'statusCode', {
get: () => statusCode,
set: (value) => { statusCode = value; },
});
Object.defineProperty(res, 'headersSent', {
get: () => headersWritten,
});
return res;
}
Packaging del build para Lambda
El script de build que toma el output de Next.js y lo empaqueta correctamente:
#!/usr/bin/env bash
# scripts/build-lambda.sh
set -e
echo "Building Next.js..."
npm run build
echo "Preparando estructura Lambda..."
rm -rf .lambda
mkdir -p .lambda
# Copiar el standalone output
cp -r .next/standalone/* .lambda/
cp -r .next/standalone/.next .lambda/
# Copiar archivos estáticos al server (necesario para páginas estáticas)
cp -r .next/static .lambda/.next/static
# Copiar nuestro handler custom
cp server/lambda-handler.js .lambda/lambda-handler.js
# Los archivos public/ van al handler
if [ -d "public" ]; then
cp -r public .lambda/public
fi
# Reemplazar server.js por nuestro handler
cat > .lambda/index.js <<EOF
const { handler } = require('./lambda-handler.js');
exports.handler = handler;
EOF
echo "Creando zip..."
cd .lambda
zip -rq ../lambda-build.zip .
cd ..
echo "Build completo: lambda-build.zip ($(du -h lambda-build.zip | cut -f1))"
La definición del stack CDK
// infra/lib/rsc-lambda-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';
import { Construct } from 'constructs';
export class RscLambdaStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
const staticBucket = new s3.Bucket(this, 'StaticAssets', {
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
});
// Upload de assets estáticos
new s3deploy.BucketDeployment(this, 'StaticDeployment', {
sources: [s3deploy.Source.asset('../.next/static')],
destinationBucket: staticBucket,
destinationKeyPrefix: '_next/static',
cacheControl: [
s3deploy.CacheControl.setPublic(),
s3deploy.CacheControl.maxAge(cdk.Duration.days(365)),
s3deploy.CacheControl.immutable(),
],
});
// Lambda SSR con streaming
const ssrFunction = new lambda.Function(this, 'SSRFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('../lambda-build.zip'),
memorySize: 1769, // 1 vCPU completo
timeout: cdk.Duration.seconds(30),
environment: {
NODE_ENV: 'production',
NEXT_SHARP_PATH: '/opt/nodejs/node_modules/sharp',
},
logRetention: cdk.aws_logs.RetentionDays.ONE_MONTH,
architecture: lambda.Architecture.ARM_64,
});
// Function URL con streaming habilitado
const functionUrl = ssrFunction.addFunctionUrl({
authType: lambda.FunctionUrlAuthType.NONE,
invokeMode: lambda.InvokeMode.RESPONSE_STREAM,
cors: {
allowedOrigins: ['*'],
allowedMethods: [lambda.HttpMethod.GET, lambda.HttpMethod.POST],
},
});
// CloudFront distribution
const distribution = new cloudfront.Distribution(this, 'Distribution', {
defaultBehavior: {
origin: new origins.FunctionUrlOrigin(functionUrl),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
cachePolicy: new cloudfront.CachePolicy(this, 'SSRCache', {
defaultTtl: cdk.Duration.seconds(0),
minTtl: cdk.Duration.seconds(0),
maxTtl: cdk.Duration.days(1),
headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
'Accept',
'Accept-Language',
'RSC',
'Next-Router-State-Tree',
'Next-Router-Prefetch'
),
queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),
cookieBehavior: cloudfront.CacheCookieBehavior.none(),
enableAcceptEncodingBrotli: true,
enableAcceptEncodingGzip: true,
}),
},
additionalBehaviors: {
'/_next/static/*': {
origin: origins.S3BucketOrigin.withOriginAccessControl(staticBucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
},
},
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
});
new cdk.CfnOutput(this, 'Url', {
value: `https://${distribution.distributionDomainName}`,
});
}
}
Los headers que importan para RSC
CloudFront por defecto no reenvía los headers que React necesita para RSC. Hay 3 que son críticos:
-
RSC: indica que el request es específicamente por el RSC payload. -
Next-Router-State-Tree: el árbol de navegación actual. -
Next-Router-Prefetch: si es un prefetch del router.
Si no forwardeas estos headers en la cache policy, los navegaciones entre páginas con el router de Next.js se van a romper.
Ejemplo de página con Server Components
Aquí un ejemplo real usando las ventajas del modelo:
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
import { Suspense } from 'react';
import { CommentsList } from './comments-list';
import { RelatedPosts } from './related-posts';
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
async function getPost(slug: string) {
const result = await client.send(
new GetCommand({
TableName: process.env.POSTS_TABLE,
Key: { slug },
})
);
return result.Item;
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound();
}
return (
<article>
<header>
<h1>{post.title}</h1>
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString()}
</time>
</header>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<Suspense fallback={<p>Cargando comentarios...</p>}>
<CommentsList postId={post.id} />
</Suspense>
<Suspense fallback={null}>
<RelatedPosts tags={post.tags} excludeId={post.id} />
</Suspense>
</article>
);
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post?.title,
description: post?.excerpt,
};
}
Los Suspense son los que habilitan el streaming. El HTML principal se envía inmediatamente, y los componentes dentro de Suspense se stream cuando estén listos.
El cliente sigue siendo útil
Los Client Components son necesarios para interactividad. Se marcan con 'use client':
// app/posts/[slug]/comments-list.tsx
'use client';
import { useState } from 'react';
import { createCommentAction } from './actions';
export function CommentsList({ postId }: { postId: string }) {
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(formData: FormData) {
setIsPending(true);
setError(null);
const result = await createCommentAction(postId, formData);
if (result.error) setError(result.error);
setIsPending(false);
}
return (
<section>
<h2>Comentarios</h2>
<form action={handleSubmit}>
<textarea name="content" required minLength={3} maxLength={1000} />
<button type="submit" disabled={isPending}>
{isPending ? 'Enviando...' : 'Comentar'}
</button>
</form>
{error && <p role="alert">{error}</p>}
</section>
);
}
Streaming en acción
Cuando todo está bien configurado, el flujo visual es:
sequenceDiagram
participant User as Usuario
participant CF as CloudFront
participant L as Lambda
participant DB as DynamoDB
User->>CF: GET /posts/mi-post
CF->>L: Invoke with streaming
L->>DB: Get post
DB-->>L: Post data
L->>CF: Shell HTML + post content (streaming)
CF->>User: Render parcial visible
L->>DB: Get comments (paralelo)
L->>DB: Get related posts
DB-->>L: Comments
L->>CF: Suspense boundary comments
CF->>User: Render comments
DB-->>L: Related
L->>CF: Suspense boundary related
CF->>User: Render related
El usuario ve el contenido principal en ~200ms y el resto se va materializando sin bloquear.
Medición real: antes y después
En un proyecto real medí estas métricas antes y después de migrar a RSC + Lambda streaming:
| Métrica | SSR tradicional | RSC + streaming |
|---|---|---|
| TTFB (P75) | 890ms | 180ms |
| First Contentful Paint | 1.4s | 0.6s |
| JS bundle cliente | 312 KB | 187 KB |
| Lambda duration promedio | 650ms | 720ms |
| Lambda memory usado | 420 MB | 380 MB |
El TTFB bajó dramáticamente por el streaming. Lambda tarda parecido porque sigue haciendo el mismo trabajo, pero el usuario ve pixels antes.
Lo que sale mal en producción
1. Cold starts con el bundle de Next.js.
Next.js standalone es ~50MB. Cold start puede ser 1-2 segundos. Solución: Provisioned Concurrency para producción, o SnapStart si está disponible en tu región.
2. Streaming se rompe con middleware mal configurado.
Middleware que intenta leer el body completo antes de pasarlo al handler rompe el streaming. Asegúrate de que tu middleware sea transparente.
3. Los logs se llenan rápido.
Next.js loguea mucho en SSR por default. Desactiva logs innecesarios con NEXT_TELEMETRY_DISABLED=1 y configura retention corto en CloudWatch.
4. Sharp en Lambda requiere el binario correcto.
La dependencia sharp para optimización de imágenes tiene binarios nativos. Necesitas el de linux-arm64 (si usas Graviton) o linux-x64. Instálalo con --platform=linux --arch=arm64.
Cuándo no vale la pena
Este setup tiene complejidad. Evítalo si:
- Tu app es principalmente estática (usa Astro).
- No tienes tráfico suficiente para justificar Provisioned Concurrency.
- El equipo no está cómodo con Lambda para producción.
Para esos casos, Amplify Hosting maneja todo esto por ti a cambio de menos control.
Cierre
RSC en Lambda con streaming es el punto dulce entre control y experiencia de usuario. El setup inicial toma un día completo, pero el payoff es un TTFB que compite con CDNs estáticas y flexibilidad total sobre la infraestructura.
En el próximo artículo voy a enfocarme específicamente en Lambda Response Streaming, sus límites y trucos para sacarle el máximo.
Top comments (0)