Recreating Search with Eleventy v3 and Netlify

For reasons that are now lost to the frustrations of the process1, I decided to upgrade this site to version 3 of Eleventy. A major change in version 3 is that there is no longer an official plugin to integrate with Netlify's Functions. That meant part of my upgrade process was going to be a little more complicated than just swapping out some require() functions with import statements: I needed to rewrite the basics of what that plugin did. I use Netlify Functions to allow searching this blog without the use of client-side JavaScript. If you've previously read my article about adding search to Eleventy, this is the update to that. I'm going to keep this mostly to the changes I've made—you can read the other post for more details and context.

So without further ado, let's jump in to the changes!

JSON Data

The generated JSON file that is the 11ty collection that the search function uses is mostly the same. I changed the permalink location so the file is saved straight into the netlify/functions/search/src/ folder. Since there's no 3rd-party plugin managing the functions folder for me, I'm not worried about another process touching the folder.

// _generated-serverless-collections.11ty.js
...
-     permalink: "./_generated-serverless-collections.json",
+     permalink: "./netlify/functions/search/src/_generated-serverless-collections.json",

The Removed 11ty Plugin

The old official 11ty serverless plugin would set up the files and folders needed for Netlify. Without that, I need to do it myself. This took a while as I learned the ins-and-outs of what Netlify Functions need uploaded in conjunction with 11ty's API for building pages on the fly. In the end, I've landed on my own little plugin to handle some of those pieces for me, but since it isn't a one-size-fits-all situation, I'm not auto-generating the main index.js function file or anything.

My Function Plugin

Below is my functions plugin. Since this isn't a public package, I've hard-coded default options that work for my setup—the files I'm copying and where they live. This plugin just (optionally) cleans the function folder and then copies the required scripts and files needed to do an 11ty build to that same folder.

I think it's important to note that since the serverless function builds a search results page for my site, it needs to look like the rest of the site. That means copying over the 11ty configuration file (.eleventy.js for me), any dependencies of that config (plugins, etc) so it can run, and the layout files. Now, I could have a custom 11ty configuration that is significantly pared down from my main site's. I don't have images on the results page, so pulling in my image shortcode (which includes the Sharp package) is overkill. However, it is nice to not need to keep two configs rendering the same way when something changes.

config/functions.js
// config/functions.js
// (config/ is where I keep extra 11ty configuration, plugins, shortcodes, etc)

import { cp, rm } from "node:fs/promises";
import { join } from "node:path";

/**
 * @typedef {object} PluginOptions
 * @prop {boolean} clean
 * @prop {string} basePath
 * @prop {string[]} paths
 */
/** @type {PluginOptions} */
const defaultOptions = {
	clean: false,
	basePath: `${process.env.PWD}/netlify/functions/search/`,
	paths: ["config", ".eleventy.js", "src/_includes", "src/search.11ty.js"],
};

/**
 * Optionally clean the functions folder and then copy the required files in to it.
 * @param {import("@11ty/eleventy").UserConfig} _
 * @param {PluginOptions=} options
 */
export default async function (_, options) {
	options = { ...defaultOptions, ...options };

	const basePath = options.basePath;
	const paths = options.paths;

	const clean = async () => {
		/** @type {Promise<void>[]} */
		const promises = [];
		const targetPaths = paths.map(path => join(basePath, path));

		promises.push(rm(join(basePath, "src"), { recursive: true, force: true }));

		targetPaths.forEach(p =>
			promises.push(rm(p, { recursive: true, force: true }))
		);

		await Promise.all(promises);
	};

	const copyFiles = async () => {
		/** @type {Promise<void>[]} */
		const promises = [];

		paths.forEach(p => {
			promises.push(cp(p, join(basePath, p), { recursive: true }));
		});

		await Promise.all(promises);
	};

	if (options.clean) {
		await clean();
	}

	await copyFiles();
}

You may notice the clean option is false by default. I generally won't need to remove files, but if I rename or remove a file, like a layout, this gives me a way to manage that. In my package.json file I have a script that runs the plugin function in isolation with clean set to true.

node -e 'const { default: func } = await import('./config/functions.js'); await func(null, {clean: true})

Eleventy Configuration

To use the plugin, it needs to be added to the 11ty configuration file, but not loaded during a serverless call. That'd probably open a recursive worm-hole or something.

// .eleventy.js - snippet

