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.

April 4, 20266 min read2 / 3

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:

JavaScript
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

JavaScript
// 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:

HTML
<!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:

JavaScript
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:

JavaScript
// 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 needWhat React gives you
Routing (URL → component)Nothing
Writing N pages to N filesNothing
Asset hashing (style.abc123.css)Nothing
Image optimisationNothing
Dev server with hot reloadNothing
Incremental builds (only rebuild changed pages)Nothing
Code splittingNothing

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' in next.config.js turns 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

renderToStaticMarkuprenderToString
React markers in outputNoYes (internal hydration markers)
Can hydrate on clientNoYes
Output sizeSmallerSlightly larger
Use casePure static HTMLSSR 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.

  1. Create a new folder, run npm init -y, add "type": "module" to package.json
  2. Install react and react-dom only — nothing else
  3. Write index.html with <div id="root"><!--ROOT--></div> as the only special content
  4. Write App.js using createElement instead of JSX — no Babel needed
  5. Write build.js: import renderToStaticMarkup, read index.html, replace <!--ROOT-->, write to dist/index.html
  6. Add the __dirname shim for ES modules (copy it from the post above)
  7. Run node build.js and open dist/index.html in a browser — you should see your component rendered as plain HTML
  8. Open dist/index.html in a text editor and confirm there are no data-react attributes 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.

Build an SSG Pipeline from ScratchQuiz: Static Site Generation
2 exercises

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.