Building a Tiny Static Site Generator in 200 Lines of Python

There is no faster way to understand a tool than to build a worse version of it. Static site generators look like magic — Markdown in, website out — until you write one and realize the entire job is four steps: read files, parse front matter, render Markdown, wrap in a layout. This is that generator: complete, readable, and short enough to hold in your head. You will come out the other side understanding Hugo, Astro, and Eleventy better than any tutorial could teach you.

The pipeline

Every SSG is the same pipeline with different polish:

  1. Read all content files from a directory.
  2. Parse the front matter (metadata) off the top of each file.
  3. Render the body (Markdown → HTML).
  4. Wrap the rendered body in a layout template.
  5. Write the result to an output directory, deriving the URL from the filename.

Here's step one and two — reading and parsing, which is 90% of the "hard" parts because front matter is just a mini-YAML block:

import re
from pathlib import Path
def parse_post(path: Path) -> dict:
text = path.read_text()
m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.DOTALL)
if not m:
raise ValueError(f"{path}: missing front matter")
meta, body = m.groups()
fields = {}
for line in meta.splitlines():
key, _, value = line.partition(":")
fields[key.strip()] = value.strip().strip('"')
return {"slug": path.stem, "meta": fields, "body": body}

That regex — ^---\n(.*?)\n---\n(.*)$ — is the entire front-matter protocol. Everything after the second --- is the body. It's a pattern you'll recognize in every SSG's source, and now you know why they all converge on it.

Rendering Markdown

For the render step, the temptation is to hand-roll a parser. Don't. This is exactly what libraries are for, and even the real SSGs stand on the shoulders of markdown-family libraries:

import markdown
def render_body(body: str) -> str:
return markdown.markdown(
body,
extensions=["fenced_code", "tables", "nl2br"],
)

Two extensions matter disproportionately: fenced_code (``` blocks) and tables (GitHub-style pipes). Without them your Markdown silently loses features — a failure mode you'll now recognize when a "Markdown" tool mangles your content.

Layouts: the template engine that fits in a function

A layout is just a string with a placeholder for the content. The simplest correct version uses Python's own format strings — which is exactly what the first generation of SSGs did before they over-engineered it:

LAYOUT = """<!doctype html>
<html lang="en">
<head>
<title>{title}</title>
<meta name="description" content="{description}">
</head>
<body>
<main>{content}</main>
</body>
</html>"""
def render_page(post: dict) -> str:
return LAYOUT.format(
title=post["meta"]["title"],
description=post["meta"].get("description", ""),
content=render_body(post["body"]),
)

The .format() call is your template engine. It's not a great one — no loops, no partials — but it's honest, and upgrading to Jinja2 later is a one-line import. Every "powerful" SSG feature is this same function with more arguments.

The build: derive URLs from filenames

The final step maps posts/hello.md to output/hello/index.html — the pretty-URL convention that makes every link on the site extension-free:

from datetime import date
def build(src_dir: Path, out_dir: Path) -> list[dict]:
out_dir.mkdir(exist_ok=True)
posts = [parse_post(p) for p in src_dir.glob("*.md")]
posts.sort(key=lambda p: p["meta"]["pubDate"], reverse=True)
for post in posts:
dest = out_dir / post["slug"] / "index.html"
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(render_page(post))
return posts

That's the whole generator. Twenty lines of plumbing, four concepts, and you have a site. The remaining 180 lines of "200" are the features that make it usable — and each one teaches you why real SSGs exist.

The features that make it real

A generator that renders one page is a demo; a generator that runs a blog needs the unglamorous 80%:

# 1. An index page listing posts (a loop that builds the list HTML)
def render_index(posts: list[dict]) -> str:
items = "\n".join(
f'<li><a href="/{p["slug"]}/">{p["meta"]["title"]}</a></li>'
for p in posts
)
return f"<ul>\n{items}\n</ul>"
# 2. Relative links, so the site works in subdirectories
def link(target: str, current_depth: int) -> str:
return "../" * current_depth + target
# 3. Date objects instead of strings, so sorting works
def parse_date(raw: str) -> date:
return date.fromisoformat(raw) # 2026-09-08 → date(2026, 9, 8)
# 4. Drafts excluded from the build
posts = [p for p in posts if not p["meta"].get("draft")]

The date one is the sleeper: string-sorting dates is a bug factory ("2026-9-8" sorts wrong), and every SSG's "date handling" is really just "parse ISO 8601 and sort properly." The draft filter is the other lesson — drafts don't exist on the output side; they're filtered at build time, which is why your local draft never leaks into production.

The whole thing, assembled

# sitegen.py — a complete, if humble, static site generator
import re, markdown
from pathlib import Path
from datetime import date
# ... parse_post, render_body, render_page, build as above ...
if __name__ == "__main__":
build(Path("posts"), Path("site"))

One hundred and forty lines of actual code, sixty lines of comments. Run it, and posts/ becomes a browsable website with zero dependencies beyond markdown.

What writing one teaches you

The real lessons aren't the code — they're the architecture insights you can't get any other way:

  • Builds must be deterministic. Same input, same output, every time. The moment your generator depends on filesystem order or wall-clock time, your deploys become a lottery.
  • Content and presentation are separate layers. The whole point of front matter is that the writer's file doesn't know or care about the layout — and the layout doesn't care what the writer wrote.
  • Incremental builds are where the complexity lives. "Just rebuild everything" is correct and slow; every real SSG's engine is a bet on which of those two words to optimize.
  • The features that matter are boring. Sorting, drafts, links, dates. The magic is 10%; the reliability is 90%.

Build your own, badly, once. The next time a static site generator confuses you — a config option, a build error, a mystery cache — you'll have the source map already in your head: it's reading, parsing, rendering, and wrapping. Everything else is polish on top of four steps you've now written yourself.