Documentation

AWS Lambda

What you'll have at the end: a Vura app deployed to AWS Lambda + API Gateway using AWS SAM, with EventBridge cron triggers for task routes.

Supports: serverless, task (via EventBridge). Hot routes are not supported — see the limitation box below.


Steps

1. Install the adapter

npm install @celsian/vura-adapter-lambda

2. Configure the adapter

// vura.config.ts
import { defineConfig } from '@celsian/vura-core';
import { lambdaAdapter } from '@celsian/vura-adapter-lambda';

export default defineConfig({
  adapter: lambdaAdapter({
    region: 'us-east-1',
    memory: 256,
    timeout: 30,
    stackName: 'my-vura-app',
    runtime: 'nodejs22.x',
    architecture: 'arm64',
  }),
});

All options have defaults: region is us-east-1, memory is 256, timeout is 30 seconds, stackName is then-app, runtime is nodejs22.x, architecture is arm64.

3. Build

npm run build

Emitted artifacts:

dist/
  lambda/
    api_hello_get/
      index.js
      route.js
    api_orders_post/
      index.js
      route.js
    task_api_cleanup/        ← task route with EventBridge cron
      index.js
      route.js
    __pages/                 ← every page, in one function
      index.js
      pages.js               ← only when the project has `server` pages
      assets/                ← prerendered pages, client bundles, public/
  template.yaml
  samconfig.toml

4. The SAM template

dist/template.yaml is generated by vura build. For a project with one serverless GET route (/api/hello), one task route (/api/cleanup, schedule 0 3 * * *), and two pages (/ static, /posts server):

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Vura Application

Globals:
  Function:
    Runtime: nodejs22.x
    Architectures:
      - arm64
    MemorySize: 256
    Timeout: 30
    Environment:
      Variables:
        NODE_ENV: production

Resources:
  ThenHttpApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      StageName: prod

  GETApihelloFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: lambda/api_hello_get/
      Events:
        Api:
          Type: HttpApi
          Properties:
            ApiId: !Ref ThenHttpApi
            Path: /api/hello
            Method: GET

  TaskCleanupFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: lambda/task_api_cleanup/
      Timeout: 60
      Events:
        Schedule:
          Type: Schedule
          Properties:
            Schedule: cron(0 3 * * ? *)
            Enabled: true

  VuraPagesFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: lambda/__pages/
      Events:
        PageRoot:
          Type: HttpApi
          Properties:
            ApiId: !Ref ThenHttpApi
            Path: /
            Method: GET
        PagePosts:
          Type: HttpApi
          Properties:
            ApiId: !Ref ThenHttpApi
            Path: /posts
            Method: GET
        CatchAll:
          Type: HttpApi
          Properties:
            ApiId: !Ref ThenHttpApi
            Path: /{proxy+}
            Method: ANY

Outputs:
  ApiUrl:
    Description: API Gateway endpoint URL
    Value: !Sub "https://${ThenHttpApi}.execute-api.${AWS::Region}.amazonaws.com/prod"

Notes:

5. Validate and deploy

# Validate the template
sam validate --template dist/template.yaml --lint

# Build Lambda packages (resolves dependencies into zip bundles)
sam build --template dist/template.yaml

# Deploy (guided first time — sets up samconfig.toml answers)
sam deploy --guided

After the first --guided deploy, subsequent deploys use samconfig.toml:

sam deploy

6. Verify

# Get the API URL from the SAM output
aws cloudformation describe-stacks \
  --stack-name my-vura-app \
  --query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" \
  --output text

curl -fsS <api-url>/api/hello

Smoke test

