I've taken a React course recently so I've been doing quite a bit of React programming. Despite the general dislike that a lot of loud voices in web development have, I actually think it's a cool library and frameworks like Next.js work pretty well.
That said, I do like making static websites and I don't think hydrating the entire website is necessary in those kinds of cases. However, React does have an API to render to static markup, so I wanted to take a stab at building a simple static site generator using React. This article is a very basic attempt at documenting the process.
The first problem in using React is of course that you need to support JSX. When you use Next.js, support for JSX is built-in and the same goes for other frameworks. Under the hood, those frameworks use stuff like Babel and esbuild, so you can probably hook up a compile step to build your script before running it. However, to keep things simple and to be able to use TypeScript, I decided to go for tsx, which is a really cool Node runner.
The script to render static markup is really simple. In fact, the React documentation basically shows you how to do it verbatim:
first example (renderToStaticMarkup)
// -> app.jsx
function BaseLayout({ children }) {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Document</title>
</head>
<body>{children}</body>
</html>
);
}
export default function App() {
return (
<BaseLayout>
<main>
<h1>Hello World</h1>
<p>There is no spoon!</p>
<Counter />
</main>
</BaseLayout>
);
}
// -> main.jsx
const html = renderToStaticMarkup(<Page />);
Really easy so far. Next.js and other frameworks also have a file-based router that let
you automatically build html pages based on source files in a directory. For example,
about.tsx
becomes about.html
. This was also relatively easy to implement:
file-based router
const pages = readdirSync("./src/pages");
for (const page of pages) {
const importPath = `./pages/${filename}`;
const pageId = basename(filename, extname(filename));
const outPath = join(process.cwd(), BUILD_DIR, pageId + ".html");
const Contents = (await import(importPath)).default;
const App = () => (
<Layout pageId={pageId}>
<Contents />
</Layout>
);
// and then render the App component using the code from before
}
As a final excercise, even though I didn't think it was necessary, I did try out the hydration api. Getting this to work is actually a two-step process. First, you have to use one of the other rendering API's to render the app into markup that can be hydrated.
second example (renderToPipeableStream)
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ["bundle.js"],
onAllReady() {
console.log(`writing ${outPath}`);
pipe(createWriteStream(outPath, "utf8"));
},
});
After that, the html has to actually be hydrated on the client side. This is also pretty easy, although the following example also takes into account which component has to be hydrated, using the file-based router which passes the name of the page and writes a data-page attribute to the body tag.
client-side hydration script
const PAGE_MAP = {
index: () => import("../pages/index"),
about: () => import("../pages/about"),
};
(async () => {
const pageId = document.body.dataset.page;
console.log(`hydrating page with id '${pageId}'`);
if (pageId && pageId in PAGE_MAP) {
const App = (await PAGE_MAP[pageId]()).default;
hydrateRoot(document.body, <App />);
}
})();
It's pretty finnicky, and I'm assuming the actual frameworks have
some kind of really clever way to do this kind of mapping automatically.
Obviously, this kind of thing shouldn't be done manually unless you're
using renderToStaticMarkup
to build a really simple static site
generator.
This article was written on 12-feb-2025 and the complete code for my experiments is available on GitHub.