Start Your Own Static Site Generator



This content originally appeared on DEV Community and was authored by Abanoub Samir

Static Site Generation (SSG) doesn’t always require big frameworks like Next.js, Gatsby, or Astro.
Sometimes, you just want to take a React component, render it into HTML at build time, and ship it.

In this article, we’ll build a minimal SSG in less than 40 lines of JavaScript, using only React and Node.js.

Why SSG?

Static Site Generation is the process of turning your app into plain HTML at build time.

Benefits:

  1. Faster load times — HTML is ready before the user arrives.
  2. Better SEO — search engines can crawl HTML easily.
wait for SSG Pattern Article

Prerequisites

  • Node.js 18+ (for ES modules)
  • react & react-dom installed

File Structure

ssg/
├── App.js
├── index.html
├── build.js
└── package.json

Step 1 — App.js

import { createElement as h } from "react";

function App() {
  return h(
    'div',
    null,
    h('h1', null, 'this is SSG'),
    h('p', null, 'Ooooh Yeah!')
  );
}

export default App;

Why createElement?
createElement is the low-level building block of JSX.
We use it in our SSG script because we’re outside of a bundler, so there’s no JSX compilation step.

Step 2 — index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>My SSG Site</title>
</head>
<body>
   <!--Root-->  
</body>
</html>

Root Placeholder
This marks where the React-rendered content will be inserted.
During the build process, <!--Root--> is replaced with the static HTML generated from your React component.

Step 3 — package.json

{
  "type": "module",
  "scripts": {
    "build": "node build.js"
  },
  "dependencies": {
    "react": "^19.1.1",
    "react-dom": "^19.1.1"
  }
}

Step 4 — build.js

import { createElement as h } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { fileURLToPath } from "node:url";
import path, { dirname, join } from "node:path";
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import App from "./App.js";

// Render React component to HTML
const app = renderToStaticMarkup(h(App));

// Resolve paths
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const distPath = join(__dirname, "dist");

// Read template & inject app HTML
const shell = readFileSync(join(__dirname, "index.html"), "utf-8");
const generatedHTML = shell.replace("<!--Root-->", app);

// Prepare dist folder
if (!existsSync(distPath)) {
  mkdirSync(distPath);
} else {
  for (const file of readdirSync(distPath)) {
    unlinkSync(join(distPath, file));
  }
}

// Save final HTML
writeFileSync(join(distPath, "index.html"), generatedHTML);

Why h(App) Instead of App()?

const app = renderToStaticMarkup(h(App)); // ✅ Works
const app = renderToStaticMarkup(App());  // ❌ Breaks hooks

Reason:

  • h(App) → creates a React element (like <App />)
  • App() → just runs the function and returns its raw output (bypasses React features)

Hooks like useState or useEffect will break if you call the function directly.

What is renderToStaticMarkup?

renderToStaticMarkup takes a React element and returns a pure HTML string.

Example:

import { createElement as h } from "react";
import { renderToStaticMarkup } from "react-dom/server";

const html = renderToStaticMarkup(h("h1", null, "Hello World"));
console.log(html); // <h1>Hello World</h1>

Difference from renderToString:

  • renderToString → includes React-specific attributes for client hydration.
  • renderToStaticMarkup → outputs clean HTML only.

Getting __filename & __dirname in ES Modules

In CommonJS (the older require() system), Node.js automatically provides two special variables:

  • __filename → the absolute path to the current file
  • __dirname → the directory containing the current file

Example in CommonJS:

console.log(__filename); // /Users/you/project/build.js
console.log(__dirname);  // /Users/you/project

However, in ES Modules (the newer import / export syntax), these don’t exist by default — so if you try to log them, you’ll get a ReferenceError.

How to recreate them in ES Modules

We can reconstruct them using Node.js’ built-in import.meta.url and the path module:

import { fileURLToPath } from "node:url";
import path, { dirname } from "node:path";

// Convert the module's URL to a file path
const __filename = fileURLToPath(import.meta.url);

// Get the directory name from the file path
const __dirname = dirname(__filename);

console.log(__filename); // /Users/you/project/build.js
console.log(__dirname);  // /Users/you/project

What’s happening here?

  1. import.meta.url Every ES module has a meta.url property containing its absolute URL, e.g.:
   file:///Users/you/project/build.js
  1. fileURLToPath() Converts that URL into a normal file system path:
   /Users/you/project/build.js
  1. dirname() Extracts just the folder path:
   /Users/you/project

Why we need them in our SSG

Our script uses __dirname and __filename to:

  • Read the HTML template (index.html) from the correct location.
  • Create a dist folder in the right place.
  • Avoid path issues when running the script from a different working directory.

Wrapping Up

We just built a fully functional Static Site Generator in under 40 lines, using nothing but React and Node.js.

Next steps:

  • Add multiple pages from data sources
  • Parse Markdown for blog posts
  • Integrate CSS or Tailwind
  • Add client-side hydration for interactivity


This content originally appeared on DEV Community and was authored by Abanoub Samir