Build a Static Blog Generator from Markdown in Node.js (Step by Step)
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
- Node.js 18+ installed
- JavaScript ES6+ familiarity
- Basic HTML and CSS knowledge
- Familiarity with Markdown format
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 templatesProject 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.jsonStep 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' %> ·
<%= 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
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