Static Site Generation: How It Works from Scratch
How SSG works under the hood — from renderToStaticMarkup to build-time output — and when to reach for it over a framework.
The previous post laid out all four render modes and made the case for starting with client-side React. This one goes deep on the first server-side option: SSG.
Static site generation turns your React components into flat HTML files at build time. Understanding it from the ground up — before reaching for Next.js or Astro — makes the tradeoffs clear.
The Core API: renderToStaticMarkup
React ships two server rendering functions. renderToStaticMarkup is the simpler one:
import { renderToStaticMarkup } from 'react-dom/server';
function Page() {
return (
<main>
<h1>Hello World</h1>
<p>Rendered at build time.</p>
</main>
);
}
const html = renderToStaticMarkup(<Page />);
// → '<main><h1>Hello World</h1><p>Rendered at build time.</p></main>'It outputs a plain HTML string with no React data attributes (data-reactroot, etc.). The result is truly static — the browser receives it as a document, not as a React app.
Notice what's missing: no JSX transform, no Babel, no Webpack. For simple static sites, you can run this as a plain Node script.
A Minimal SSG Build Script
// build.js
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
// Your HTML shell with a placeholder comment
const template = readFileSync('index.html', 'utf-8');
function App() {
return createElement('h1', null, 'Built with React SSG');
}
// Render the component
const rendered = renderToStaticMarkup(createElement(App));
// Replace the placeholder with rendered output
const output = template.replace('<!--ROOT-->', rendered);
// Write the final HTML file
mkdirSync('dist', { recursive: true });
writeFileSync('dist/index.html', output);
console.log('Built!');The HTML shell looks like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My Site</title>
</head>
<body>
<div id="root"><!--ROOT--></div>
</body>
</html>The comment <!--ROOT--> is a unique delimiter. It is easy to split on and impossible to appear accidentally in normal content. The build script replaces it with the rendered React output.
__dirname in ES modules
If you use "type": "module" in package.json, the familiar __dirname from CommonJS is not defined. You have to recreate it:
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Now you can use __dirname as usual
const distPath = join(__dirname, 'dist');This is one of the few things worth copy-pasting from a snippet or asking an AI to generate — the logic never changes, you just need it every time you write an ES module build script.
No JSX, No Build Tools Required
One underappreciated fact: JSX is syntactic sugar. Without a build step, you can write the same components using createElement directly:
// Without JSX (no Babel/Vite needed)
import { createElement as h } from 'react';
function Nav() {
return h('nav', null,
h('a', { href: '/' }, 'Home'),
h('a', { href: '/about' }, 'About'),
);
}
function Page() {
return h('main', null,
h(Nav),
h('h1', null, 'Hello'),
);
}For a basic static site with no interactivity, you can generate HTML from React in pure Node.js — no configuration, no bundler, no transpiler.
When SSG Makes Sense
SSG is the right choice when:
- The content is inert — it doesn't change per user or per request
- The deployment target can be a CDN or file host (GitHub Pages, Netlify, Cloudflare Pages)
- You want zero server infrastructure at runtime
- Load times should be as fast as physically possible (pre-rendered HTML + CDN edge = minimal latency)
Classic use cases: blogs, documentation sites, marketing pages, portfolio sites, course websites.
Why You Still Need a Framework
React is a UI library, not a framework. renderToStaticMarkup does exactly one thing: turns a component into an HTML string. Everything else a real site needs, you have to build yourself:
| What you need | What React gives you |
|---|---|
| Routing (URL → component) | Nothing |
| Writing N pages to N files | Nothing |
Asset hashing (style.abc123.css) | Nothing |
| Image optimisation | Nothing |
| Dev server with hot reload | Nothing |
| Incremental builds (only rebuild changed pages) | Nothing |
| Code splitting | Nothing |
Next.js, Astro, and Gatsby solve all of that. They use renderToStaticMarkup (or equivalent) internally. They are just complete wrappers around it that handle the 20 other things React doesn't do for you.
The from-scratch exercise exists so you understand what frameworks are doing under the hood, not because you'd use it in production.
For real projects:
- Next.js:
output: 'export'innext.config.jsturns any Next app into a static site. All the React patterns you already know, plus optimized images, automatic code splitting, and ISR when you need it later. - Astro: purpose-built for content sites. Outputs zero JS by default; React components can be progressively hydrated only when needed (called "islands").
- Gatsby: older but mature, with a large plugin ecosystem for CMS integration.
The rough rule: if you can reach the end of a tutorial explaining how to set up SSG manually, you are 30 seconds from a npm create command that gives you everything that tutorial left out.
Mixing Markdown and React components: If you want to author content in Markdown but embed React components inside it, use MDX. It lets you write .mdx files that are valid Markdown plus JSX, giving you the best of both authoring experience and component composability. Both Next.js and Astro support MDX out of the box.
How renderToStaticMarkup vs renderToString Differ
renderToStaticMarkup | renderToString | |
|---|---|---|
| React markers in output | No | Yes (internal hydration markers) |
| Can hydrate on client | No | Yes |
| Output size | Smaller | Slightly larger |
| Use case | Pure static HTML | SSR with hydration |
If you plan to add client-side React on top of the static HTML, you need renderToString (covered in the SSR post). If the page is truly static with no interactivity, renderToStaticMarkup gives you cleaner output.
Build This
Build the SSG pipeline from scratch without using any framework. This takes about 20 minutes and makes everything Next.js does for you suddenly obvious.
- Create a new folder, run
npm init -y, add"type": "module"topackage.json - Install
reactandreact-domonly — nothing else - Write
index.htmlwith<div id="root"><!--ROOT--></div>as the only special content - Write
App.jsusingcreateElementinstead of JSX — no Babel needed - Write
build.js: importrenderToStaticMarkup, readindex.html, replace<!--ROOT-->, write todist/index.html - Add the
__dirnameshim for ES modules (copy it from the post above) - Run
node build.jsand opendist/index.htmlin a browser — you should see your component rendered as plain HTML - Open
dist/index.htmlin a text editor and confirm there are nodata-reactattributes anywhere
Stretch goal: Add a second page. Create about.js with a different component, update build.js to render it to dist/about.html. Now you have a two-page static site. Think about what you would need to add to make this work for ten pages, then a hundred. That is what Next.js and Astro automate for you.
Practice what you just read.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.