The Role of Hydration in Pre-rendered Pages

Hydration refers to the step where server-generated markup becomes fully interactive. Producing the HTML for a button doesn't automatically give it behavior (unless you're meticulously building progressive enhancements from plain HTML forms, which demands significant restraint and doesn't cover every scenario). In a library such as React, hydration involves starting at the root node, walking the element tree, and verifying that the rendered output aligns with expectations. Along the way, event handlers and stateful logic get attached. From a visual standpoint, the page appears complete from the pre-render, but in terms of responsiveness, you're still nearly as limited as you'd be with full client-side rendering. This approach—"full hydration"—remains the standard in many tools.

Hydrating Selectively

There's room for improvement, though. When building sites, particularly static ones, you frequently notice sections that are purely decorative and never change. Running a diffing algorithm over those subtrees is wasted effort. Consider a typical site header:

export const SiteHeader = title => <h1>{title}</h1> 

Most likely, we never alter anything about that header after its initial output, so skipping hydration there saves time. Furthermore, in a standard isomorphic setup, that component still ends up in the client bundle even if it's unused on the client. While this example is trivial, imagine more substantial, intricate components with the same constraint. If it's not needed, it shouldn't ship.

Tagging Components for Hydration

Given that we're not hydrating the entire tree, we need to target specific subtrees. What's the criterion for deciding which parts require hydration? A detailed blog post offers excellent guidance on this topic, and I'll borrow heavily from it.

The key idea is to insert a script tag—which doesn't render visibly and won't disrupt the DOM—to act as a marker for the component root. Here's the pattern:

<script type="application/hydration-marker" data-id="1"></script>
<div><!-- Component markup to hydrate -->
 ...
</div>

We then locate these markers in the DOM and invoke hydrate on the element that follows each one.

Successful hydration requires three pieces of information:

  1. The DOM node targeted for hydration
  2. The component type to mount
  3. The props to pass to that component

The first item is straightforward: it's the sibling right after the marker. But how do we determine items 2 and 3?

We establish a registry system. Each marker gets a unique id, and from that id we can retrieve the component and its associated props.

We introduce a WithHydration higher-order component:

//templates/components/_hydrator.js
export function WithHydration(Component, path){
	return props => html`
		<>
			<script type="application/hydration-marker" data-id="${storeHydrationData(Component, props, path)}" />
			<${Component} ...${props}>
		</>`;
}

It simply wraps the target component with the marker tag. Next, we need the registry and a function called storeHydrationData.

//templates/components/_hydrator.js
const hydrationData = {};
const componentPaths = {};

let id = 0;

export function storeHydrationData(component, props, path){
	const componentName = component.displayName ?? component.name;
	hydrationData[id] = {
		props,
		componentName 
	};
	componentPaths[componentName] = {
		path,
		exportName: component.name
	};
	return id++;
}

This segment functions as a singleton that accumulates all hydration metadata. Each time new data is registered, the id increments to ensure uniqueness. I also stash some entries in a separate store called componentPaths. This is a deliberate choice to sidestep bundling complexities for now. Instead, we track where each component originates so we can load the correct script and its corresponding export. That's why the path argument exists—it's not the most elegant API to require the component's script location, but it's essential for maintaining a reference.

Structuring Hydration Data

We now have a collection of required scripts. The next step is communicating this structure to the page. This is handled by the HydrationData component:

//templates\preact\components\_hydrator.js
export function HydrationData(){
	return html`<script type="application/hydration-data" dangerouslySetInnerHTML=${{ __html: JSON.stringify({
		componentPaths,
		hydrationData
	})}} />`;
}

This component plugs into the layout. Its sole purpose is to persist a JSON-serialized list of components along with the data needed to hydrate them.

Script Output Management

The initial SSG version ignored scripts entirely. Even manually written script tags wouldn't function because only HTML is emitted. That gap needs closing. Ideally, we'd output only the scripts we actually need, not every script composing the site. To achieve this, we track which scripts are in active use through a compact module:

//templates/components/_script-manager.js
export const scripts = new Set();

export function addScript(path){
	scripts.add(path);
}
export function getScripts(){
	return [...scripts];
}

This is another singleton store. We can leverage it when generating hydration data, since that script is inherently necessary for hydration:

//templates/components/_hydrator.js
export function storeHydrationData(component, props, path){
	const componentName = component.displayName ?? component.name;
	hydrationData[id] = {
		props,
		componentName 
	};
	componentPaths[componentName] = {
		path,
		exportName: component.name
	};
        addScript(path); //here
	return id++;
}

Allowing direct script inclusion by users also seems valuable:

//templates/components/_script.js
import { html } from "htm/preact/index.mjs";
import { addScript } from "./_script-manager.js";

export function Script({ src }){
	addScript(src);
	return html`<script src=${src} type="module"></script>`
}

Usage would look like <${Script} src="./my-script.js" />. It behaves like a normal script tag but also registers itself for output.

Now we revisit htm-preact-renderer.js and extend it to copy marked scripts:

