Incremental Static Regeneration (ISR) in Nitro on Cloudflare
I have been using Vercel and ISR at work for awhile now and it’s great. However, with recent user growth we have been looking to deploy on Cloudflare to save on significant bandwidth costs.
We use SolidStart and with the new beta it now builds on top of Nitro. Nitro comes with a bunch of deployment adapters for different platforms. The Vercel and Netlify adapters support Incremental Static Regeneration (ISR) out of the box as these platforms have this pattern built into their deployment systems.
Some resources on ISR:
Configuring Nitro for ISR
Create a new SolidStart (Nitro) project and configure the deployment for Cloudflare Pages. I wrote a short guide here, this will give us a good starting point.
Let’s also create a catch-all route to make testing easier. This just returns a simple page with the time it was generated at.
Remember to remove any exisiting catch-all routes like [...404].tsx
.
Now you can visit any arbitrary path and see when it was generated.
Route Rules
Using Nitro’s Route Rules we can configure how each route is rendered. We can specify if a route has ISR enabled or if it should be generated at build time.
Configuration based off Nuxt Vercel example.
This configuration is sufficient to work on Vercel, however if you tried to deploy this now on Cloudflare Pages you will notice ISR does not work.
Implementing ISR on Cloudflare Pages
To implement ISR we need to cache the Server-Side Rendered (SSR) pages for future requests, serve them from cache and then regenerate them in the background when stale. Cloudflare provides a few building blocks that helps us with this.
KV - a global, low-latency, key-value data storage is the store we will use to cache the generated pages. Here is pseudo code for how we will handle a fetch SSR request.
For context here are the Cloudflare specific functions:
env.PAGES_CACHE_KV
is a KV Binding that allow us to interact with the KV store.context.waitUntil
allows us to run async tasks in the background. This is important as we don’t want to block the request from returning. (Docs)
But how do we implement this in Nitro? We need to create a custom Nitro preset to implement the above logic.
Creating custom Nitro Preset
Nitro comes with a Cloudflare Pages adapter that we were using initially if you followed the guide. To achieve the above behavior we need to create a custom adapter that extends the built in pages adapter.
Create the custom entry file to implement the ISR logic, this is the request handler. This is where we will implement the pseudocode above, for now just copy cloudflare-pages
implementation.
Serves static asset requests and routes other (SSR) requests to our Nitro App.
nitroApp.localFetch
is effectively ourrender(request)
function in our pseudocode.
At this point you might see some TS errors due to missing isPublicAssetURL
and requestHasBody
functions. These are Rollup Virtual Modules that are injected by Nitro. Nitro generates the type definitions for these, but SolidStart doesn’t expose them. We can declare them to get rid of the errors.
To make use of this custom preset we just made we need to update Vite config to use it.
Run build locally and you should now see our custom preset being used, look out for the console message “Using Custom Preset!“. You should also still be able to deploy to Cloudflare Pages with no change in behavior.
Create KV binding and Pages Cache
For our pages cache, create a new KV Namespace using the Cloudflare Dashboard. This will be used to store the generated pages.
Bind the created KV Namespace so we can use it in our handler. Go to your Pages project, Settings -> Functions -> KV namespace bindings and create a new binding. Name the variable PAGES_CACHE_KV
.
Add KV binding to CFPagesEnv
interface to add the types, we will access the KV store from our env
.
We can now use this binding to store and retrieve generated pages. Here are some helper functions to interface with the store.
Note the expirationTtl
is set to 30 days, after 30 days the page will be removed from KV. This is so that we don’t keep pages forever. Since our cache key is unique for deployments, this cache could grow large over many deployments.
We will use the mtime
to determine if the page is stale.
Implementing ISR logic
We can now implement the ISR logic. To determine if a route has ISR enabled we need to get the route rules for the current path. We can do this by using the getRouteRulesForPath
function from Nitro.
Combining this with the pseudocode previously shown we get our working custom preset. I also added an age
header to the response to help with debugging.
Redeploy your site and it should now show the x-nitro-isr
and age
header with the correct values. You can also check the KV store to see the generated pages.
Here is my example using SolidStart.
Deployed here solidstart-cf-rendering.floppydict.com/isr/123
Next Steps
There are a few things that can be improved, particularly around the revalidation logic.
There could be duplicate revalidations if multiple requests come in for the same page while it is being revalidated. Durable Objects could be used to coordinate this so only one revalidation happens at a time.
There is also no error handling during revalidation. If the SSR errors due to data fetching errors, the stale page will be served indefinitely. We could add a retry mechanism to improve this.
We have yet to implement the On-Demand Revalidation. To do this we could specify a secret token in the request header and check for it in the fetch
function when processing ISR routes. Similar to Vercel x-prerender-revalidate
header.
To revalidate tags or multiple pages we could store tags in the KV metadata. Handle revalidation requests by listing KV objects and filter matching tags/routes, but having a more robust revalidation mechanism using Durable Objects or queues to support this would be recommended at this point.
We are not taking advantage of Cloudflare’s CDN to cache the generated pages. This could be done by setting the Cache-Control
header or using the Cache API to cache the response.