Running Eleventy Serverless On AWS Lambda@Edge Eleventy is great. It’s a static site generator written in JavaScript, for “ ” It’s 10 to 20 times faster than the alternatives, like Gatsby or Next.js. You get all of your content statically rendered and ready to be CDN-delivered. You needn’t worry about server-side rendering to get those pretty social share unfurls. And, if you have a large data set, that’s great — Eleventy can with no issues. Fast Builds and even Faster Web Sites. generate tens of thousands of pages What if you have a HUGE data set? When building Sandworm’s , we wanted to generate a catalog of beautiful report visualizations for every library in the npm registry. That is, for every version of every library in the registry. We soon found out — that’s more than . Good luck generating, uploading, and keeping that amount of HTML pages up to date in a decent amount of time, right? open-source security & license compliance audits for JavaScript packages 30 million package versions We looked at reducing our data set to just the most popular packages. We looked at implementing partial builds, where stale report pages would get continuously generated and uploaded. But the solution we ended up implementing was , a plugin that runs one or more template files to generate dynamic pages. So instead of going through the entire set of pages at build time, this plugin allows us to separate “regular” content pages rendered at build from “dynamic” pages rendered on demand. We can then simply generate and upload static content (like the homepage, about page, etc.) in the CI, and then deploy some code to a compute provider that will generate an npm package page when a user navigates to a specific URL. Great! Eleventy Serverless at request time Except: Eleventy Serverless is built to work out-of-the-box with Netlify Functions, and we’re running on AWS. The good news is that you get Eleventy Serverless to run in AWS Lambdas. Even better, you can get it to run in Lambda@Edge, which runs your code globally at AWS locations close to your users so that you can deliver full-featured, customized content with high performance and low latency. can Setting up Eleventy First things first: let’s get Eleventy running the local build. We start by installing it: npm i @11ty/eleventy --dev Then, let’s create the simplest template for our static Eleventy page. We’ll write it using , but since it’s so simple, it won’t take advantage of any useful templating tags for now. Liquid Let’s call it : index.liquid <h1>Hello</h1> That’s it, we’re ready to build and serve! Run: npx @11ty/eleventy --serve [11ty] Writing _site/index.html from ./src/index.liquid [11ty] Serverless: 3 files bundled to ./serverless/edge. [11ty] Wrote 1 file in 0.12 seconds (v2.0.0) [11ty] Watching… [11ty] Server at http://localhost:8080/ Visit in your browser at this point, and you should see the “Hello” heading we’ve created above. Neat! http://localhost:8080/ Setting up the Eleventy Serverless plugin The Serverless plugin is bundled with Eleventy and doesn’t require you to npm install anything. We do need to configure it, though. To do that, we need to create an Eleventy config file: // .eleventy.js const { EleventyServerlessBundlerPlugin } = require("@11ty/eleventy"); module.exports = function(eleventyConfig) { eleventyConfig.addPlugin(EleventyServerlessBundlerPlugin, { name: "edge", functionsDir: "./serverless/", redirects: false, }); return { dir: { input: 'src', }, }; }; Let’s break the plugin configuration down: Each plugin’s unique will determine the build output directory name and will be required when assigning permalinks. You can also instantiate multiple plugins to have different functions handle different pages. name The allows you to specify the path to the build output dir; in our case, the plugin will generate files in the directory relative to the app root. functionsDir ./serverless/edge configures how Netlify redirects should be handled — since we’re not running on Netlify, we set this to false to skip generating a file. redirects netlify.toml Lastly, in the configuration object we return to Eleventy, we specify an input dir for our content to keep things tidy. We’ll also go ahead and move the file we created earlier in the src directory. index.liquid Next, let's build again by running , and investigating what gets output under . npx @11ty/eleventy ./serverless/edge You should see the following: A number of js and json files starting with . Some are configuration files, and some are built to inform the Netlify bundler of function dependencies — we won’t need those for Lambda. eleventy- , a copy of the main configuration file in the app root. eleventy.config.js The directory with the template. src index.liquid An file with the actual serverless handler code that we’ll update and deploy to Lambda. index.js Let’s gitignore the build artifacts that we don’t want in our repo and only keep the file for now. index.js Add this to your file: .gitignore serverless/edge/** !serverless/edge/index.js Good, we’re now ready to create our first dynamically generated page. Let’s make another simple Liquid file for it under : src/edge.liquid --- permalink: edge: /hello/ --- <h1>Hello@Edge</h1> You’ll notice that for this file, we’ve added some to the liquid template. front matter data Specifically, we’ve defined a permalink for our page to respond to when running under the edge plugin. Eleventy won’t generate an page when building — this page will only be generated by invoking the serverless handler code. edge.html Making things Lambda-compatible Let’s now look at what’s going on with . This is only generated with the initial build, so we’re free to modify it — and we’ll definitely need to in order to support Lambda@Edge. serverless/edge/index.js First, we can remove the , as that’s only needed for the Netlify bundle process; require("./eleventy-bundler-modules.js") Next, we’ll need to get a reference to the current request path and query, as Eleventy needs that info to know what content to generate. With Netlify, you get these via , , and . With Lambda@Edge, we’ll be getting events generated by CloudFront on origin requests — see . We’ll also use querystring to handle parsing the query string. event.rawUrl event.multiValueQueryString event.queryStringParameters an example in the AWS docs Let’s update the code to this: const { request } = event.Records[0].cf; const path = ${request.uri}${request.uri.endsWith("/") ? "" : "/"}; const query = querystring.parse(request.querystring); let elev = new EleventyServerless("edge", { path, query, functionsDir: "./", }); Finally, we need to update the handler’s returned objects to match the format expected by . Lambda@Edge Update the success & error responses status and headers to: { status: "200", headers: { "cache-control": [ { key: "Cache-Control", value: "max-age=0", }, ], "content-type": [ { key: "Content-Type", value: "text/html; charset=UTF-8", }, ], }, body: ... } We’ve also added a header to configure how CloudFront caches the returned results. We can get more thoughtful about this when moving to production, but for now, we’ll go with no caching. Cache-Control One last thing: we’ll want to separate build dependencies from edge handling dependencies, so let’s create a separate file in , and install as a prod dependency. package.json serverless/edge @11ty/edge As our edge function grows, we’ll add more things here, like database clients. Here’s our full handler code, for reference: Testing it out locally Good, let’s test this out locally before we deploy! It should be pretty easy to simulate sending an event to our handler function. Let’s create a simple file: test.js const { handler } = require('.'); (async () => { const response = await handler({Records: [{cf: {request: {uri: "/hello/", querystring: ""}}}]}); console.log(response); })(); Running in the console, you should see: node test.js { status: '200', headers: { 'cache-control': [ [Object] ], 'content-type': [ [Object] ] }, body: '<h1>Hello</h1>' } Take a moment to celebrate! You’ve just triggered your first Eleventy build in a serverless function. 🎊 Deploying to AWS Things look good — it’s now time to deploy this to AWS. To handle the deployment, we’ll be using Serverless. No, not the Eleventy Serverless plugin, but Serverless, the “ ” command-line tool. zero-friction development tooling for auto-scaling apps on AWS Lambda If you don’t have it installed, . run npm install -g serverless Then create a file to configure the deploy: serverless/edge/serverless.yml This will instantiate a CloudFront distribution connected to the bucket you specified under . Any calls to URLs matching the pathPattern will be forwarded to the serverless handler instead of being routed to the bucket. events>cloudfront>origin Fun fact: Lambda@Edge functions log console output to their regional CloudWatch. That is, if a user in Germany accesses your pages via the edge at , you’ll see logs for that specific run under the region and nowhere else. In the yml config, we make sure to give our function proper permissions to write log groups anywhere. eu-frankfurt-1 eu-frankfurt-1 We should also add an exception for the config file to — we want this in the repo. .gitignore If you already have a CloudFront distribution that you want to connect to your new serverless function, check out the plugin. serverless-lambda-edge-pre-existing-cloudfront We’re ready to deploy! Make sure you export AWS credentials for an IAM user with proper permissions for deploying the entire stack. When moving to production, for security purposes, you should create a dedicated user with the minimal set of permissions required — however, I haven’t been able to find a comprehensive list of such permissions, so this will likely be a tedious trial-and-error process of figuring them out by trying deploys and seeing what fails. While still in development, an admin user might be easier to use. Run to deploy. If all goes well, in a couple of minutes, you should see the URL to your new CloudFront distribution! sls deploy --stage prod Your settings will need to propagate globally though, so it might take a few more minutes for everything to be ready. You can check the current status of your distribution under the AWS console dashboard. Once it’s done deploying, navigating to in a browser should display our “ ” HTML header from the template. CF_URL/hello Hello@Edge edge.liquid We did it! 🙌 Bonus: making it dynamic Now let’s quickly make our serverless function actually do something async. Let’s have it accept a URL parameter that’s the name of a Pokémon, and respond with an image of said cute beast. We’ll use to get the image. https://pokeapi.co/ We could do the async work outside of eleventy, and then inject some global data like this: const eleventy = new EleventyServerless('serverless', { path, query, functionsDir: './', config: (config) => { config.addGlobalData('data', yourData); }, }); Or, better yet, starting with Eleventy 2.0.0, we can use . async filters Let’s first update our template to include the new HTML we want: edge.liquid --- permalink: edge: - /hello/ - /hello/:name/ --- <h1>Hello@Edge \{\{ eleventy.serverless.path.name }}</h1> {% if eleventy.serverless.path.name %} <img src="\{\{ eleventy.serverless.path.name | escape | pokeimage }}" /> {% endif %} We’ve added a new that includes a name path parameter. That will become available in the data cascade as . permalink eleventy.serverless.path.name We’re transforming this param via two filters: and . Remember, user input should be treated as potentially malicious 😉. name escape pokeimage We need to define our filter. This is where the async magic happens. Add this to your file: pokeimage .eleventy.js eleventyConfig.addAsyncFilter("pokeimage", async function(name) { const results = await fetch(https://pokeapi.co/api/v2/pokemon/${name}); const json = await results.json(); return json.sprites.front_default; }); We’re relying on the node’s built-in API here — it’s a good thing we’ve set in our file. fetch runtime: nodejs18.x serverless.yml Let’s update our file to query the URL, and run again. test.js /hello/ditto/ node test.js In the console output, you should now see: { status: '200', headers: { 'cache-control': [ [Object] ], 'content-type': [ [Object] ] }, body: '<h1>Hello@Edge ditto</h1>\n' + '\n' + ' <img src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/132.png" />\n' } One last to get this deployed, and done! You’ve mastered setting up Eleventy Serverless on Lambda@Edge. sls deploy --stage prod All of Sandworm’s npm package report pages are generated using Eleventy Serverless and Lambda@Edge.