Building a Markdown-Based Blog System with Next.js

Learn how we built a simple, performant blog system for qz-l using markdown files and Next.js static generation

November 17, 2025By qz-l team

Building a Markdown-Based Blog System with Next.js

When we decided to add a blog to qz-l, we wanted something simple, fast, and easy to maintain. No complex databases, no heavy CMS—just markdown files and static generation.

Why Markdown?

Markdown is perfect for developer-focused projects because:

  • Simple format: Easy to write and version control
  • No database needed: Store posts as files in your repository
  • Fast: Static generation means instant page loads
  • Flexible: Can be extended with custom plugins
  • Searchable: All content is in plain text

Our Architecture

File Structure

app/blog/
├── page.tsx              # Blog listing page
├── [slug]/
│   └── page.tsx          # Individual blog post page
└── posts/
    ├── welcome.md
    ├── markdown-blog-setup.md
    └── your-post.md

Frontmatter Format

Each markdown file starts with YAML frontmatter containing metadata:

---
title: Your Post Title
description: Brief description for listings
date: 2025-11-16
author: Your Name
---

# Your Post Content Here

Key Technologies

1. gray-matter

Parses frontmatter from markdown files:

import matter from 'gray-matter';

const { data, content } = matter(fileContent);
// data = { title, date, author, description }
// content = markdown content

2. marked

Converts markdown to HTML:

import { marked } from 'marked';

const html = await marked(markdownContent);

3. Next.js Static Generation

Blog posts are pre-rendered at build time for maximum performance:

export async function generateStaticParams() {
  const posts = getAllBlogPosts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

Implementation Highlights

Getting All Posts

export async function getAllBlogPosts(): Promise<BlogPost[]> {
  const postsDirectory = join(process.cwd(), 'app/blog/posts');
  const files = readdirSync(postsDirectory);
  
  const posts = files
    .filter((file) => file.endsWith('.md'))
    .map((file) => {
      const content = readFileSync(join(postsDirectory, file), 'utf-8');
      const { data, content: body } = matter(content);
      
      return {
        slug: file.replace('.md', ''),
        title: data.title,
        date: new Date(data.date),
        author: data.author,
        description: data.description,
        content: body,
      };
    })
    .sort((a, b) => b.date.getTime() - a.date.getTime());
  
  return posts;
}

Blog Listing Page

Display all posts with metadata:

export default async function BlogPage() {
  const posts = await getAllBlogPosts();
  
  return (
    <div className="max-w-4xl mx-auto px-4">
      <h1>Blog</h1>
      <div className="grid gap-6">
        {posts.map((post) => (
          <Link href={`/blog/${post.slug}`} key={post.slug}>
            <article className="p-6 border rounded-lg hover:shadow-lg">
              <h2>{post.title}</h2>
              <p className="text-gray-600">{post.description}</p>
              <time>{post.date.toLocaleDateString()}</time>
            </article>
          </Link>
        ))}
      </div>
    </div>
  );
}

Individual Post Page

export default async function PostPage({ 
  params 
}: { 
  params: { slug: string } 
}) {
  const post = await getBlogPostBySlug(params.slug);
  
  return (
    <article className="max-w-2xl mx-auto">
      <h1>{post.title}</h1>
      <time>{post.date.toLocaleDateString()}</time>
      <div 
        className="prose"
        dangerouslySetInnerHTML={{ __html: post.htmlContent }}
      />
    </article>
  );
}

Benefits of This Approach

Zero Database: Simpler deployment, no migrations
Version Control: Posts are tracked in git
Fast Performance: Static generation means instant loads
Developer Friendly: Write posts in your IDE
Easy Backup: Posts are just files in your repository
Scalable: Works perfectly for 10 or 1000 posts
SEO Friendly: Static HTML is great for search engines

Future Enhancements

As your blog grows, you can add:

  • RSS Feed: Auto-generate from markdown posts
  • Search: Index posts and implement full-text search
  • Tags/Categories: Organize posts by topic
  • Comments: Integration with services like Giscus
  • Email Notifications: Notify subscribers of new posts
  • Analytics: Track popular posts

Getting Started

To add a new post to qz-l:

  1. Create a new .md file in app/blog/posts/
  2. Add frontmatter with metadata
  3. Write your content in markdown
  4. Deploy—the post appears automatically!

Conclusion

This markdown-based approach gives us the best of both worlds: simplicity of static files with the power of a modern framework. It's perfect for projects that want a blog without the overhead of a full CMS.

If you're building a developer-focused product, we highly recommend this approach. It's simple, maintainable, and scales beautifully.

Happy blogging! 📝


Want to learn more? Check out the gray-matter and marked documentation for more customization options.

Comments