Optimistic Routing with Netlify Rewrites

Phil Hawksworth wrote a great article on building “static-first” sites that mix pre-rendered pages with server-rendered content, all using user-submitted data.

I’m using a similar approach for a side project and ran into a confusing little hiccup when deploying to Netlify. I figured I’d document the behavior I encountered along with a workaround.

💡 tl;dr — If you’re using Netlify rewrite rules to route requests to a serverless function, any query string parameters defined in your to value are lost. You may be able to recover the information you need by using the originally requested URL path instead.

Static-first sites

You really should read Phil’s article (and check out the accompanying site Virtual Popsicle), but the basic idea behind a static-first site is this:

  1. A user submits a new entry for the site — ex., they create a new popsicle
  2. The submission is POST-ed to a serverless function that stores the new data in a remote database and triggers a Netlify deployment using a build hook. In the case of Virtual Popsicle, the function also gives the user a friendly URL for their newly created popsicle page to send to the intended recipient — ex., https://vlolly.net/popsicle/trrQq6Oag
  3. During deployment, Eleventy—or whichever static generator is being used to build the site—fetches all user-supplied entries from the database and builds them into individual static pages.

This is a great use of Eleventy’s JavaScript data files and pagination features, bringing the speed and reliability of statically generated pages to sites that largely feature user-submitted content.

Mind the gap

Netlify deployments move pretty darn quickly but they aren’t instantaneous. This leaves us in a bit of a pickle: there’s a window of time between the moment our user submits a new entry and when its corresponding page is finally built, deployed, and ready to be browsed. During this gap, attempting to visit /popsicle/trrQq6Oag will result in a 404 error.

This might be acceptable in cases where site generation runs in the 10- to 20-second range, but as the number of user submissions grows (or if we switch from on-demand to scheduled deployments) it becomes less and less tenable.

Phil offers a rather elegant solution for handling requests during this period of time:

Until the site generation is complete, any new lollipops [aka “popsicles” 😉] will not be available as static pages. Unsuccessful requests for lollipop pages fall back to a page which dynamically generates the lollipop page by querying the database API on the fly.

This works by using a Netlify redirect rule that serves as a catch-all for any requests for popsicle pages that haven’t been built, redirecting them to another serverless function showLolly.js:

  from = "/popsicle/*"
  to = "/.netlify/functions/showLolly?id=:splat&lang=us"
  status = 302

The function uses the id query string parameter defined by the redirect rule to fetch the appropriate entry from the database, build HTML that’s identical to our static pre-rendered pages, and return that to the visitor’s browser. Eventually, the site will rebuild and deploy, and the popsicle page will be served statically.


Redirect vs. rewrite

Redirecting URL misses to a serverless function that temporarily supplements static, pre-generated pages is a really clever approach. There’s one tiny thing I wanted to change for the implementation in my project.

Since Virtual Popsicle uses a 302 redirect for its fallback popsicle rule, anyone who opens a not-yet-static link will find that their browsers are redirected to the less friendly serverless function URL path /.netlify/functions/showLolly?id=trrQq6Oag:

Browser showing full serverless function URL instead of the friendly version

This is expected based on the configuration and it’s hardly what I’d call a deal-breaker.

Still, I want fallback handling to be invisible to visitors of my project if at all possible. That is, visiting the soon-to-be-static URL will ideally display the dynamic server-rendered page without exposing the serverless function URL.

Netlify supports this precise behavior with “rewrites,” directives that are nearly identical to redirect rules but which use a 200 status instead:

When you assign an HTTP status code of 200 to a redirect rule, it becomes a rewrite. This means that the URL in the visitor’s address bar remains the same, while Netlify’s servers fetch the new location behind the scenes.

It might look something like this for Virtual Popsicle:

  from = "/popsicle/*"
  to = "/.netlify/functions/showLolly?id=:splat&lang=us"
  status = 200

I made the switch from 302 to 200 in my project‘s redirect rules, deployed to production, and then things went haywire...

The case of the missing query string parameters

Let’s rewind a bit and take a quick peek at my original redirect rule and serverless function:

  from = "/fruit/:slug"
  to = "/.netlify/functions/showFruit?slug=:slug"
  status = 302
let handler = async (event) =>
	let slug = event.queryStringParameters.slug;

	if( slug )
		// Do the page rendering...
		response.statusCode = 400;
		response.body = "Missing required parameter 'slug'";

	return response.body;

Like Phil’s popsicle site, the redirect rule creates a query string parameter slug using the :slug component of the original URL path as its value. This is passed off to the showFruit.js serverless function which checks that the query string parameter has been set; if not, it returns a brief error message explaining that the request wasn’t properly formed.

This all worked great. When I switched to a rewrite rule, however:

  from = "/fruit/:slug"
  to = "/.netlify/functions/showFruit?slug=:slug"
  status = 200

and deployed the change to Netlify, my fallback started giving me an unexpected error message:

Missing required parameter 'slug'

even in cases where redirections had been working previously. More bizarrely, I couldn’t reproduce the issue using Netlify Dev, a command-line tool for emulating Netlify’s platform when working locally.

I added some real galaxy-brain debug logging to the function to take a closer look at what might be happening to the slug parameter:


Lo and behold, the function log in Netlify’s dashboard revealed that the query string parameters object was flat out empty:

1:41:59 PM: 2020-12-29T20:41:59.945Z	INFO	{}

But why? It turns out this is a known issue with Netlify’s rewrites behaving differently from redirects.

A known issue and a workaround

I did some DuckDuckGo-ing and found a post from last September in Netlify’s community forums describing exactly the behavior I was seeing: query string parameters defined in the to key of a 200 redirect rule are stripped somewhere along the journey from the original request to our serverless function.

The original poster followed up with a workaround that uses the query string parameter if present, and otherwise falls back to parsing the path of the requested URL:

let slug = event.queryStringParameters.slug
	? event.queryStringParameters.slug
	: event.path.split( "/" )[2];

It works beautifully for both redirects and rewrites, whether the script is running locally or in production.

⚠️ — It’s worth noting that this only helps recover values the rule plucked from our original path, like :slug in /fruit/:slug. If you were to append new information that does not exist in the path — ex., lang=us — using event.path would not help.

Development vs. production

There’s an open issue for the discrepancy in behavior between Netlify Dev and production in this scenario, but it sounds like the change from Netlify will be to make Dev work more like production than vice versa.

The good news is with the workaround in place, serverless functions can handle either behavior seamlessly.