//renderers/htm-preact-render.js
import { getScripts } from "../templates/preact/components/_script-manager.js";

//at the very end after html files have been written
//export scripts in use
for(const script of getScripts()){
	const outputPath = fileURLToPath(new URL(script, outputUrl));
	await ensure(outputPath)
		.then(() => fs.copyFile(fileURLToPath(new URL(script, templatesUrl)), outputPath));
}

We fetch the registered scripts and copy them to the output directory. I initially experimented with Promise.all, but it caused race conditions during directory creation with the ensure calls.

We still need the Preact runtime scripts, so those get added too:

//renders/htm-preact-render.js
const preactScripts = ["./node_modules/preact/dist/preact.mjs", "./node_modules/preact/hooks/dist/hooks.mjs", "./node_modules/htm/preact/dist/index.mjs"];
for(const script of preactScripts){
	const outputPath = fileURLToPath(new URL(script, outputUrl));
	await ensure(outputPath)
			.then(() => fs.copyFile(fileURLToPath(new URL(script, pathToFileURL(process.cwd() + "/"))), fileURLToPath(new URL(script, outputUrl))));
};

This approach isn't ideal regarding exports—I'm simply hardcoding the ones I know are referenced. If no components require hydration, Preact might be unnecessary, or at least not all of its modules. Determining that dynamically isn't trivial, so I'll leave it as is. Dynamic imports ensure we won't incur a runtime overhead regardless.

Handling Isomorphic Imports

You're likely anticipating the next hurdle. We have all scripts at our disposal and a client-side list detailing what's needed to hydrate each component: its script path, export name, and props. The solution seems straightforward—just stitch it together. However, a significant obstacle looms: isomorphic imports. On the Node side, import { html } from "htm/preact/index.mjs"; works seamlessly. While we need to append the extension for ESM imports, that alone doesn't make the import isomorphic because Node still resolves the bare specifier. What does a path like htm mean in the browser? It's unsupported, and you'll encounter an error.

I touched on this in Best Practice Tips for Writing Your JS Modules. You might attempt rewriting the import as import { html } from "../../../node_modules/htm/preact/index.mjs";, but that fails since inside index.mjs there's a bare import for preact—and we didn't author that file.

Screenshot 2020-12-06 142756

Uncaught (in promise) TypeError: Failed to resolve module specifier "preact". Relative references must start with either "/", "./" or "../".

At this juncture, introducing a bundler seems necessary just to resolve this minor snag. It's frustrating, and I consider it a deficiency in the ecosystem. Even forward-thinking libraries like htm are affected.

So, what paths can we take?

  1. Adopt a bundler
  2. Import Maps

Option 1 is something I'd rather defer to preserve the project's simplicity. Option 2 lacks browser support… or does it?

Technically, no browser ships import maps (though Chrome supports them behind a flag as of writing), but we can emulate the concept. A service worker might redirect import fetches, yet bare imports are syntactically invalid, necessitating a script rewrite. This rewriting could occur in a service worker, but since we have access to script source during rendering, it's more efficient to handle it then. Let me revise what we've done in the renderer to incorporate this. Here's the complete implementation:

//renders/htm-preact-renderer.js
import { promises as fs } from "fs";
import { fileURLToPath, pathToFileURL } from "url";
import yargs from "yargs";
import render from "preact-render-to-string";
import { getScripts } from "../templates/preact/components/_script-manager.js";

import { ensure, readJson } from "../utilities/utils.js";

const args = yargs(process.argv.slice(2)).argv;
const templatesUrl = pathToFileURL(`${process.cwd()}/${args.t ?? "./templates/"}`);
const outputUrl = pathToFileURL(`${process.cwd()}/${args.o ?? "./output/"}`);

const files = await fs.readdir(fileURLToPath(templatesUrl));
await ensure(fileURLToPath(outputUrl));

