Astro SSR on Lambda with Architect

avatar
Taylor Beseda
April 15, 2022

Astro, Architect, and Lambda logos

tl;dr
Astro released an experimental SSR feature with an adapter API. To integrate with the Lambda runtime, an API Gateway event can be transformed into an Astro Request. Then the Astro Response is returned to the client. With this adapter, an Astro + Lambda SSR project can be developed locally and deployed with Architect.

Astro and SSR

Astro started as a static site generator (SSG) with a novel file format. Astro focuses on shipping less JavaScript to the browser and, if you want, lets you bring your own front-end framework (React, Vue, Svelte, etc.).

With their recent 1.0 Beta release came an experimental server-side rendering (SSR) feature. The format is compelling for HTTP cloud functions: server-side (or build-time, if using Astro for SSG) JavaScript up top and a JSX-like template below. Be sure to check out the examples in Astro’s docs.

Lambda Adapter with Typescript

Astro’s renderer accepts a Node.js Request primitive and returns a Response. Assuming a Lambda receives an event from API Gateway 2.0, we’ll need to transform that event into a proper Request.

Typescript is well suited for this sort of thing: ensuring one entity can be correctly mapped to another.

We’ll grab some type definitions from @types/aws-lambda and use Astro’s built-in declarations to build a simple function we can plug into the Astro adapter API later:

import { NodeApp } from "astro/app/node";
import type { SSRManifest } from "astro";
import type {
  APIGatewayProxyEventV2,
  APIGatewayProxyResult,
} from "aws-lambda";

async function adapter(
  event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResult> {
  // create an Astro Node App; requires a manifest
  const app = new NodeApp(manifest as SSRManifest);
  // destructure the API Gateway event
  const {
    cookies,
    headers,
    rawPath,
    rawQueryString,
    requestContext,
  } = event;

  // reconstruct the original URL
  const fullUrl = new URL(
    `${rawPath}${
      rawQueryString.length > 0 ? `?${rawQueryString}` : ""
    }`,
    new URL(`https://${headers.host}`)
  );

  // assemble the Headers and Request
  const newHeaders = new Headers(headers as any);
  if (cookies) newHeaders.append("cookie", cookies.join("; "));

  const request = new Request(fullUrl.toString(), {
    method: requestContext.http.method,
    headers: newHeaders,
  });

  try {
    // render with Astro
    const rendered: Response = await app.render(request);
    const body: string = await rendered.text();

    // return an API Gateway Result
    return {
      statusCode: rendered.status,
      headers: Object.fromEntries(rendered.headers.entries()),
      body,
    };
  } catch (error: unknown) {
    throw error;
  }
}

In about 20 lines of code, we’ve got a minimal translator for API Gateway to Astro and back again. Typescript made the development a lot quicker by assuring us that we’ve correctly translated API Gateway events to Astro’s expectations. Credit to the list of existing Astro integrations for establishing some patterns I’ve adapted.

I’ve built this functionality into a small package called astro-lambda-adapter. Test it out in your Astro + Lambda projects.
You may notice one glaring omission: the event.body isn’t passed to Astro. The body is key for a POST request to work in Astro. The adapter is still a work in progress; feedback and PRs are welcome 😄

Develop Locally with Architect Sandbox

A great way to test our Astro SSR build locally is with a Lambda emulator like Architect’s Sandbox.
(Jump right to the example project on Github.)

In an existing Astro project, add an Architect manifest file:

# app.arc

@app
arc-astro

@http
/*
  method any
  src ./server

Inst all the Lambda adapter:

npm i astro-lambda-adapter

Tell Astro to use the adapter for SSR builds and to build to the server/ folder:

// astro.config.mjs

import { defineConfig } from 'astro/config';
import awsAdapter from 'astro-lambda-adapter';

export default defineConfig({
  adapter: awsAdapter(),
  outDir: './server/dist',
});

Last, create the server handler:

// ./server/index.mjs

import { handler as ssrHandler } from "./dist/server/entry.mjs";

export async function handler(event) {
  console.log(`🚀 ${event.requestContext.http.method}: ${event.rawPath}`);

  return await ssrHandler(
    event,
    { host: (process.env.ARC_SANDBOX ? "http://localhost:3333" : null) },
  );
}

This is grabbing an exported handler function (renamed to ssrHandler to not conflict with Lambda conventions) created by astro-lambda-adapter to render the Astro application.
Note that the host argument is optional in production but should be set in a local emulated environment.

Now we can build our Astro app in SSR mode (the astro dev command won’t use our adapter but is a great way to work on just the Astro parts of a project):

npx astro build --experimental-ssr

Astro will bundle the app to a file located in a dist/ folder alongside the Lambda handler.

Finally, we’re able to view our built application with Sandbox:

npx arc sandbox

Check localhost:3333 to see the Astro app running locally.

What’s next?

First, the adapter needs to be extended to support Astro (and generally HTTP) capabilities. Then it certainly needs some tests. Also, astro-lambda-adapter hasn’t been used with a complex Astro project that depends on many of external libraries that need to be bundled when Astro builds.

Once astro-lambda-adapter is in good shape, it would be beneficial to leverage Architect 10’s plugin API to build Astro’s build process into Sandbox so that changes to an Astro app are automatically rebuilt. Additionally, you probably plan to access a data layer if you’ve got Astro running in a Lambda. Combining DynamoDB and Astro’s speed could yield a really snappy web application.

Do you have a good use case for Astro in a Lambda? Let us know what you think on Twitter.