import functions from "./config/functions.js";
...
export default async function (eleventyConfig) {
	// This is set in the function code itself: netlify/functions/search/index.js
	const isServerless = process.env.NODE_ENV === "serverless";

	// Most of the netlify/functions/search files are git ignored, but Eleventy needs to notice them
	eleventyConfig.setUseGitIgnore(false);

	if (!isServerless) {
		// Can add a secondary argument with options (clean, function dir, paths to copy)
		eleventyConfig.addPlugin(functions);
	}
...

Note the override of Git ignore with setUseGitIgnore(false). I don't want to version control the generated & copied files, but I do want them available to the build processes. Here's part of my .gitignore

// .gitignore

# Netlify serverless
.netlify
netlify/functions/search/*
!netlify/functions/search/index.js
!netlify/functions/search/package.json

The Serverless Function

This file is quite a bit different. No longer is EleventyServerless() used; instead, it's the programmatic API.

The serverless function takes a Request and context parameter (Netlify Functions documentation). It will return a Response to send to the client.

I'll walk through the file I have in sections to keep it easy to follow.

// netlify/functions/search/index.js - snippet

import Eleventy from "@11ty/eleventy";
import precompiledCollections from "./src/_generated-serverless-collections.json" with {type: "json"};
import setup from "./.eleventy.js";

process.env.NODE_ENV = "serverless";

This is pretty straight forward: import Eleventy, the generated JSON collection, and the configuration file. Then set a Node environment variable (a little odd to do in code, but no harm to it). Remember, the function setup plugin won't run in serverless environments.

Wait, importing the configuration file? It's to do with how Netlify bundles code. I'll get to that.

// netlify/functions/search/index.js - snippet

/**
 * @param {Request} request
 * @param {*} _ context object https://docs.netlify.com/functions/api/#netlify-specific-context-object
 */
export default async function (request, _) {
  try {
    const url = new URL(request.url);
    const query = Object.fromEntries(url.searchParams.entries());

    let elev = new Eleventy(
      "./netlify/functions/search/src/search.11ty.js",
      null,
      {
        configPath: null,
        config: (elevConfig) => {
          // Do config like this so that imports get included by Netlify and
          // 11ty 'input' is set to above, not returned value from config
          setup(elevConfig);

          elevConfig.addGlobalData("serverless", {
            query,
            blog: precompiledCollections.blog,
          });
        },
      }
    );

This is the meat of the function. The context isn't needed so it is referenced as _.

First, get the query portion of the URL so it can be passed to the search template. Then create a new Eleventy instance.

The Eleventy constructor takes a few optional parameters: input, output, options, and a template configuration (undocumented). Since this isn't writing to the file system, but just returning the rendered output to the client, all that's needed is an input template and options. The null is for the unused output.

Here's the tricky part, and this is mostly due to how Netlify bundles and deploys function code. Rather than pointing to the .eleventy.js configuration file, the configPath is set to null and the imported configuration function is called within the config option callback. By doing an import Netlify will pick up the file and all its referenced import dependencies to be bundled. Otherwise I would need to manually include the entire tree in to the bundle manually. It's simple to just import as setup and call that prior to modifying the rest of the configuration.

The only real work here then is adding the "serverless" global data so the search template file can access it.

// netlify/functions/search/index.js - snippet

    const json = await elev.toJSON();

    return new Response(json[0].content, {
      status: 200,
      headers: { "Content-Type": "text/html" },
    });

Now that Eleventy is set up, the template is parsed into 11ty's JSON output and the rendered HTML content is returned!

The last piece is just to close out the try/catch block and error handle.

// netlify/functions/search/index.js - snippet

  } catch (err) {
    console.log(err);

    let url = new URL(request.url);
    url.pathname = '404';

    return Response.redirect(url, 307);
  }
}

Search Template

The search results page—the 11ty template file itself—has more or less remained the same. I've adjusted bits and pieces since I first created it, but as you'll want it custom to your site anyway, the specifics are left as an exercise for the reader. Check out the first post about the search functionality for the main points.

Final Notes

There are a few more pieces of the puzzle to get this working with Netlify.

First, since I'm using JS modules and don't want to rename all of my copied files to *.mjs, I have a bare package.json file in /netlify/functions/search with "type": "module"` set.

Second, I have a netlify.toml file in my project which handles the redirect to the serverless function and includes any files that aren't part of the dependency tree.

// netlify.toml

[[redirects]]
from="/search/"
to="/.netlify/functions/search/"
status=200
force=true

[functions]
node_bundler = "esbuild"
included_files = ["netlify/functions/search/package.json", "netlify/functions/search/src/**"]

That included_files line is definitely a gotcha, so be sure add items that aren't referenced by import statements. Magic string filepaths for 11ty config aren't enough!

I think that covers it. Hopefully, this is enough for you to implement a search feature or other useful function through Netlify's serverless offerings. Though I have a feeling I'll eventually have an update to this where I do it on my own infrastructure or in a less vendor-lock-in way! 😅

Reach out if you have any questions, I'd be happy to help clarify any issues.


  1. Most of the version upgrade went fine actually, I think the main frustrations were around more custom code like this serverless plugin. ↩︎