const importMap = await readJson("./importmap.json");
const patchScript = src => src.replace(/(?<=\s*import(.*?)from\s*\")[^\.\/](.*?)(?=\")/g, v => importMap.imports[v] ?? `Bare import ${v} not found`);
async function emitScript(path, base){
	const outputPath = fileURLToPath(new URL(path, outputUrl));
	await ensure(outputPath)
	const src = await patchScript(await fs.readFile(fileURLToPath(new URL(path, base)), "utf-8"));
	await fs.writeFile(fileURLToPath(new URL(path, outputUrl)), src);
} 

for (const file of files) {
	if (/^_/.test(file) || !/\.js$/.test(file)) continue;
	const outfile = new URL(file.replace(/\.js$/, ".html"), outputUrl);
	const path = new URL(file, templatesUrl);
	const { title: pageTitle, body: pageBody, layout: pageLayout } = await import(path);
	const body = typeof (pageBody) === "function" ? await pageBody() : pageBody;
	const { layout } = await import(new URL(pageLayout ?? "_layout.js", templatesUrl));
	const output = render(layout({ title: pageTitle, body }));
	await fs.writeFile(fileURLToPath(outfile), output);
}
//export scripts in use
const scripts = getScripts();
for(const script of scripts){
	await emitScript(script, templatesUrl);
}
const preactScripts = ["./node_modules/preact/dist/preact.mjs", "./node_modules/preact/hooks/dist/hooks.mjs", "./node_modules/htm/preact/index.mjs", "./node_modules/htm/dist/htm.mjs"];
for(const script of preactScripts){
	await emitScript(script, pathToFileURL(process.cwd() + "/"));
};

The code matches the earlier version but is streamlined, with an added emitScript import rewriter. Let's examine that piece closely:

//renders/htm-preact-renderer.js
const patchScript = src => src.replace(/(?<=\s*import(.*?)from\s*\")[^\.\/](.*?)(?=\")/g, v => importMap.imports[v] ?? `Bare import ${v} not found`);

This regex-based trick identifies patterns resembling import {something} from "library" (module names lacking a leading . or /), extracts "library", looks it up in the import map, and substitutes it. It's admittedly not infallible—it could theoretically replace substrings within strings. A proper parser would be needed for correctness, but that exceeds this project's scope. The regex covers a pragmatic 95% of real-world cases.

At the root, importmap.json contains a valid import map per the current specification:

//importmap.json
{
	"imports": {
		"preact" : "/output/preact/node_modules/preact/dist/preact.mjs",
		"htm/preact/index.mjs" : "/output/preact/node_modules/htm/preact/index.mjs",
		"htm": "/output/preact/node_modules/htm/dist/htm.mjs",
		"preact/hooks/dist/hooks.mjs": "/output/preact/node_modules/preact/hooks/dist/hooks.mjs"
	}
}

Consequently, every script has its bare imports rewritten, while relative paths pass through unchanged. We could even drop the node_modules segment entirely since we have full control, but various cleanup tasks remain for future iterations.

Hydration Execution

The last component is the hydration script itself:

import { render, h } from "preact";

const componentData = JSON.parse(document.querySelector("script[type='application/hydration-data']").innerHTML);
document.querySelectorAll("script[type='application/hydration-marker']").forEach(async marker => {
	const id = marker.dataset.id;
	const { props, componentName } = componentData.hydrationData[id];
	const { path, exportName } = componentData.componentPaths[componentName];
	const { [exportName]: component } = await import(new URL(path, window.location.href));

	render(h(component, props), marker.parentElement, marker.nextElementSibling);
});

We scan for each marker, identify the following element, dynamically import the script with the designated export, and attach the props. Preact's documentation recommends hydrate, but in my testing it reordered elements incorrectly. The render function, however, performs as expected.

Here's the updated layout structure:

//templates\preact\_layout.preact.js
import { html } from "htm/preact/index.mjs";
import { HydrationData } from "./components/_hydrator.js";
import { Script } from "./components/_script.js";

export const layout = data => html`
<html>
	<head>
		<title>${data.title}</title>
	</head>
	<body>
		${data.body}
		<${HydrationData} />
		<${Script} src="./components/_init-hydrate.js" />
	</body>
</html>
`;

And the home page:

import { html } from "htm/preact/index.mjs";
import { Counter } from "./components/_counter.preact.js";
import { WithHydration, HydrationData } from "./components/_hydrator.js";

export const title = "Home Preact";
export const layout = "_layout.preact.js"

const Header = ({ text }) => html`<h1>${text}</h1>`

export const body = html`
	<div>
		<${Header} text="Hello World!"><//>
		<p>A simple SSG Site with Preact</p>
		<${WithHydration(Counter, "./components/_counter.preact.js")} title="counter" />
	</div>
`;

Finally, the simple counter component:

import { useState } from "preact/hooks/dist/hooks.mjs";
import { html } from "htm/preact/index.mjs";

export const Counter = ({ title }) => {
	
	const [value, setValue] = useState(0);
	
	function increment(){
		setValue(value + 1);
	}

	function decrement(){
		setValue(value - 1);
	}

	return html`
		<div id="foo">
			<h2>${title}</h2>
			<div>${value}</div>
			<button onClick=${increment}>+</button>
			<button onClick=${decrement}>-</button>
		</div>
	`;
};

With that, partial hydration is operational. It may not be fully optimized, carries some rough edges, and the project organization could be refined, but we've achieved a functional SSG with partial hydration as the default behavior. Few projects can make that claim.

Final breakdown:

  • _hydrator.js: roughly 36 lines
  • _init_hydrate: about 11 lines
  • _script_manager: around 8 lines
  • htm-preact-renderer: approximately 43 lines
  • 0 added dependencies! (rimraf and http-server are for dev convenience and optional)

That's just under 100 lines of boilerplate code (excluding the pages and components themselves)!

The code is hosted on GitHub.

A Note Regarding React

The title might be slightly misleading—chosen for searchability since these concepts aren't Preact-exclusive. This project began with feature parity between React and Preact. From prior experience wrestling with this, I anticipate React will pose more challenges due to its persistent lack of ESM support. Honestly, at this stage, everyone stands to gain from Preact's benefits. A feasible alternative might involve using Preact-compat, or revisiting a bundler to open up that path once more.