Skip to content
Build a Static Blog Generator from Markdown in Node.js (Step by Step)

Build a Static Blog Generator from Markdown in Node.js (Step by Step)

DodaTech Updated Jun 20, 2026 8 min read

Build a static blog generator CLI tool in Node.js that reads markdown files, generates HTML pages from EJS templates, creates an RSS feed, applies syntax highlighting, and deploys to any static host.

What You’ll Build

You’ll build a CLI tool called mdblog that converts a folder of markdown files into a fully functional static blog. Run mdblog build and it generates HTML, an RSS feed, category pages, and a deploy script. This is the same approach used to generate documentation for DodaZIP and parts of the DodaTech tutorial site itself.

Why Static Blog Generators Matter

Static site generators (Hugo, Jekyll, Eleventy) power millions of blogs because they are fast, secure, and cheap to host. Building your own teaches you file I/O, template rendering, metadata parsing, and build pipelines — all transferable skills. You’ll understand exactly what tools like Hugo do under the hood, and you’ll have a custom generator tailored to your workflow.

Prerequisites

Step 1: Project Setup

mkdir mdblog
cd mdblog
npm init -y
npm install marked ejs front-matter highlight.js rss
npm install -D nodemon
mkdir src content build templates

Project structure:

mdblog/
├── src/
│   ├── cli.js          # CLI entry point
│   ├── builder.js      # Core build logic
│   ├── rss.js          # RSS feed generator
│   └── deploy.js       # Deployment script
├── templates/
│   ├── post.ejs        # Individual post page
│   ├── list.ejs        # Blog listing page
│   └── layout.ejs      # HTML shell
├── content/
│   └── hello-world.md  # Sample post
├── build/              # Generated output
└── package.json

Step 2: CLI Entry Point

#!/usr/bin/env node
// src/cli.js
const { program } = require('commander');
const { build } = require('./builder');
const { deploy } = require('./deploy');

program
  .name('mdblog')
  .description('Static blog generator from markdown')
  .version('1.0.0');

program
  .command('build')
  .description('Build the static blog')
  .option('-o, --output <dir>', 'Output directory', 'build')
  .option('-c, --content <dir>', 'Content directory', 'content')
  .option('--no-rss', 'Skip RSS feed generation')
  .action(async (options) => {
    console.log('Building blog...');
    await build(options);
    console.log('Build complete! Output in', options.output);
  });

program
  .command('deploy')
  .description('Deploy to GitHub Pages')
  .option('-d, --dir <dir>', 'Directory to deploy', 'build')
  .action((options) => {
    deploy(options);
  });

program.parse();

Step 3: Front-Matter Parsing

Each markdown file starts with YAML front-matter between --- delimiters:

---
title: Hello World
date: 2026-06-20
tags: [nodejs, tutorial]
author: DodaTech
---

This is the **first post** using our custom static blog generator.

Step 4: The Core Builder

// src/builder.js
const fs = require('fs-extra');
const path = require('path');
const marked = require('marked');
const fm = require('front-matter');
const ejs = require('ejs');
const hljs = require('highlight.js');
const { generateRss } = require('./rss');

// Configure marked with syntax highlighting
marked.setOptions({
  highlight: function (code, lang) {
    if (lang && hljs.getLanguage(lang)) {
      return hljs.highlight(code, { language: lang }).value;
    }
    return hljs.highlightAuto(code).value;
  }
});

