- Next.js Markdown Blog: Complete Static Guide for Developers
Next.js Markdown Blog: Complete Static Guide for Developers
Build a markdown-based blog with Next.js, gray-matter and remark—static generation, SEO frontmatter, Tailwind styling…

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
I was building the Farmica platform when I realized we needed a blog system that didn't require a database or monthly CMS costs. After researching headless CMS options, API complexity, and vendor lock-in, I decided to build something simpler: a markdown-based blog where content lives in git, styling is handled by components, and everything deploys as static HTML. This guide walks you through the exact implementation I developed, which is now powering the Farmica blog.
Before diving into the code, let me explain why this solution is valuable. You get the simplicity of markdown files (anyone can write git commit a post), the control of a custom implementation (no vendor lock-in), and the performance of static generation (fast everywhere, minimal server load). If you later decide to migrate to a headless CMS, the architecture supports it—just replace the file reading with API calls.
The blog you'll build here supports YAML frontmatter for metadata, automatic date-based publishing, SEO optimization, responsive design, and beautiful prose styling. No admin panel needed.
The system works in three layers:
Content Layer: Markdown files in content/blog/ with YAML frontmatter containing title, description, image, date, author, and slug.
Processing Layer: TypeScript utilities that read markdown files, parse frontmatter, convert markdown to HTML, and filter posts by publish date.
Presentation Layer: React components that accept data as props (no hardcoded content), making it easy to switch from files to an API later.
Each part is decoupled, so you can modify one without touching the others.
Start by adding the markdown parsing libraries to your Next.js project:
npm install remark remark-html remark-parse rehype-stringify gray-matter
Or with pnpm:
pnpm add remark remark-html remark-parse rehype-stringify gray-matter
These packages handle the heavy lifting: gray-matter extracts YAML frontmatter from your markdown files, and remark converts the markdown content to HTML. This separation lets us handle metadata and content independently.
Create a TypeScript file to define the structure of your blog posts. This ensures type safety throughout your application and makes the data flow clear.
// types/blog.ts
export interface BlogPostFrontmatter {
title: string;
description: string;
image: string;
date: string;
author: string;
authorImage?: string;
slug: string;
}
export interface BlogPost extends BlogPostFrontmatter {
content: string;
htmlContent: string;
}
export interface BlogPostCardProps {
title: string;
description: string;
slug: string;
date: string;
author: string;
image: string;
}
The BlogPostFrontmatter interface matches the YAML structure in your markdown files. The BlogPost extends it with content (raw markdown) and htmlContent (converted HTML). The BlogPostCardProps defines what the card component needs for the blog listing.
Now create a utility file that converts markdown to HTML. This is straightforward with remark—we pipe the markdown through parsers and formatters.
// lib/markdown.ts
import { remark } from "remark";
import remarkHtml from "remark-html";
import remarkParse from "remark-parse";
export async function markdownToHtml(markdown: string): Promise<string> {
const result = await remark()
.use(remarkParse)
.use(remarkHtml)
.process(markdown);
return result.toString();
}
This function takes raw markdown and returns HTML. The remark() chain applies two plugins: remarkParse reads the markdown, and remarkHtml converts it to HTML. The .toString() at the end gives us a string we can safely render.
This is the core of the system—utilities that read your markdown files from disk, parse them, and return structured blog post objects.
// app/blog/_lib/posts.ts
import { readFileSync, readdirSync } from "fs";
import { join } from "path";
import matter from "gray-matter";
import { markdownToHtml } from "@/lib/markdown";
import { BlogPost, BlogPostFrontmatter } from "@/types/blog";
const blogDir = join(process.cwd(), "content", "blog");
export async function getBlogPost(slug: string): Promise<BlogPost | null> {
try {
const filePath = join(blogDir, `${slug}.md`);
const fileContent = readFileSync(filePath, "utf-8");
const { data, content } = matter(fileContent);
const htmlContent = await markdownToHtml(content);
return {
...(data as BlogPostFrontmatter),
content,
htmlContent,
};
} catch (error) {
console.error(`Error reading blog post: ${slug}`, error);
return null;
}
}
export async function getAllBlogPosts(): Promise<BlogPost[]> {
try {
const files = readdirSync(blogDir).filter((file) => file.endsWith(".md"));
const posts = await Promise.all(
files.map(async (file) => {
const slug = file.replace(".md", "");
return getBlogPost(slug);
})
);
const today = new Date();
today.setHours(0, 0, 0, 0);
return posts
.filter((post): post is BlogPost => post !== null)
.filter((post) => new Date(post.date) <= today)
.sort(
(a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
} catch (error) {
console.error("Error reading blog posts", error);
return [];
}
}
export function getBlogPostSlugs(): string[] {
try {
const files = readdirSync(blogDir).filter((file) => file.endsWith(".md"));
return files.map((file) => file.replace(".md", ""));
} catch (error) {
console.error("Error reading blog post slugs", error);
return [];
}
}
Here's what each function does:
getBlogPost(slug) reads a single markdown file, uses gray-matter to split the frontmatter from content, converts the markdown to HTML, and returns everything together. If the file doesn't exist, it returns null gracefully.
getAllBlogPosts() reads all markdown files in the blog directory, fetches each one, filters out any that failed to load, excludes posts with future dates (so you can schedule posts), and sorts by date newest first.
getBlogPostSlugs() returns just the list of slugs, which Next.js uses for static generation.
Notice the date filtering: new Date(post.date) <= today means posts only appear once their publish date arrives. This lets you write posts ahead of time without them showing up publicly.
Now we build the React components that display the blog. These components accept data as props—no hardcoded content, making it easy to swap the file-based system for an API later.
First, the metadata component that displays date and author with icons:
// components/blog/blog-meta.tsx
import { CalendarBlank, User } from "@phosphor-icons/react/dist/ssr";
export interface BlogMetaProps {
date: string;
author: string;
}
export function BlogMeta({ date, author }: BlogMetaProps) {
const formattedDate = new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<div className="flex flex-wrap gap-6 items-center text-text-secondary text-sm">
<div className="flex gap-2 items-center">
<CalendarBlank className="w-4 h-4" weight="fill" />
<span>{formattedDate}</span>
</div>
<div className="flex gap-2 items-center">
<User className="w-4 h-4" weight="fill" />
<span>{author}</span>
</div>
</div>
);
}
This component formats the date into a readable string and displays both date and author with icons. The styling uses Tailwind's responsive classes to stack on mobile.
The blog header component displays at the top of the post:
// components/blog/blog-header.tsx
import Image from "next/image";
import { BlogMeta } from "./blog-meta";
export interface BlogHeaderProps {
title: string;
description: string;
image: string;
date: string;
author: string;
}
export function BlogHeader({
title,
description,
image,
date,
author,
}: BlogHeaderProps) {
return (
<div className="space-y-6 lg:space-y-8">
<div className="space-y-4">
<h1 className="text-4xl md:text-[48px] lg:text-[56px] font-bold leading-[1.1] text-foreground">
{title}
</h1>
<p className="text-lg md:text-[17px] text-text-secondary leading-relaxed max-w-2xl">
{description}
</p>
<BlogMeta date={date} author={author} />
</div>
<div className="relative w-full aspect-video rounded-[12px] overflow-hidden">
<Image
src={image}
alt={title}
fill
className="object-cover"
priority
/>
</div>
</div>
);
}
This stacks the title, description, metadata, and featured image. The Image component from Next.js handles optimization automatically.
The content component renders the HTML markdown. This is where styling matters—we use Tailwind's arbitrary selector syntax to style HTML elements generated from markdown:
// components/blog/blog-content.tsx
"use client";
export interface BlogContentProps {
htmlContent: string;
}
export function BlogContent({ htmlContent }: BlogContentProps) {
return (
<div
className="max-w-none
[&_h1]:text-3xl [&_h1]:md:text-4xl [&_h1]:font-bold [&_h1]:text-foreground [&_h1]:leading-tight [&_h1]:mt-8 [&_h1]:mb-4
[&_h2]:text-2xl [&_h2]:md:text-3xl [&_h2]:font-bold [&_h2]:text-foreground [&_h2]:leading-tight [&_h2]:mt-8 [&_h2]:mb-4 [&_h2]:pt-6 [&_h2]:border-t [&_h2]:border-border [&_h2]:first:mt-0 [&_h2]:first:pt-0 [&_h2]:first:border-0
[&_h3]:text-xl [&_h3]:md:text-2xl [&_h3]:font-bold [&_h3]:text-foreground [&_h3]:leading-tight [&_h3]:mt-6 [&_h3]:mb-3
[&_h4]:text-lg [&_h4]:font-semibold [&_h4]:text-foreground [&_h4]:mt-5 [&_h4]:mb-2
[&_p]:text-foreground [&_p]:leading-relaxed [&_p]:text-base [&_p]:md:text-[17px] [&_p]:mb-4
[&_strong]:font-semibold [&_strong]:text-foreground
[&_em]:italic [&_em]:text-foreground
[&_a]:text-primary [&_a]:underline [&_a]:hover:opacity-80 [&_a]:transition-opacity
[&_blockquote]:border-l-4 [&_blockquote]:border-primary [&_blockquote]:bg-cream [&_blockquote]:pl-5 [&_blockquote]:pr-4 [&_blockquote]:py-4 [&_blockquote]:my-6 [&_blockquote]:rounded-r-lg [&_blockquote]:italic [&_blockquote]:text-text-secondary
[&_blockquote_p]:text-text-secondary [&_blockquote_p]:mb-0 [&_blockquote_p]:leading-relaxed
[&_code]:bg-cream [&_code]:px-2 [&_code]:py-1 [&_code]:rounded [&_code]:text-primary [&_code]:text-sm [&_code]:font-mono [&_code]:break-words
[&_pre]:bg-[#1a1a1a] [&_pre]:text-[#e0e0e0] [&_pre]:rounded-[12px] [&_pre]:overflow-x-auto [&_pre]:p-4 [&_pre]:my-6 [&_pre]:font-mono [&_pre]:text-sm [&_pre]:leading-relaxed
[&_pre_code]:bg-transparent [&_pre_code]:px-0 [&_pre_code]:py-0 [&_pre_code]:text-[#e0e0e0]
[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-4 [&_ul]:space-y-2
[&_ul_li]:text-foreground [&_ul_li]:leading-relaxed
[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-4 [&_ol]:space-y-2
[&_ol_li]:text-foreground [&_ol_li]:leading-relaxed
[&_li]:text-foreground [&_li]:leading-relaxed
[&_img]:rounded-[12px] [&_img]:my-6 [&_img]:max-w-full [&_img]:h-auto
[&_table]:w-full [&_table]:border-collapse [&_table]:my-6
[&_th]:border [&_th]:border-border [&_th]:bg-cream [&_th]:px-4 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold
[&_td]:border [&_td]:border-border [&_td]:px-4 [&_td]:py-2
[&_hr]:my-8 [&_hr]:border-t [&_hr]:border-border
space-y-4"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
);
}
The [&_selector] syntax targets elements within the div. [&_h2] means "h2 tags inside this div get these styles." This approach gives you complete control over markdown styling without relying on a prose plugin.
The author component displays author info at the end of the post:
// components/blog/blog-author.tsx
import Image from "next/image";
export interface BlogAuthorProps {
name: string;
image?: string;
bio?: string;
}
export function BlogAuthor({ name, image, bio }: BlogAuthorProps) {
return (
<div className="border-t border-border pt-8 mt-12">
<div className="flex gap-4 items-start">
{image && (
<div className="relative w-16 h-16 flex-shrink-0 rounded-full overflow-hidden">
<Image
src={image}
alt={name}
fill
className="object-cover"
/>
</div>
)}
<div className="flex-1">
<h3 className="font-semibold text-foreground text-lg">{name}</h3>
{bio && (
<p className="text-text-secondary text-sm leading-relaxed mt-1">
{bio}
</p>
)}
</div>
</div>
</div>
);
}
Finally, the card component for the blog listing:
// components/blog/blog-post-card.tsx
import Link from "next/link";
import Image from "next/image";
import { BlogMeta } from "./blog-meta";
export interface BlogPostCardProps {
title: string;
description: string;
slug: string;
date: string;
author: string;
image: string;
}
export function BlogPostCard({
title,
description,
slug,
date,
author,
image,
}: BlogPostCardProps) {
return (
<Link href={`/blog/${slug}`}>
<article className="group border border-border rounded-[12px] overflow-hidden hover:shadow-lg transition-shadow duration-300 cursor-pointer h-full flex flex-col">
<div className="relative w-full aspect-video overflow-hidden bg-gray-100">
<Image
src={image}
alt={title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="flex flex-col flex-1 p-6 lg:p-8 space-y-4">
<div className="space-y-2">
<h2 className="text-xl md:text-2xl font-bold text-foreground leading-[1.2] group-hover:text-primary transition-colors">
{title}
</h2>
<p className="text-text-secondary text-sm md:text-base leading-relaxed line-clamp-2">
{description}
</p>
</div>
<div className="mt-auto pt-4 border-t border-border">
<BlogMeta date={date} author={author} />
</div>
</div>
</article>
</Link>
);
}
The card wraps in a link, contains the image with a hover scale effect, and displays the title, description, and metadata. The mt-auto pushes the metadata to the bottom regardless of content height.
Now create the pages that display your blog. First, the listing page that shows all posts:
// app/blog/page.tsx
import { Metadata } from "next";
import { Section } from "@/components/layout/section";
import { BlogPostCard } from "@/components/blog/blog-post-card";
import { getAllBlogPosts } from "./_lib/posts";
export const metadata: Metadata = {
title: "Blog | Your Site",
description:
"Insights and tips on [your topic]. Read articles from our team.",
openGraph: {
title: "Blog | Your Site",
description:
"Insights and tips on [your topic]. Read articles from our team.",
type: "website",
},
};
export default async function BlogPage() {
const posts = await getAllBlogPosts();
return (
<Section background="cream">
<div className="space-y-12 lg:space-y-16">
<div className="space-y-4 text-center max-w-2xl mx-auto">
<h1 className="text-4xl md:text-5xl lg:text-[56px] font-bold leading-[1.1] text-foreground">
Blog
</h1>
<p className="text-lg md:text-[17px] text-text-secondary">
Articles and insights from our team
</p>
</div>
{posts.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
{posts.map((post) => (
<BlogPostCard
key={post.slug}
title={post.title}
description={post.description}
slug={post.slug}
date={post.date}
author={post.author}
image={post.image}
/>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-text-secondary text-lg">
No blog posts published yet. Check back soon!
</p>
</div>
)}
</div>
</Section>
);
}
This page fetches all posts, displays them in a responsive grid, and handles the empty state gracefully. The metadata export ensures good SEO on the listing page.
Now the dynamic post page:
// app/blog/[slug]/page.tsx
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { Section } from "@/components/layout/section";
import { BlogHeader } from "@/components/blog/blog-header";
import { BlogContent } from "@/components/blog/blog-content";
import { BlogAuthor } from "@/components/blog/blog-author";
import { getBlogPost, getBlogPostSlugs } from "../_lib/posts";
interface BlogPostPageProps {
params: Promise<{
slug: string;
}>;
}
export async function generateMetadata({
params,
}: BlogPostPageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getBlogPost(slug);
if (!post) {
return {
title: "Post not found",
};
}
return {
title: `${post.title} | Your Site Blog`,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: "article",
publishedTime: post.date,
authors: [post.author],
images: [
{
url: post.image,
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.description,
images: [post.image],
},
};
}
export async function generateStaticParams() {
const slugs = getBlogPostSlugs();
return slugs.map((slug) => ({
slug,
}));
}
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { slug } = await params;
const post = await getBlogPost(slug);
if (!post) {
notFound();
}
return (
<>
<Section background="cream">
<BlogHeader
title={post.title}
description={post.description}
image={post.image}
date={post.date}
author={post.author}
/>
</Section>
<Section background="white">
<div className="max-w-2xl mx-auto space-y-8">
<BlogContent htmlContent={post.htmlContent} />
<BlogAuthor name={post.author} image={post.authorImage} />
</div>
</Section>
</>
);
}
The key points here:
generateMetadata() creates proper Open Graph tags for social sharing and SEO. Notice the structure matches what social platforms expect.
generateStaticParams() tells Next.js which routes to pre-render. It calls getBlogPostSlugs() and creates a route for each one. This is what enables static generation—Next.js runs this at build time and creates HTML files for every post.
The page component itself is straightforward: fetch the post, or show a 404 if it doesn't exist, then render the sections.
Create the content directory and add a markdown file:
mkdir -p content/blog
Then create your first post:
// content/blog/your-first-post.md
---
title: "Your First Post Title"
description: "A brief description that appears in listings and metadata"
image: "https://example.com/image.jpg"
date: "2024-02-26"
author: "Your Name"
authorImage: "https://example.com/author.jpg"
slug: "your-first-post"
---
# Your First Post
This is the start of your markdown content. Write normally in markdown—headings, lists, bold, italics, code blocks, everything works.
## Markdown Features Supported
You can use all standard markdown features:
- **Bold text** with `**bold**`
- *Italic text* with `*italic*`
- [Links](https://example.com) with `[text](url)`
- Code blocks with triple backticks
- Blockquotes with `>`
## Blockquotes
> This is a blockquote. It will be styled with a left border and light background, making it stand out from regular text.
## Code Example
```typescript
// Your code examples appear with syntax highlighting
const greeting = "Hello, world!";
console.log(greeting);
Keep writing. Your content will appear on /blog once the build runs.
The key point: the frontmatter must match your `BlogPostFrontmatter` interface. The `date` controls when the post becomes visible (posts with future dates won't appear until that date arrives).
## Step 8: Configure Next.js
Add external image domains to your Next.js config so images from external URLs work:
```typescript
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
},
{
protocol: "https",
hostname: "example.com",
},
],
},
};
export default nextConfig;
Add patterns for any external image hosts you use. This lets Next.js optimize images from those domains.
When you run the build, Next.js uses generateStaticParams() to pre-render every post as static HTML:
npm run build
You'll see output like:
✓ Generating static pages using 9 workers (12/12)
Route (app)
├ ○ /blog
├ ● /blog/[slug]
│ └ /blog/your-first-post
The ● indicates a static page that was pre-rendered. Every post gets its own HTML file. This means your blog loads instantly, requires no server processing, and scales to any traffic level.
Deploy the built files to any static host: Vercel, Netlify, CloudFlare Pages, or traditional CDN. Because everything is pre-rendered, you don't need a Node.js server.
Just create new markdown files in content/blog/. On the next build, they'll automatically appear. No database migrations, no admin panel needed.
Extend your BlogPostFrontmatter interface to include tags: string[]. Then in getAllBlogPosts(), filter by tag before returning. The component-based design means you only change the data layer—presentation stays the same.
Collect all posts and their content at build time, generate a JSON index, and search client-side. Keeps everything static while providing search capability.
Most static blog systems use Disqus, Giscus, or Utterances for comments. These are embedded via script tags, requiring zero server infrastructure.
When you're ready for a database, replace getBlogPost() and getAllBlogPosts() with API calls. Your components don't change—they just receive data from an API instead of files. This is the power of the architecture.
You've now built a blog system that's fast, simple, and future-proof. No monthly fees, no vendor lock-in, no databases to maintain. Content lives in git alongside your code. Writers can use any markdown editor. The build process handles everything else.
As your platform grows and you need features like drafts, scheduled posts, or team collaboration, you can add them by modifying the file-based system or switching to a headless CMS—your components don't care where the data comes from.
This is what powers production blogs at companies like Vercel, Stripe, and Segment. It's proven architecture that scales from first post to thousands.
Let me know in the comments if you have questions about the implementation, and subscribe for more practical development guides.
Thanks, Matija