Last tested: December 2025 | Hono: 4.x | Node.js: 20+ | Platform: Cloudflare Workers
I built this blog with Gatsby. It works. It's also 200+ dependencies, a 2-minute build, and breaks every time I update a plugin. For a simple markdown blog, that's absurd.
If you're starting fresh in 2025, there's a better way. Hono is a tiny web framework that runs on Cloudflare Workers. Combine it with markdown files and you get a blog that deploys in seconds, costs nothing to host, and doesn't require you to debug webpack config at 2am.
Here's everything you need to build one from scratch.
What We're Building
A markdown blog with:
- Posts written in markdown with YAML frontmatter
- A list page showing all posts
- Individual post pages with full content
- Proper SEO tags and schema markup
- Deploy to Cloudflare Workers for free
Total size when deployed: around 10KB plus your content. No client-side JavaScript required.
Prerequisites
Before you start, make sure you have Node.js 18 or higher installed, a Cloudflare account (free tier works), and basic familiarity with TypeScript. You'll also need the Wrangler CLI, which we'll install in a moment.
Project Structure
Create your project folder and set up this structure:
your-blog/
├── content/
│ └── blog/
│ ├── my-first-post.md
│ └── another-post.md
├── src/
│ ├── index.ts
│ ├── routes/
│ │ └── blog.ts
│ └── templates/
│ └── blog.ts
├── package.json
├── tsconfig.json
└── wrangler.tomlThe content/blog folder holds your markdown posts. The src folder contains the Hono application code. Simple.
Install Dependencies
npm init -y
npm install hono marked
npm install -D @types/node typescript wranglerThat's three production dependencies. Compare that to the 847 packages in a typical Gatsby project.
How Markdown Posts Work
Each blog post is a markdown file with YAML frontmatter at the top. The frontmatter contains metadata like title, date, and description. Everything after the second --- is the post content.
---
title: My First Blog Post
slug: my-first-post
date: 2024-12-22
description: A brief description for SEO and previews.
author: Your Name
tags: [javascript, tutorial]
---
# My First Blog Post
Your markdown content goes here.
## Subheading
More content with **bold** and *italic* text.
- Bullet points
- Work great
```javascript
// Code blocks too
console.log('Hello, world!');
The frontmatter gets parsed into structured data. The markdown body gets converted to HTML. Together they form a complete blog post.
## The Blog Routes
Create `src/routes/blog.ts`. This file handles parsing markdown, storing posts, and defining the routes.
```typescript
import { Hono } from 'hono';
import { marked } from 'marked';
export interface BlogPost {
slug: string;
title: string;
date: string;
description: string;
author?: string;
tags?: string[];
content: string;
htmlContent?: string;
}
function parseFrontmatter(markdown: string): {
frontmatter: Record<string, any>;
content: string;
} {
const match = markdown.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) {
return { frontmatter: {}, content: markdown };
}
const [, frontmatterStr, content] = match;
const frontmatter: Record<string, any> = {};
for (const line of frontmatterStr.split('\n')) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
let value = line.slice(colonIndex + 1).trim();
if (value.startsWith('[') && value.endsWith(']')) {
value = value.slice(1, -1).split(',').map(v => v.trim()) as any;
}
frontmatter[key] = value;
}
}
return { frontmatter, content };
}The parseFrontmatter function is simple. It splits the file on the --- markers, then parses the YAML-like frontmatter line by line. It handles arrays like [tag1, tag2] by splitting on commas. Nothing fancy, but it works for 95% of use cases.
Storing Posts
Cloudflare Workers can't read the filesystem at runtime. Your posts need to be embedded in the code at build time. Here's how:
const BLOG_POSTS: Map<string, BlogPost> = new Map();
const EMBEDDED_POSTS: Record<string, string> = {
'my-first-post': `---
title: My First Post
slug: my-first-post
date: 2024-12-22
description: This is my first blog post.
author: Your Name
tags: [intro]
---
# My First Post
Welcome to my blog!
`,
// Add more posts here
};
function initializePosts(): void {
if (BLOG_POSTS.size > 0) return;
for (const [slug, markdown] of Object.entries(EMBEDDED_POSTS)) {
const { frontmatter, content } = parseFrontmatter(markdown);
BLOG_POSTS.set(slug, {
slug: frontmatter.slug || slug,
title: frontmatter.title || 'Untitled',
date: frontmatter.date || new Date().toISOString().split('T')[0],
description: frontmatter.description || '',
author: frontmatter.author,
tags: frontmatter.tags,
content,
htmlContent: marked(content) as string,
});
}
}Yes, embedding posts as strings in your code is ugly. But it works and it's simple. If you have many posts, I'll show you how to automate this with a build script later.
Getting and Listing Posts
Add these helper functions and the Hono routes:
export function getAllPosts(): BlogPost[] {
initializePosts();
return Array.from(BLOG_POSTS.values())
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
export function getPost(slug: string): BlogPost | undefined {
initializePosts();
return BLOG_POSTS.get(slug);
}
export const blog = new Hono();
blog.get('/', (c) => {
const posts = getAllPosts();
const { getBlogListPage } = require('../templates/blog');
return c.html(getBlogListPage(posts));
});
blog.get('/:slug', (c) => {
const slug = c.req.param('slug');
const post = getPost(slug);
if (!post) {
return c.notFound();
}
const { getBlogPostPage } = require('../templates/blog');
return c.html(getBlogPostPage(post));
});Two routes total. One for the list page at /blog, one for individual posts at /blog/my-post-slug. Hono makes this straightforward.
HTML Templates
Create src/templates/blog.ts for the HTML templates. I'm including complete templates with CSS so you have something that looks decent out of the box.
import type { BlogPost } from '../routes/blog';
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
export function getBlogListPage(posts: BlogPost[]): string {
const postCards = posts.map(post => `
<article class="post-card">
<a href="/blog/${post.slug}">
<h2>${post.title}</h2>
<time datetime="${post.date}">${formatDate(post.date)}</time>
<p>${post.description}</p>
${post.tags ? `
<div class="tags">
${post.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
</div>
` : ''}
</a>
</article>
`).join('');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog | Your Site</title>
<meta name="description" content="Read our latest articles and insights.">
<link rel="canonical" href="https://yoursite.com/blog">
<style>${getStyles()}</style>
</head>
<body>
<header>
<nav>
<a href="/" class="logo">Your Site</a>
<a href="/blog">Blog</a>
</nav>
</header>
<main class="blog-list">
<h1>Blog</h1>
<div class="posts">${postCards}</div>
</main>
<footer>
<p>© ${new Date().getFullYear()} Your Site</p>
</footer>
</body>
</html>`;
}The single post template needs proper SEO tags. Search engines care about meta descriptions, canonical URLs, and Open Graph tags. The JSON-LD schema helps Google understand your content for rich snippets.
export function getBlogPostPage(post: BlogPost): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${post.title} | Your Site</title>
<meta name="description" content="${post.description}">
<link rel="canonical" href="https://yoursite.com/blog/${post.slug}">
<meta property="og:type" content="article">
<meta property="og:title" content="${post.title}">
<meta property="og:description" content="${post.description}">
<meta property="og:url" content="https://yoursite.com/blog/${post.slug}">
<meta property="article:published_time" content="${post.date}">
${post.author ? `<meta property="article:author" content="${post.author}">` : ''}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "${post.title}",
"description": "${post.description}",
"datePublished": "${post.date}",
${post.author ? `"author": { "@type": "Person", "name": "${post.author}" },` : ''}
"url": "https://yoursite.com/blog/${post.slug}"
}
</script>
<style>${getStyles()}</style>
</head>
<body>
<header>
<nav>
<a href="/" class="logo">Your Site</a>
<a href="/blog">Blog</a>
</nav>
</header>
<main class="blog-post">
<article>
<header class="post-header">
<h1>${post.title}</h1>
<div class="post-meta">
<time datetime="${post.date}">${formatDate(post.date)}</time>
${post.author ? `<span class="author">by ${post.author}</span>` : ''}
</div>
${post.tags ? `
<div class="tags">
${post.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
</div>
` : ''}
</header>
<div class="post-content">${post.htmlContent}</div>
</article>
<nav class="post-nav">
<a href="/blog">← Back to Blog</a>
</nav>
</main>
<footer>
<p>© ${new Date().getFullYear()} Your Site</p>
</footer>
</body>
</html>`;
}The CSS
I'm including complete styles so you don't deploy something ugly. Add this function to your templates file:
function getStyles(): string {
return `
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1a202c;
background: #fafafa;
}
header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
}
header nav {
max-width: 800px;
margin: 0 auto;
display: flex;
gap: 2rem;
align-items: center;
}
header .logo {
font-weight: 700;
font-size: 1.25rem;
color: #1a202c;
text-decoration: none;
}
header a { color: #4a5568; text-decoration: none; }
header a:hover { color: #2d3748; }
main {
max-width: 800px;
margin: 0 auto;
padding: 3rem 2rem;
}
.blog-list h1 { font-size: 2.5rem; margin-bottom: 2rem; }
.posts { display: flex; flex-direction: column; gap: 1.5rem; }
.post-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
transition: box-shadow 0.2s;
}
.post-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.post-card a { text-decoration: none; color: inherit; }
.post-card h2 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #2d3748; }
.post-card time { font-size: 0.875rem; color: #718096; }
.post-card p { margin-top: 0.5rem; color: #4a5568; }
.tags { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.tag {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: #edf2f7;
color: #4a5568;
border-radius: 4px;
}
.post-header {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.post-header h1 { font-size: 2.5rem; line-height: 1.2; margin-bottom: 1rem; }
.post-meta { font-size: 0.9rem; color: #718096; display: flex; gap: 1rem; }
.post-content { font-size: 1.125rem; line-height: 1.8; }
.post-content h2 { font-size: 1.75rem; margin-top: 2rem; margin-bottom: 1rem; }
.post-content h3 { font-size: 1.375rem; margin-top: 2rem; margin-bottom: 1rem; }
.post-content p { margin-bottom: 1.25rem; }
.post-content ul, .post-content ol { margin-bottom: 1.25rem; padding-left: 1.5rem; }
.post-content li { margin-bottom: 0.5rem; }
.post-content a { color: #3182ce; }
.post-content a:hover { text-decoration: underline; }
.post-content code {
font-family: 'SF Mono', Consolas, monospace;
font-size: 0.9em;
background: #f1f5f9;
padding: 0.2em 0.4em;
border-radius: 4px;
}
.post-content pre {
background: #1e293b;
color: #e2e8f0;
padding: 1.25rem;
border-radius: 8px;
overflow-x: auto;
margin-bottom: 1.25rem;
}
.post-content pre code { background: none; padding: 0; color: inherit; }
.post-content blockquote {
border-left: 4px solid #e2e8f0;
padding-left: 1rem;
margin: 1.25rem 0;
font-style: italic;
color: #4a5568;
}
.post-nav {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid #e2e8f0;
}
.post-nav a { color: #3182ce; text-decoration: none; }
.post-nav a:hover { text-decoration: underline; }
footer { text-align: center; padding: 2rem; color: #718096; font-size: 0.875rem; }
@media (max-width: 640px) {
main { padding: 2rem 1rem; }
.blog-list h1, .post-header h1 { font-size: 2rem; }
.post-content { font-size: 1rem; }
}
`;
}The Main App
Create src/index.ts to wire everything together:
import { Hono } from 'hono';
import { blog } from './routes/blog';
const app = new Hono();
app.route('/blog', blog);
app.get('/', (c) => c.text('Welcome to my blog!'));
export default app;That's the entire application entry point. Three lines of meaningful code.
Wrangler Configuration
Create wrangler.toml in your project root:
name = "your-blog"
main = "src/index.ts"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]The compatibility_date locks your Workers runtime version. The nodejs_compat flag enables Node.js compatibility mode so the marked package works correctly.
Deploy
# Local development
npx wrangler dev
# Deploy to Cloudflare
npx wrangler deployThe first deploy prompts you to log in to Cloudflare. After that, deployments take about 5 seconds. Your blog is live at your-blog.your-account.workers.dev.
Adding New Posts
Since Workers can't read files at runtime, you have three options for managing content.
Option 1: Embed posts in code
The simplest approach. Just add more entries to EMBEDDED_POSTS. Good for small blogs with a few posts.
Option 2: Build-time bundling
Write a script that reads your markdown files and generates TypeScript. Create scripts/bundle-posts.ts:
import { readdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
const postsDir = './content/blog';
const outputFile = './src/generated/posts.ts';
const files = readdirSync(postsDir).filter(f => f.endsWith('.md'));
const posts: Record<string, string> = {};
for (const file of files) {
const slug = file.replace('.md', '');
const content = readFileSync(join(postsDir, file), 'utf-8');
posts[slug] = content;
}
writeFileSync(outputFile, `export const POSTS = ${JSON.stringify(posts, null, 2)};`);
console.log(`Bundled ${files.length} posts`);Run it before deploying:
npx tsx scripts/bundle-posts.ts && npx wrangler deployOption 3: Use R2 or KV
For truly dynamic content, store markdown in Cloudflare R2 or Workers KV. Your route handler fetches content at request time:
blog.get('/:slug', async (c) => {
const slug = c.req.param('slug');
const r2 = c.env.BLOG_BUCKET;
const object = await r2.get(`posts/${slug}.md`);
if (!object) return c.notFound();
const markdown = await object.text();
const { frontmatter, content } = parseFrontmatter(markdown);
const html = marked(content);
// Render template...
});This adds complexity but lets you update content without redeploying code. Good for high-volume blogs or multiple authors.
Optional: RSS Feed
Every blog should have an RSS feed. Add this route:
blog.get('/rss.xml', (c) => {
const posts = getAllPosts();
const items = posts.map(post => `
<item>
<title>${post.title}</title>
<link>https://yoursite.com/blog/${post.slug}</link>
<description>${post.description}</description>
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
<guid>https://yoursite.com/blog/${post.slug}</guid>
</item>
`).join('');
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Your Blog</title>
<link>https://yoursite.com/blog</link>
<description>Your blog description</description>
${items}
</channel>
</rss>`;
return c.text(rss, 200, { 'Content-Type': 'application/rss+xml' });
});Optional: Sitemap
Search engines appreciate sitemaps. Add another route:
blog.get('/sitemap.xml', (c) => {
const posts = getAllPosts();
const urls = posts.map(p => `
<url>
<loc>https://yoursite.com/blog/${p.slug}</loc>
<lastmod>${p.date}</lastmod>
</url>
`).join('');
return c.text(`<?xml version="1.0"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://yoursite.com/blog</loc>
</url>
${urls}
</urlset>
`, 200, { 'Content-Type': 'application/xml' });
});What You Get
When you're done, you have a blog that:
- Deploys in 5 seconds, not 2 minutes
- Runs on Cloudflare's global edge network for free
- Has no client-side JavaScript unless you add it
- Loads fast because there's nothing to load
- Uses markdown files you can edit in any text editor
- Has proper SEO with schema markup built in
- Costs $0 unless you get millions of requests
The total code is around 300 lines of TypeScript. You can read and understand all of it in an afternoon.
When to Use This vs Gatsby/Next.js
Use Hono on Workers when you want a simple blog, you don't need client-side interactivity, you value deployment speed and simplicity, and you're tired of debugging build tools.
Use Gatsby or Next.js when you need a complex site with many pages, you want plugins for image optimization and GraphQL, you need client-side React components, or you're already deep in that ecosystem.
There's no shame in using a big framework when you need it. But for a blog that's mostly words and code snippets, 10KB of Hono beats 10MB of Gatsby every time.
Next Steps
Once you have the basics working, consider adding syntax highlighting with Prism.js, a dark mode toggle, comments using something like Giscus, full-text search with a client-side library, or analytics using a privacy-friendly service.
But start simple. Get your words on the internet first. You can add features later.
Related Guides
Ready to deploy your Hono blog? Check out these guides:
Deploy Your Static Site to Cloudflare Workers covers the full deployment process including custom domains and GitHub Actions CI/CD.
Move Your DNS from GoDaddy to Cloudflare helps you get your domain on Cloudflare for better performance.
Content-Only SEO Experiment follows a new site with zero backlinks to see if content alone can rank—relevant if you're building a blog to attract organic traffic.
Questions about building with Hono? Drop a comment below. I'm actively using this setup for side projects and can share more specific examples if there's interest.
Fred
AUTHORFull-stack developer with 10+ years building production applications. I've been deploying to Cloudflare's edge network since Workers launched in 2017.

