Markdown blog
The canonical static-site use case: markdown files in a folder, one static page per post, an RSS feed, and zero client-side JavaScript. Full source in examples/blog.
What you get
- A home page listing posts, newest first
/blog/[slug]— one static HTML page per markdown file viagenerateStaticParamsrss.xml— generated by sitelo from therssconfigsitemap.xml— enabled by settingsite- Zero JS shipped — markdown parsing happens at build time in Node
Project layout
my-blog/
sitelo.config.js
content/
hello-world.md # posts: frontmatter + markdown
why-static.md
src/
lib/
posts.js # read content/, parse frontmatter, render markdown
index.ht.js # / — post list
blog/
[slug].ht.js # /blog/:slug — one page per post
css/
styles.cssexport default {
site: 'https://example.com',
rss: {
site: 'https://example.com',
title: 'My Blog',
description: 'Latest posts',
routePrefix: '/blog', // every /blog/* page becomes a feed item
},
}1. Write posts as markdown
Posts live in content/ — outside src/, so sitelo never treats them as pages or assets. Frontmatter is plain key: value lines:
---
title: Hello, world
date: 2026-08-01
description: The obligatory first post.
---
This blog is a folder of markdown files rendered to static HTML.2. Read and render them in Node
A small server-only module reads the folder, parses frontmatter, and renders markdown with marked. Because nothing in the HTML references this module, it never ships to the browser.
import { readdir, readFile } from 'node:fs/promises'
import { marked } from 'marked'
// Posts live outside src/ so they're never treated as pages or assets.
const CONTENT_DIR = new URL('../../content/', import.meta.url)
/** Tiny frontmatter parser — `key: value` lines between --- fences. */
function parseFrontmatter(raw) {
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw)
if (!match) return { meta: {}, body: raw }
const meta = {}
for (const line of match[1].split('\n')) {
const colon = line.indexOf(':')
if (colon === -1) continue
meta[line.slice(0, colon).trim()] = line.slice(colon + 1).trim()
}
return { meta, body: raw.slice(match[0].length) }
}
/** All posts, newest first. Runs in Node at build/dev time only. */
export async function getPosts() {
const files = (await readdir(CONTENT_DIR)).filter((file) =>
file.endsWith('.md'),
)
const posts = await Promise.all(
files.map(async (file) => {
const raw = await readFile(new URL(file, CONTENT_DIR), 'utf8')
const { meta, body } = parseFrontmatter(raw)
return {
slug: file.replace(/\.md$/, ''),
title: meta.title ?? file,
date: meta.date ?? '1970-01-01',
description: meta.description ?? '',
html: marked.parse(body),
}
}),
)
return posts.sort((a, b) => b.date.localeCompare(a.date))
}
export async function getPost(slug) {
const posts = await getPosts()
return posts.find((post) => post.slug === slug) ?? null
}3. List posts on the home page
import { getPosts } from './lib/posts.js'
export async function data() {
return { posts: await getPosts() }
}
export default ({ data }) => `
<html lang="en">
<head>
<title>My Blog</title>
<link rel="stylesheet" href="/css/styles.css">
<link rel="alternate" type="application/rss+xml" title="My Blog" href="/rss.xml">
</head>
<body>
<h1>My Blog</h1>
<ul class="posts">
${data.posts
.map(
(post) => `
<li>
<a href="/blog/${post.slug}">${post.title}</a>
<time datetime="${post.date}">${post.date}</time>
<p>${post.description}</p>
</li>`,
)
.join('')}
</ul>
<p><a href="/rss.xml">RSS feed</a></p>
</body>
</html>
`import {
html, head, title, link, body, h1, ul, li, a, time, p,
} from 'javascript-to-html'
import { getPosts } from './lib/posts.js'
export async function data() {
return { posts: await getPosts() }
}
export default ({ data }) =>
html({ lang: 'en' },
head(
title('My Blog'),
link({ rel: 'stylesheet', href: '/styles.css' }),
link({
rel: 'alternate',
type: 'application/rss+xml',
title: 'My Blog',
href: '/rss.xml',
}),
),
body(
h1('My Blog'),
ul({ class: 'posts' },
...data.posts.map((post) =>
li(
a({ href: `/blog/${post.slug}` }, post.title),
time({ datetime: post.date }, post.date),
p(post.description),
),
),
),
p(a({ href: '/rss.xml' }, 'RSS feed')),
),
)import { getPosts } from './lib/posts.js'
export async function data() {
return { posts: await getPosts() }
}
export default function Home({ data }) {
return (
<html lang="en">
<head>
<title>My Blog</title>
<link rel="stylesheet" href="/css/styles.css" />
<link
rel="alternate"
type="application/rss+xml"
title="My Blog"
href="/rss.xml"
/>
</head>
<body>
<h1>My Blog</h1>
<ul className="posts">
{data.posts.map((post) => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
<time dateTime={post.date}>{post.date}</time>
<p>{post.description}</p>
</li>
))}
</ul>
<p><a href="/rss.xml">RSS feed</a></p>
</body>
</html>
)
}4. One static page per post
generateStaticParams returns every slug at build time; data() loads the matching post for each page.
import { getPost, getPosts } from '../lib/posts.js'
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export async function data({ params }) {
const post = await getPost(params.slug)
if (!post) throw new Error(`Post not found: ${params.slug}`)
return { post }
}
export default ({ data }) => {
const { post } = data
return `
<html lang="en">
<head>
<title>${post.title} — My Blog</title>
<meta name="description" content="${post.description}">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<article>
<p><a href="/">← All posts</a></p>
<h1>${post.title}</h1>
<time datetime="${post.date}">${post.date}</time>
${post.html}
</article>
</body>
</html>
`
}import {
html, head, title, meta, link, body, article, p, a, h1, time,
} from 'javascript-to-html'
import { getPost, getPosts } from '../lib/posts.js'
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export async function data({ params }) {
const post = await getPost(params.slug)
if (!post) throw new Error(`Post not found: ${params.slug}`)
return { post }
}
export default ({ data }) => {
const { post } = data
return html({ lang: 'en' },
head(
title(`${post.title} — My Blog`),
meta({ name: 'description', content: post.description }),
link({ rel: 'stylesheet', href: '/styles.css' }),
),
body(
article(
p(a({ href: '/' }, '← All posts')),
h1(post.title),
time({ datetime: post.date }, post.date),
post.html,
),
),
)
}import { getPost, getPosts } from '../lib/posts.js'
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export async function data({ params }) {
const post = await getPost(params.slug)
if (!post) throw new Error(`Post not found: ${params.slug}`)
return { post }
}
export default function Post({ data }) {
const { post } = data
return (
<html lang="en">
<head>
<title>{post.title} — My Blog</title>
<meta name="description" content={post.description} />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<article>
<p><a href="/">← All posts</a></p>
<h1>{post.title}</h1>
<time datetime={post.date}>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
</body>
</html>
)
}5. RSS for free
With the rss config above, sitelo build emits dist/rss.xml with an item for every page under /blog — no extra code.