async function build({ output = 'build', content = 'content', rss = true } = {}) {
  const outputDir = path.resolve(output);
  const contentDir = path.resolve(content);

  // Clean and recreate output
  await fs.emptyDir(outputDir);
  await fs.ensureDir(path.join(outputDir, 'posts'));
  await fs.ensureDir(path.join(outputDir, 'assets'));

  // Copy static assets
  if (await fs.pathExists(path.join(process.cwd(), 'static'))) {
    await fs.copy(path.join(process.cwd(), 'static'), path.join(outputDir, 'assets'));
  }

  // Read all markdown files
  const files = await fs.readdir(contentDir);
  const posts = [];

  for (const file of files) {
    if (!file.endsWith('.md')) continue;

    const raw = await fs.readFile(path.join(contentDir, file), 'utf-8');
    const parsed = fm(raw);
    const html = marked.parse(parsed.body);

    const slug = path.basename(file, '.md');
    const post = {
      slug,
      ...parsed.attributes,
      body: html,
      date: new Date(parsed.attributes.date || Date.now()),
      url: `/posts/${slug}.html`,
    };

    posts.push(post);
  }

  // Sort posts by date (newest first)
  posts.sort((a, b) => b.date - a.date);

  // Load templates
  const layout = await fs.readFile(
    path.join(__dirname, '..', 'templates', 'layout.ejs'), 'utf-8'
  );
  const postTemplate = await fs.readFile(
    path.join(__dirname, '..', 'templates', 'post.ejs'), 'utf-8'
  );
  const listTemplate = await fs.readFile(
    path.join(__dirname, '..', 'templates', 'list.ejs'), 'utf-8'
  );

  // Render individual post pages
  for (const post of posts) {
    const content = ejs.render(postTemplate, { post, layout, siteTitle: 'My Blog' });
    await fs.writeFile(path.join(outputDir, 'posts', `${post.slug}.html`), content);
  }

  // Render index page (post listing)
  const indexHtml = ejs.render(listTemplate, {
    posts,
    layout,
    pageTitle: 'Home',
    siteTitle: 'My Blog',
  });
  await fs.writeFile(path.join(outputDir, 'index.html'), indexHtml);

  // Generate RSS feed
  if (rss) {
    await generateRss(posts, outputDir);
  }

  console.log(`Generated ${posts.length} posts`);
}

module.exports = { build };

Expected output: Running node src/cli.js build creates build/index.html, build/posts/hello-world.html, and (if RSS enabled) build/rss.xml.

Step 5: EJS Templates

<!-- templates/layout.ejs -->
<!DOCTYPE html>
<html>
<head>
  <title><%= typeof pageTitle !== 'undefined' ? pageTitle + ' | ' : '' %><%= siteTitle %></title>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml">
  <link rel="stylesheet" href="/assets/style.css">
</head>
<body>
  <nav>
    <a href="/"><%= siteTitle %></a>
  </nav>
  <main>
    <%- content %>
  </main>
</body>
</html>
<!-- templates/post.ejs -->
<% pageTitle = post.title; %>
<h1><%= post.title %></h1>
<p class="meta">
  By <%= post.author || 'DodaTech' %> &middot;
  <%= post.date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) %>
</p>
<% if (post.tags) { %>
<div class="tags">
  <% post.tags.forEach(tag => { %>
    <span class="tag"><%= tag %></span>
  <% }) %>
</div>
<% } %>
<article class="content">
  <%- post.body %>
</article>
<!-- templates/list.ejs -->
<h1>Latest Posts</h1>
<div class="posts">
  <% posts.forEach(post => { %>
  <article class="post-card">
    <h2><a href="<%= post.url %>"><%= post.title %></a></h2>
    <p class="meta"><%= post.date.toLocaleDateString() %></p>
    <p><%= post.body.replace(/<[^>]*>/g, '').substring(0, 200) %>...</p>
  </article>
  <% }) %>
</div>

Step 6: RSS Feed Generator

// src/rss.js
const RSS = require('rss');
const fs = require('fs-extra');
const path = require('path');

async function generateRss(posts, outputDir) {
  const feed = new RSS({
    title: 'My Blog',
    description: 'Generated by mdblog',
    feed_url: 'https://example.com/rss.xml',
    site_url: 'https://example.com',
    language: 'en',
    pubDate: posts[0]?.date || new Date(),
  });

  for (const post of posts) {
    feed.item({
      title: post.title,
      description: post.body.substring(0, 500).replace(/<[^>]*>/g, ''),
      url: `https://example.com${post.url}`,
      date: post.date,
      categories: post.tags || [],
      author: post.author || 'DodaTech',
    });
  }

  await fs.writeFile(path.join(outputDir, 'rss.xml'), feed.xml({ indent: true }));
  console.log('RSS feed generated');
}

module.exports = { generateRss };

Step 7: Deploy Script

// src/deploy.js
const { execSync } = require('child_process');
const fs = require('fs-extra');

