Build a Git-Backed Markdown Wiki in Node.js (Step by Step)
In this tutorial, you'll learn about Build a Git. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Build a Git-backed markdown wiki in Node.js where every page is a markdown file, edits are automatically committed to Git for full version history, and a built-in search engine indexes every word.
What You'll Build
You'll build a wiki server that stores pages as plain markdown files on disk, renders them as HTML, tracks every edit through Git commits, provides full-text search across all pages, and auto-generates a sidebar from page titles and headings.
Why a Git-Backed Wiki Matters
Traditional wikis lock your content in a database. A Git-backed wiki gives you version history, branching, diffs, and portability — every page is a standard markdown file you can edit with any text editor. Teams can review changes via pull requests. The same approach powers documentation systems like DodaTech's own tutorials, and file-level version tracking is used in DodaZIP to let users browse archive contents by version.
Prerequisites
- Node.js 18+ installed
- Git installed and configured (
git --version) - Basic JavaScript and Express.js knowledge
Step 1: Project Setup
mkdir markdown-wiki
cd markdown-wiki
npm init -y
npm install express marked gray-matter lunr
mkdir pages
cd pages && git init && cd ..
echo "# Welcome" > pages/index.md
Project structure:
markdown-wiki/
├── server.js # Express server + API routes
├── wiki.js # Wiki engine: load, save, search
├── views/
│ └── layout.html # HTML template
└── pages/ # Markdown files (Git repo)
└── index.md # Home page
Step 2: The Wiki Engine
The core engine loads markdown files, converts them to HTML with marked, manages Git commits for every save, and builds a full-text search index with lunr.
// wiki.js
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const marked = require("marked");
const matter = require("gray-matter");
const lunr = require("lunr");
const PAGES_DIR = path.join(__dirname, "pages");
function listPages() {
const files = fs.readdirSync(PAGES_DIR);
return files
.filter((f) => f.endsWith(".md"))
.map((f) => ({
slug: f.replace(/\.md$/, ""),
title: f.replace(/\.md$/, "").replace(/-/g, " "),
}));
}
function loadPage(slug) {
const filePath = path.join(PAGES_DIR, slug + ".md");
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf-8");
const { content, data } = matter(raw);
const html = marked.parse(content);
const log = execSync(`git log --oneline -- "${slug}.md"`, { cwd: PAGES_DIR })
.toString()
.trim()
.split("\n")
.filter(Boolean);
return { slug, html, content, data, history: log };
}
function savePage(slug, content, message) {
const filePath = path.join(PAGES_DIR, slug + ".md");
fs.writeFileSync(filePath, content, "utf-8");
execSync(`git add "${slug}.md"`, { cwd: PAGES_DIR });
execSync(`git commit -m "${message}"`, { cwd: PAGES_DIR });
}
function buildSearchIndex() {
const docs = listPages().map((p) => {
const raw = fs.readFileSync(path.join(PAGES_DIR, p.slug + ".md"), "utf-8");
return { id: p.slug, title: p.title, text: raw };
});
return lunr(function () {
this.ref("id");
this.field("title");
this.field("text");
docs.forEach((d) => this.add(d));
});
}
function search(query) {
const idx = buildSearchIndex();
return idx.search(query);
}
module.exports = { listPages, loadPage, savePage, search };
Step 3: The Server
// server.js
const express = require("express");
const wiki = require("./wiki");
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.static("public"));
app.set("view engine", "html");
app.engine("html", (filePath, options, callback) => {
fs.readFile(filePath, "utf-8", (err, content) => {
if (err) return callback(err);
const rendered = content.replace(/\{\{(\w+)\}\}/g, (_, key) => options[key] || "");
return callback(null, rendered);
});
});
app.get("/", (req, res) => {
const page = wiki.loadPage("index");
const pages = wiki.listPages();
const sidebar = pages.map((p) => `<li><a href="/${p.slug}">${p.title}</a></li>`).join("");
res.send(buildHtml(page.html, pages, sidebar, "Home"));
});
app.get("/:slug", (req, res) => {
const page = wiki.loadPage(req.params.slug);
if (!page) return res.status(404).send("Page not found");
const pages = wiki.listPages();
const sidebar = pages.map((p) => `<li><a href="/${p.slug}">${p.title}</a></li>`).join("");
res.send(buildHtml(page.html, pages, sidebar, page.data.title || req.params.slug));
});
app.get("/:slug/edit", (req, res) => {
const page = wiki.loadPage(req.params.slug);
res.send(/* edit form HTML */);
});
app.post("/:slug", (req, res) => {
wiki.savePage(req.params.slug, req.body.content, req.body.message);
res.redirect("/" + req.params.slug);
});
app.get("/search/results", (req, res) => {
const results = wiki.search(req.query.q);
const pages = wiki.listPages();
const html = results.map((r) => `<li><a href="/${r.ref}">${r.ref}</a> (score: ${r.score.toFixed(2)})</li>`).join("");
res.send(buildHtml(`<h1>Search: ${req.query.q}</h1><ul>${html}</ul>`, pages, "", "Search"));
});
function buildHtml(body, pages, sidebar, title) {
return `<!DOCTYPE html>
<html><head><title>${title} - Wiki</title>
<style>
body { margin: 0; font-family: system-ui, sans-serif; display: flex; min-height: 100vh; }
nav { width: 250px; background: #1e293b; color: white; padding: 20px; }
nav a { color: #94a3b8; text-decoration: none; display: block; padding: 4px 0; }
nav a:hover { color: white; }
main { flex: 1; padding: 40px; max-width: 800px; }
#search { width: 100%; padding: 8px; margin-bottom: 16px; border-radius: 6px; border: none; }
pre { background: #f1f5f9; padding: 16px; border-radius: 8px; overflow-x: auto; }
a { color: #6366f1; }
</style></head>
<body>
<nav>
<h2>Wiki</h2>
<form action="/search/results" method="get">
<input type="text" name="q" id="search" placeholder="Search...">
</form>
<ul style="list-style:none;padding:0;">${sidebar}</ul>
</nav>
<main>${body}</main>
</body></html>`;
}
app.listen(3000, () => console.log("Wiki at http://localhost:3000"));
Expected output: Visit http://localhost:3000. The home page renders from pages/index.md. The sidebar lists all pages. Edit a page and check git log inside the pages/ directory to see the commit history.
Architecture
flowchart TD
A[Browser] --> B[Express Server]
B --> C[Wiki Engine]
C --> D[Markdown Files]
D --> E[Git Repository]
C --> F[lunr Search Index]
B --> G[Mustache Template]
G --> A
E --> H[Commit History]
H --> B
Common Errors
1. Git not initialized in pages directory
If saving throws an error, the pages/ directory may not be a Git repo. Run cd pages && git init && git config user.email "wiki@local" && git config user.name "Wiki" before first save.
2. Search index returns no results
lunr indexes only what you add. Make sure buildSearchIndex() is called after every page save, or the index won't reflect recent edits. Restart the server to rebuild.
3. Markdown not rendering as HTML
The marked.parse() call may fail if the content contains invalid HTML. Wrap in try/catch: try { html = marked.parse(content); } catch { html = "<p>Error rendering</p>"; }.
4. Git commit fails with "nothing to commit"
This happens when the file content hasn't changed. Check that savePage actually writes new content before committing. The Git binary must also be on the system PATH.
5. Sidebar doesn't update after adding a page
listPages() reads from disk every call. If the file exists, it appears. But if you added a page outside the app (e.g., via command line), restart the server or add a file watcher with fs.watch.
Practice Questions
1. Why use Git as the storage backend instead of a database? Git gives free version history, diffs, branching, and portability. Every edit is tracked with a commit message, author, and timestamp — no need to build audit logging yourself.
2. How does full-text search work in this wiki? lunr builds an inverted index mapping every word to the documents containing it. At search time, it ranks results by term frequency and document length (TF-IDF scoring).
3. What happens if two users edit the same page simultaneously? The second commit will succeed but the first user's changes are overwritten. For collaboration, use Git branching or implement Operational Transform (OT) like Google Docs.
4. Challenge: Add page diff view
Add a /diff/:slug?from=abc123&to=def456 route that runs git diff between two commit hashes and renders the output as HTML with added lines in green and removed lines in red.
5. Challenge: Auto-generated table of contents
Parse headings from the markdown content and inject a table of contents at the top of each page. Extract ## and ### headings and link to anchor IDs generated by marked.
FAQ
Next Steps
- Add user authentication and page-level permissions
- Explore Git internals for deeper version control understanding
- Try building the Static Site Generator project for another markdown-related tool
- Learn about lunr search indexing strategies for larger wikis
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro