DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Server Components de React en AWS Lambda el setup completo con streaming

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

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

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

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

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

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

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

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

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

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)