API_URL=$(aws cloudformation describe-stacks \
  --stack-name my-vura-app \
  --query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" \
  --output text)

# API route
curl -fsS "${API_URL}/api/hello"

# Prerendered page, served from the pages function's asset copy
curl -fsS "${API_URL}/" | grep -q '<h1'

# Server-mode page: rendered per request, with its loader
curl -fsS "${API_URL}/posts" | grep -q '__VURA_LOADER__'

The repo ships all of this as one script, which is what CI runs against every target:

node scripts/assert-served-pages.mjs "$API_URL"

Limitation: hot routes are not supported

Lambda terminates the process between invocations and cannot hold a WebSocket connection open. Hot routes require a persistent process.

When hot routes are present, vura build warns at build time:

[vura] N hot route(s) cannot run on lambda and were not bundled: /api/live/room — deploy them to a persistent host (see /self-host/)

Hot routes are not silently excluded — they are named in the warning. Deploy hot routes to a persistent host (Node / VPS, Docker, or Fly.io) alongside your Lambda deployment.


Pages

All four page modes are served, by one function: VuraPagesFunction, declared in the SAM template with a GET route per page pattern plus a greedy /{proxy+} on ANY. HTTP API matches the most specific route first, so every API route still wins; the catch-all serves the client bundles under /_then/, anything from public/, and turns an unknown path into the 404 page instead of API Gateway's 403 Missing Authentication Token.

Prerendered assets are served from the function, not from S3. They are copied into dist/lambda/__pages/assets/ and read from /var/task. That is a deliberate trade: sam deploy uploads code, not site content, so an S3 + CloudFront story needs a bucket, a distribution and an aws s3 sync you run yourself. Serving from the bundle keeps sam deploy as the whole deploy, and costs you every asset byte as function time.

vura build tells you when that trade stops being the right one:

[vura] 31.4 MB of prerendered assets are bundled into the pages Lambda and served through it. That works, but every byte is billed as function time and inflates cold starts — put the files in S3 behind CloudFront and route only server-mode pages here.

and names any single file API Gateway cannot return at all:

[vura] 1 asset(s) exceed what API Gateway can return (6 MB, less base64 overhead) and will fail with a 500 when requested: /video/intro.mp4. Serve them from S3/CloudFront instead.

Limitation: streaming pages are buffered

A page with export const page = { streaming: true } still renders correctly, but the shell cannot go out before the body: API Gateway's proxy integration buffers the whole response. Named at build time:

[vura] N streaming page(s) are buffered on AWS Lambda — API Gateway's proxy integration has no early flush, so the shell cannot go out before the body: /feed. They still render correctly.

Limitation: a server page cannot import a Node built-in

The pages bundle is built runtime-neutral so the same artifact runs on every serverless target. A server page, layout or loader that imports a node: module fails the build rather than being dropped:

[vura] server-mode page(s) could not be bundled for AWS Lambda: src/pages/report.tsx.

Move that work into an API route the page fetches with its loader.


revalidateTag inside Lambda functions

Lambda function bundles include a revalidateTag/revalidatePath shim that logs a warning instead of calling a local cache engine:

[vura] revalidateTag("posts") is a no-op inside Lambda functions today — call your cache host's /__vura/revalidate webhook instead.

Lambda functions have no local ISR cache. To invalidate ISR cache from a Lambda function, make an authenticated POST to your Node server's /__vura/revalidate endpoint:

curl -X POST https://your-node-server.com/__vura/revalidate \
  -H "x-vura-revalidate-secret: your-secret" \
  -H "Content-Type: application/json" \
  -d '{"tags":["posts"]}'

Cold-start latency has not been measured for this adapter — no numbers are stated here. Measure for your own function sizes and regions before making performance claims.


CI-tested: this guide is verified by the lambda job in .github/workflows/selfhost.yml. The job builds the project with the Lambda adapter, runs sam validate --lint on dist/template.yaml, and boots the handler under sam local start-api (Docker-backed Lambda emulation), then runs scripts/assert-served-pages.mjs against it: every page mode, the browser bundle a client page boots from, the loader payload on a server page (twice, a second apart, to prove the loader runs per request), the API route, and the 404. It does not deploy to AWS — no cloud credentials are in CI; API Gateway, IAM, and real Lambda networking behavior are out of scope.


Route kind support

Kind Supported
Serverless yes
Hot (WebSocket) no — build warns by name
Task (cron) yes — via EventBridge Schedule event