Building a Markdown-Based Blog System in Next.js 15
Building a Markdown-Based Blog System in Next.js 15
When building my portfolio website, I wanted a simple yet powerful blogging system that didn't require a database or complex CMS. The solution? A markdown-based blog system leveraging Next.js 15's App Router and static generation capabilities. Here's how I built it.
Why Markdown?
Markdown offers several advantages for a blog system:
- Simplicity: Write content in plain text with minimal formatting
- Version Control: Track changes with Git
- Portability: Easy to migrate or backup
- Developer-Friendly: No need for a database or admin panel
- Performance: Static generation means lightning-fast load times
Architecture Overview
The blog system consists of four main components:
- Content Storage: Markdown files with frontmatter metadata
- Blog Utilities: Functions to read and process markdown files
- Blog Listing Page: Display all blog posts with pagination
- Individual Post Page: Render individual blog posts with full content
Project Structure
portfolio/
├── content/
│ └── blogs/
│ ├── building-scalable-react-apps/
│ │ └── index.md
│ ├── mastering-css-grid/
│ │ └── index.md
│ └── typescript-tips/
│ └── index.md
├── src/
│ ├── app/
│ │ └── blog/
│ │ ├── page.tsx # Blog listing
│ │ └── [slug]/
│ │ └── page.tsx # Individual post
│ └── lib/
│ └── blog.ts # Blog utilities
└── public/
└── blog-images/ # Blog images
Implementation Details
1. Setting Up Dependencies
First, I installed the necessary packages for markdown processing:
npm install gray-matter remark remark-html
npm install --save-dev @tailwindcss/typography
Key Dependencies:
gray-matter: Parse frontmatter metadata from markdown filesremark&remark-html: Convert markdown to HTML@tailwindcss/typography: Beautiful typography styles for markdown content
2. Creating the Blog Utilities
The core of the system is the blog.ts utility file. Here's what it handles:
Type Definitions
export interface BlogPost {
slug: string
title: string
date: string
excerpt: string
author?: string
tags?: string[]
[key: string]: unknown
}
export interface BlogPostWithContent extends BlogPost {
content: string
}
Reading All Blog Posts
export function getAllBlogPosts(): BlogPost[] {
const blogsDirectory = path.join(process.cwd(), "content/blogs")
if (!fs.existsSync(blogsDirectory)) {
return []
}
const entries = fs.readdirSync(blogsDirectory, { withFileTypes: true })
const allPostsData = entries
.filter((entry) => {
if (entry.isDirectory()) {
const indexPath = path.join(blogsDirectory, entry.name, "index.md")
return fs.existsSync(indexPath)
}
return entry.isFile() && entry.name.endsWith(".md")
})
.map((entry) => {
// Process each blog entry
// Extract metadata using gray-matter
// Return BlogPost object
})
// Sort posts by date in descending order
return allPostsData.sort((a, b) => (a.date < b.date ? 1 : -1))
}
Key Features:
- Supports both folder-based (
slug/index.md) and flat file (slug.md) structures - Extracts frontmatter metadata using
gray-matter - Automatically sorts posts by date
- Returns empty array if directory doesn't exist (graceful error handling)
Pagination Support
export function getPaginatedBlogPosts(
page: number = 1,
postsPerPage: number = 6
) {
const allPosts = getAllBlogPosts()
const totalPosts = allPosts.length
const totalPages = Math.ceil(totalPosts / postsPerPage)
const startIndex = (page - 1) * postsPerPage
const endIndex = startIndex + postsPerPage
const posts = allPosts.slice(startIndex, endIndex)
return {
posts,
currentPage: page,
totalPages,
totalPosts,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
}
}
This function makes pagination effortless by returning everything needed for navigation.
Processing Individual Posts
export async function getBlogPostBySlug(
slug: string
): Promise<BlogPostWithContent | null> {
try {
// Try folder structure first (slug/index.md)
let fullPath = path.join(blogsDirectory, slug, "index.md")
// Fall back to flat file structure (slug.md)
if (!fs.existsSync(fullPath)) {
fullPath = path.join(blogsDirectory, `${slug}.md`)
}
const fileContents = fs.readFileSync(fullPath, "utf8")
const { data, content } = matter(fileContents)
// Fix relative image paths
const contentWithFixedImages = content.replace(
/!\[([^\]]*)\]\(\.\/images\/([^)]+)\)/g,
``
)
// Convert markdown to HTML
const processedContent = await remark()
.use(html)
.process(contentWithFixedImages)
const contentHtml = processedContent.toString()
return {
slug,
title: data.title || "Untitled",
date: data.date || new Date().toISOString(),
excerpt: data.excerpt || "",
content: contentHtml,
...data,
} as BlogPostWithContent
} catch {
return null
}
}
Key Features:
- Flexible file structure support
- Automatic image path resolution for blog-specific images
- Markdown to HTML conversion using
remark - Graceful error handling with null return
3. Blog Listing Page
The blog listing page (app/blog/page.tsx) displays all posts with:
- Featured Post: The latest post gets special treatment with a larger card
- Grid Layout: Remaining posts displayed in a responsive grid
- Pagination: Navigate through multiple pages of posts
- Metadata Display: Date, author, reading time, and tags
export default async function BlogPage({ searchParams }: BlogPageProps) {
const resolvedParams = await searchParams
const currentPage = Number(resolvedParams.page) || 1
const { posts, totalPages, hasNextPage, hasPrevPage, totalPosts } =
getPaginatedBlogPosts(currentPage, 6)
const featuredPost = posts[0]
const regularPosts = posts.slice(1)
// Render featured post + grid of regular posts
}
Design Features:
- Staggered animations for visual appeal
- Hover effects with scale and translation transforms
- Icon integration using Lucide React
- Responsive design with Tailwind CSS
4. Individual Blog Post Page
The individual post page (app/blog/[slug]/page.tsx) provides:
- Static Generation: Pre-renders all blog posts at build time
- Reading Progress: Visual indicator of scroll progress
- Reading Time: Calculated based on word count
- Typography Styles: Beautiful prose styling with
@tailwindcss/typography
export async function generateStaticParams() {
const slugs = getAllBlogSlugs()
return slugs.map((slug) => ({ slug }))
}
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const resolvedParams = await params
const post = await getBlogPostBySlug(resolvedParams.slug)
if (!post) {
notFound()
}
const readingTime = calculateReadingTime(post.content)
// Render post with full content
}
Reading Time Calculator:
const calculateReadingTime = (content: string): number => {
const wordsPerMinute = 200
const textContent = content.replace(/<[^>]*>/g, "")
const wordCount = textContent.split(/\s+/).length
return Math.ceil(wordCount / wordsPerMinute)
}
5. Prose Styling
One of the most important aspects is making the content readable. I used Tailwind's typography plugin with extensive customization:
<div
className="prose prose-lg dark:prose-invert max-w-none
prose-headings:font-bold
prose-h2:text-3xl prose-h2:border-b
prose-p:leading-relaxed
prose-a:text-blue-600 dark:prose-a:text-blue-400
prose-code:text-pink-600 dark:prose-code:text-pink-400
prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5
prose-pre:bg-slate-900 dark:prose-pre:bg-slate-950
prose-blockquote:border-l-4 prose-blockquote:border-primary
prose-img:rounded-lg prose-img:shadow-lg"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
This creates a beautiful reading experience with:
- Proper heading hierarchy
- Styled code blocks
- Highlighted inline code
- Beautiful blockquotes
- Rounded images with shadows
Content Structure
Each blog post uses YAML frontmatter for metadata:
---
title: "Your Blog Post Title"
date: "2025-12-14"
excerpt: "A brief description of your post"
author: "Your Name"
tags: ["Next.js", "TypeScript", "React"]
---
# Your Content Here
Write your markdown content...
Advanced Features
Image Handling
Blog-specific images are stored in public/blog-images/{slug}/ and automatically resolved:
const contentWithFixedImages = content.replace(
/!\[([^\]]*)\]\(\.\/images\/([^)]+)\)/g,
``
)
This allows you to use relative paths in your markdown:

Which gets converted to:

Reading Progress Indicator
I created a ReadingProgress component that tracks scroll position:
// Shows a progress bar at the top of the page
// Fills from 0% to 100% as user scrolls
SEO Optimization
Dynamic metadata generation for each post:
export async function generateMetadata({ params }: BlogPostPageProps) {
const post = await getBlogPostBySlug(params.slug)
return {
title: post.title,
description: post.excerpt,
}
}
Performance Benefits
The markdown-based approach offers significant performance advantages:
- Static Generation: All pages are pre-rendered at build time
- No Database Queries: Everything is read from the filesystem
- Edge-Ready: Can be deployed to edge networks easily
- Instant Navigation: No loading states for content
- SEO-Friendly: Fully indexable by search engines
Deployment Considerations
When deploying, ensure your build process includes:
- All markdown files are included in the build
- Static params are generated for all blog posts
- Images are properly optimized
- Typography plugin is configured in Tailwind
// package.json
{
"scripts": {
"build": "next build",
"start": "next start"
},
"dependencies": {
"gray-matter": "^4.0.3",
"remark": "^15.0.1",
"remark-html": "^16.0.1"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19"
}
}
Future Enhancements
Potential improvements to consider:
- Search Functionality: Add full-text search across all posts
- Tag Filtering: Filter posts by tags
- RSS Feed: Generate an RSS feed for subscribers
- Table of Contents: Auto-generate TOC from headings
- Related Posts: Show related content based on tags
- Code Syntax Highlighting: Integrate
rehype-highlightorprism - MDX Support: Enable React components in markdown files
Conclusion
Building a markdown-based blog system in Next.js 15 is straightforward and provides excellent performance. The file-based approach keeps things simple while offering all the features you need for a professional blog.
The key benefits are:
✅ Simple content management - just write markdown files
✅ Version control friendly - track changes with Git
✅ Lightning fast - static generation means optimal performance
✅ SEO optimized - pre-rendered pages with proper metadata
✅ Developer friendly - no database or CMS to manage
Whether you're building a personal blog, documentation site, or content-heavy application, this approach offers a perfect balance of simplicity and functionality.
Source Code: The complete implementation is available in my portfolio repository.
Have questions? Feel free to reach out or open an issue on GitHub!