A Markdown Blog with Express and EJS — Front Matter Parsing, Loading Once at Startup, Affiliate-Safe Link Rendering, and Sitemap Generation
Build a content site where each article is a Markdown file with front matter, parsed once at startup and rendered with EJS. Covers a tiny front-matter parser, a marked renderer that marks external and sponsored links, reading-time estimates for languages without spaces, and generating sitemap.xml.
For a content site you do not need a CMS or a static-site generator. A folder of Markdown files, one parser, and two EJS templates get you a fast site that deploys anywhere Node runs. This is the structure we use for several sites.
Layout
content/en/*.md articles
views/home.ejs list page
views/post.ejs article page
services/content.js loader
app.js routes
Front matter without a library
Each article starts with a small YAML-like block. A dozen lines of code parse it:
function parseFront(raw) {
const s = raw.replace(/\r\n/g, '\n');
const m = s.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!m) return null;
const meta = {};
for (const line of m[1].split('\n')) {
const i = line.indexOf(':');
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
return { meta, body: m[2].trim() };
}
Normalising \r\n first matters on Windows, as explained in our line-endings guide.
Load once at startup
On platforms like Cloud Run each deploy starts a fresh instance, so reading the folder once and caching in memory is simpler than watching for changes:
const { marked } = require('marked');
let cache;
function load() {
if (cache) return cache;
cache = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => {
const { meta, body } = parseFront(fs.readFileSync(path.join(dir, f), 'utf8'));
return { slug: f.replace(/\.md$/, ''), ...meta, tags: (meta.tags || '').split(',').map((t) => t.trim()).filter(Boolean), html: marked.parse(body) };
}).sort((a, b) => (a.date < b.date ? 1 : -1));
return cache;
}
Links: external in a new tab, sponsored marked
A custom marked renderer lets you treat links by destination. Here external links open in a new tab and links through an affiliate redirect domain get rel="sponsored" and a class you can style:
const renderer = new marked.Renderer();
renderer.link = function (h, t, x) {
const { href, title, text } = typeof h === 'object' && h !== null ? h : { href: h, title: t, text: x };
const external = /^https?:\/\//i.test(href);
const aff = href.includes('go.example.com');
const rel = aff ? 'sponsored nofollow noopener' : external ? 'noopener' : '';
return `<a href="${href}"${external ? ' target="_blank"' : ''}${rel ? ` rel="${rel}"` : ''}${aff ? ' class="aff"' : ''}>${text}</a>`;
};
marked.use({ renderer });
The object-or-arguments check keeps it working across marked v12 and v13, which changed the renderer signature.
Reading time for Thai, Lao and similar
Word counts assume spaces. For languages written without them, count characters instead:
const noSpaces = /^(th|lo|km)$/.test(lang);
const readMin = noSpaces
? Math.round(plain.replace(/\s+/g, '').length / 1000)
: Math.round(plain.split(/\s+/).filter(Boolean).length / 220);
Routes and templates
app.get('/', (req, res) => res.render('home', { posts: load() }));
app.get('/p/:slug', (req, res) => {
const post = load().find((p) => p.slug === req.params.slug);
if (!post) return res.status(404).render('error');
res.render('post', { post });
});
In post.ejs, output the HTML unescaped with <%- post.html %> and everything else escaped with <%= %>.
Sitemap
app.get('/sitemap.xml', (req, res) => {
const rows = [['/', ''], ...load().map((p) => [`/p/${p.slug}`, p.updated || p.date])];
res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.map(([u, d]) => ` <url><loc>https://example.com${u === '/' ? '' : u}</loc>${d ? `<lastmod>${d}</lastmod>` : ''}</url>`).join('\n')}\n</urlset>\n`);
});
Add Sitemap: https://example.com/sitemap.xml to robots.txt and submit it in Search Console once.
Summary
Markdown files with a tiny front-matter parser, loaded once and cached, rendered by two EJS templates, with a link renderer that handles external and sponsored links, plus a generated sitemap. It is a few hundred lines and it scales to hundreds of articles without any build step.