function deploy({ dir = 'build' } = {}) {
  const buildDir = `./${dir}`;

  if (!fs.existsSync(buildDir)) {
    console.error('Build directory not found. Run `mdblog build` first.');
    process.exit(1);
  }

  // Create CNAME file for custom domain (optional)
  // fs.writeFileSync(path.join(buildDir, 'CNAME'), 'blog.example.com');

  console.log('Deploying to GitHub Pages...');
  execSync(`npx gh-pages -d ${dir}`, { stdio: 'inherit' });
  console.log('Deployed!');
}

module.exports = { deploy };

Architecture


graph TD
    A[Markdown Files] -->|front-matter parser| B[Metadata + Body]
    B -->|marked + highlight.js| C[HTML Content]
    
    D[EJS Templates] --> E[Template Engine]
    C --> E
    E --> F[Static HTML Pages]
    E --> G[index.html]
    
    H[RSS Module] --> I[rss.xml]
    
    F --> J[build/ directory]
    G --> J
    I --> J
    
    J -->|deploy script| K[GitHub Pages]
    
    style A fill:#4a90d9,color:white
    style J fill:#27ae60,color:white
    style K fill:#e74c3c,color:white

Common Errors

1. “Cannot find module ‘marked’” The package isn’t installed. Run npm install from the project root. If using a global install, ensure npm link is configured. Check package.json dependencies.

2. Highlight.js not applying to code blocks Marked applies highlighting during the highlight callback. If the language isn’t recognized, highlight falls back to highlightAuto. Load additional languages with hljs.registerLanguage('rust', require('highlight.js/lib/languages/rust')).

3. RSS feed shows empty or incorrect dates The date field in front-matter must be a parseable date string. new Date('2026-06-20') works. If the date is in an unusual format, JavaScript’s Date parser may fail. Always use ISO 8601 format: YYYY-MM-DD.

4. Generated HTML shows raw EJS tags The template file was served directly without processing through EJS. Ensure ejs.render() is called with the template string, not just written to a file. Templates must be rendered, not copied.

5. Deploy script fails with “not a git repository” gh-pages requires a git repository. Ensure your project has git init run and an origin remote configured. The deploy script creates a temporary branch for the build directory.

Practice Questions

1. Why do we parse front-matter separately from the markdown body? Front-matter contains metadata (title, date, tags) that controls how the post is rendered and sorted. Separating it allows the builder to extract this data without parsing the entire markdown body. The front-matter library handles the YAML parsing and splits the content cleanly.

2. What is the advantage of EJS over concatenating HTML strings? EJS provides loops, conditionals, includes, and variable interpolation. Concatenating HTML strings is error-prone, hard to maintain, and creates XSS vulnerabilities. Templates enforce separation of logic and presentation.

3. How does the RSS feed determine the post order? Posts are sorted by date descending (newest first) in the builder. The RSS module receives the already-sorted array. The first post’s date becomes the feed’s pubDate.

4. Challenge: Tag pages Generate a page per unique tag (e.g., /tags/nodejs.html) that lists all posts with that tag. Add tag links in the post template that point to these pages. This improves SEO and navigation.

5. Challenge: Incremental builds Track file modification times. Only rebuild posts whose source .md has changed since the last build. Keep unchanged HTML files. This speeds up builds for blogs with hundreds of posts.

FAQ

Can I use this with GitHub Pages?
Yes. Run mdblog deploy which uses gh-pages to push the build directory to the gh-pages branch. Configure GitHub Pages to serve from that branch. For a custom domain, uncomment the CNAME file in the deploy script.
How do I add an “About” page?
Create content/about.md with front-matter and body. Modify the builder to detect standalone pages (no date or with type: page). Render them differently — without the listing page inclusion, but with the main layout.
Can I change the URL structure?
Yes. The url field in each post object is used for links. Change the slug format in the builder (e.g., /blog/2026/06/slug.html). Update the template link paths accordingly.

Next Steps

  • Add Bootstrap or Tailwind CSS styling to templates
  • Explore Node.js file system APIs for watch mode
  • Learn YAML for advanced front-matter configuration
  • Check the SEO tutorial for meta tag optimization
  • Try the Portfolio Site project for Hugo-based static generation

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro