Skip to content

Build a Git-Backed Markdown Wiki in Node.js (Step by Step)

DodaTech Updated 2026-06-21 7 min read

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

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

Can I use this wiki for a team?

Yes. Push the pages/ directory to a shared Git Repository (GitHub, GitLab). Multiple people can clone, edit locally, and push changes. The server auto-detects file changes.

How do I add images?

Place images in a public/images/ directory and reference them as /images/photo.png in your markdown. Update the Express static file middleware to serve public/.

Does it support wiki links like `[[PageName]]`?

Not out of the box. Add a custom marked extension that parses [[PageName]] syntax and converts it to <a href="/pagename">PageName</a>.

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