Netlify Developers

Compose Conference 2024 is coming!  Learn more

Partial Prerendering without a framework

by Matt Biilmann

Partial Prerendering might sound complex, but with Netlify’s Platform Primitives it’s simple to implement in plain static HTML without any framework or even build system, with the help of Netlify Edge Functions.

TL;DR

This guide explains the concept of PPR (Partial Prerendering) and provides a step-by-step guide you can follow to use this rendering pattern on a site without a framework.

What is Partial Prerendering?

Next.js implements experimental support for a concept called Partial Prerendering that aims to make pages with mostly static content and a few dynamic parts get useful content in front of the user faster.

The idea is that a static page is served from the edge as fast as possible, with areas of the page left as placeholders for dynamic content. The server will start fetching the dynamic content in parallel with the response stream, and then append the dynamic responses to the response stream from the static shell at the end, with a few script tags that inserts the content into the right empty slots.

Implementing Partial Prerendering in plain HTML

This example will implement support for Partial Prerendering in a very simple example, with a plain HTML document that has a slot for a dynamically generated time string, and a Netlify Function that returns the current server time. We’ll use an Edge Function to tie it all together.

The HTML

We’ll start with a simple HTML page with a placeholder for some dynamic content in a public/index.html:

<!DOCTYPE html>
<html>
<head>
<title>PPR</title>
</head>
<body>
<p>Today is: <span data-ppr="/partial/time">Loading...</span></p>
</body>
</html>

Here we use a data attribute to indicate that the content of an element should be loaded from a dynamic path.

We’ll also add a simple netlify.toml file to make sure we publish the right the folder:

[build]
publish = "public"

The Dynamic Content

We’ll provide a simple Netlify Function as the origin for /partial/time by writing the following to netlify/functions/time.ts

import type { Context } from "@netlify/functions";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default async (req: Request, context: Context) => {
// Simulate a slow API
await sleep(2000);
return new Response(`${new Date()}`, {
headers: {
"content-type": "text/html",
},
});
};
export const config = {
path: "/partial/time",
};

This is a simple time function that returns the current time as a string. To better show the Partial Prerendering in action, I’ve inserted a 2 second delay on each response.

Tying it all together

To make all of this work, we’ll implement Partial Prerendering in an edge function. This uses HTMLRewriter to find all elements that have a data-ppr attribute and inject a script that replaces each one with the dynamic content.

Add this to netlify/edge-functions/ppr.ts:

import type { Config, Context } from "@netlify/edge-functions";
import { HTMLRewriter } from "https://ghuc.cc/worker-tools/html-rewriter/index.ts";
type Partial = {
id: number;
resp: Promise<Response>;
};
export default async (request: Request, context: Context) => {
let id = 0;
const partials: Partial[] = [];
const response = await context.next();
return new HTMLRewriter()
.on("[data-ppr]", {
element(element) {
// For each element with a `data-ppr` attribute, load the dynamic content from the URL and push the response into an array
const src = element.getAttribute("data-ppr");
const url = src.match(/^https?:/) ? src : new URL(src, request.url);
partials.push({
id: ++id,
resp: fetch(url.toString()),
});
element.removeAttribute("data-ppr");
element.setAttribute("id", `ppr-id-${id}`);
},
})
.on("body", {
async element(element) {
element.onEndTag(async (tag) => {
for (const partial of partials) {
const resp = await partial.resp;
const text = await resp.text();
// For each PPR element, inject a script tag that replaces its content with the dynamic data
tag.after(`<script>document.getElementById('ppr-id-${partial.id}').innerHTML = \`${text}\`;</script>`, {
html: true,
});
}
});
},
})
.transform(response);
};
export const config: Config = {
path: "/",
};

Resources

You should see the loading shell almost instantly, and then the time will pop in after 2 seconds, all over one single HTTP request.

To get your own copy to play with, just click the button below

Deploy to Netlify

Or if you’re following along locally, deploy it all by running:

Terminal window
netlify deploy -p --build

Is this useful?

It’s still TBD how much this technique really adds, versus a pure client side approach where script tags at the very top of the static payload start fetching the dynamic content, since with HTTP2 they will piggyback over the same connection to the CDN edge node and there’ll be zero overhead - however minimal - from any edge logic.

On slower networks the Partial Prerendering approach will most likely pay off, while on faster connections it’s likely that a client side fetch request will be faster.

In any case, Netlify’s Platform Primitives make it easy to implement any of these approaches, and they can be combined with Netlify’s Fine Grained cache control to make the experiences even faster.