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.

Terminal window
pnpm create solid@latest

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.

src/routes/[...all].tsx
import { useParams } from "@solidjs/router"
import { createSignal } from "solid-js"
export default function CatchAll() {
const params = useParams()
return (
<main>
<h1>Hello World</h1>
<div>
Path <code>/{params.all}</code>
</div>
<div>Generated at {new Date().toISOString()}</div>
</main>
)
}

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.

vite.config.ts
export default defineConfig({
start: {
server: {
routeRules: {
// revalidated every 15 seconds, in the background
"/isr/**": {
isr: 15,
},
//always dynamically generated
"/dynamic": {
isr: false,
},
// generated on demand then cached permanently
"/static": {
isr: true,
},
// page generated at build time and cached permanently
"/prerendered": {
prerender: true,
},
},
},
},
})

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.

// Pseudocode
async function fetch(request: Request, env: Env, context: EventContext) {
if (hasIsrRouteRule(request)) {
const page = await env.PAGES_CACHE_KV.get(cacheKey)
if (page) {
if (isExpired(page)) {
// Stale page
// Revalidate page in the background
context.waitUntil(revalidate)
// Return stale page
return new Response(page.value, {
headers: {
"x-nitro-isr": "REVALIDATE",
},
})
}
// Return cached page
return new Response(page.value, {
headers: {
"x-nitro-isr": "HIT",
},
})
}
}
let res = render(request)
if (hasIsrRouteRule(request)) {
res.headers.set("x-nitro-isr", "MISS")
// Cache page in background
context.waitUntil(() => {
env.PAGES_CACHE_KV.put(cacheKey, response.body)
})
}
// Return page
return res
}

For context here are the Cloudflare specific functions:

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 a `preset` folder with the following files
|- preset/
|- nitro.config.ts
|- entry.ts
|- vite.config.ts
nitro.config.ts
import type { NitroPreset } from "nitropack"
import { fileURLToPath } from "node:url"
export default <NitroPreset>{
extends: "cloudflare-pages", // Extend existing cloudflage-pages preset
entry: fileURLToPath(new URL("./entry.ts", import.meta.url)),
hooks: {
compiled() {
console.info("Using Custom Preset!")
},
},
}

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.

entry.ts
19 collapsed lines
// install `@cloudflare/workers-types`
import type { Request as CFRequest, EventContext } from "@cloudflare/workers-types"
import type { NitroRouteRules } from "nitropack"
import { useNitroApp } from "nitropack/dist/runtime/app"
const nitroApp = useNitroApp()
/**
* Reference: https://developers.cloudflare.com/workers/runtime-apis/fetch-event/#parameters
*/
interface CFPagesEnv {
ASSETS: { fetch: (request: CFRequest) => Promise<Response> }
CF_PAGES: "1"
CF_PAGES_BRANCH: string
CF_PAGES_COMMIT_SHA: string
CF_PAGES_URL: string
[key: string]: any
}
export default {
async fetch(request: CFRequest, env: CFPagesEnv, context: EventContext<CFPagesEnv, string, any>) {
const url = new URL(request.url)
if (isPublicAssetURL(url.pathname)) {
return env.ASSETS.fetch(request)
}
let body
if (requestHasBody(request as unknown as Request)) {
body = Buffer.from(await request.arrayBuffer())
}
// Expose latest env to the global context
globalThis.__env__ = env
3 collapsed lines
return nitroApp.localFetch(url.pathname + url.search, {
context: {
cf: request.cf,
waitUntil: (promise) => context.waitUntil(promise),
cloudflare: {
13 collapsed lines
request,
env,
context,
},
},
host: url.hostname,
protocol: url.protocol,
method: request.method,
headers: request.headers as unknown as Headers,
body,
})
},
}

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.

// @ts-expect-error - Rollup Virtual Modules
import { requestHasBody } from "#internal/nitro/utils"
declare function requestHasBody(request: globalThis.Request): boolean
// @ts-expect-error - Rollup Virtual Modules
import { isPublicAssetURL } from "#internal/nitro/virtual/public-assets"
declare function isPublicAssetURL(id: string): boolean

To make use of this custom preset we just made we need to update Vite config to use it.

vite.config.ts
export default defineConfig({
start: {
server: {
preset: "./preset", // relative path to the custom preset
}
}
})
// If you explicitly define the preset in your build script
// package.json
{
"scripts": {
"build": "vinxi build --preset ./preset"
},
}

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.

preset/entry.ts
import { KVNamespace } from "@cloudflare/workers-types"
interface CFPagesEnv {
...
// Extend env with bindings for types
PAGES_CACHE_KV: KVNamespace
}

We can now use this binding to store and retrieve generated pages. Here are some helper functions to interface with the store.

preset/entry.ts
11 collapsed lines
export type GeneratedPageMetadata = {
/**
* Modified timestamp in seconds
*/
mtime: number
isr: number | boolean
}
function getIsrCacheKey(request: CFRequest, env: CFPagesEnv) {
return request.url
}
export async function getIsrPage(request: CFRequest, env: CFPagesEnv) {
const cacheKey = getIsrCacheKey(request, env)
return env.PAGES_CACHE_KV.getWithMetadata<GeneratedPageMetadata>(cacheKey)
}
export function storeIsrPage(
request: CFRequest,
env: CFPagesEnv,
routeRules: Omit<NitroRouteRules, "isr"> & { isr: number | true },
response: Response
) {
const cacheKey = getIsrCacheKey(request, env)
return env.PAGES_CACHE_KV.put(cacheKey, response.body as any, {
// Expiration TTL must be at least 60 seconds
expirationTtl: 60 * 60 * 24 * 30, // 30 days
metadata: {
mtime: Date.now() / 1000,
isr: routeRules.isr,
} satisfies GeneratedPageMetadata,
})
}

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.

preset/entry.ts
import { getRouteRulesForPath } from "nitropack/dist/runtime/route-rules"
/**
* Narrow down the type of route rules to include `isr` property
*/
export function isIsrRoute(
routeRules: NitroRouteRules
): routeRules is Omit<NitroRouteRules, "isr"> & { isr: number | true } {
return typeof routeRules.isr === "number" || routeRules.isr === true
}
const routeRules = getRouteRulesForPath(url.pathname)
if (isIsrRoute(routeRules)) {
// Handle ISR
}

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.

async function fetch(request: CFRequest, env: CFPagesEnv, context: EventContext<CFPagesEnv, string, any>) {
...
const routeRules = getRouteRulesForPath(url.pathname)
if (isIsrRoute(routeRules)) {
const page = await getIsrPage(request, env)
if (page.value && page.metadata) {
const age = Math.ceil(Date.now() / 1000 - page.metadata.mtime)
// Stale page
if (typeof page.metadata.isr === "number" && age >= page.metadata.isr) {
const revalidate = (async () => {
const res = await nitroApp.localFetch(...)
storeIsrPage(request, env, routeRules, res.clone())
})()
// Revalidate in the background
context.waitUntil(revalidate)
return new Response(page.value, {
headers: {
"content-type": "text/html",
age: age.toString(),
"x-nitro-isr": "REVALIDATE",
},
})
}
return new Response(page.value, {
headers: {
"content-type": "text/html",
age: age.toString(),
"x-nitro-isr": "HIT",
},
})
}
}
let res = await nitroApp.localFetch(...)
if (isIsrRoute(routeRules) && res.body !== null) {
res = new Response(res.body, res)
res.headers.set("age", "0")
res.headers.set("x-nitro-isr", "MISS")
context.waitUntil(storeIsrPage(request, env, routeRules, res.clone()))
}
return res
}

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.

const revalidate = (async () => {
const res = await nitroApp.localFetch(...)
storeIsrPage(request, env, routeRules, res.clone())
})()
// Revalidate in the background
context.waitUntil(revalidate)

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.

if (isIsrRoute(routeRules)) {
if (request.headers.get("x-nitro-revalidate") === process.env.ISR_REVALIDATE_TOKEN) {
// Revalidate in the background
context.waitUntil(revalidate)
}
}

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.