Umang.

How to Build a High-Performance Markdown Blog with Next.js

Cover Image for How to Build a High-Performance Markdown Blog with Next.js

In the modern web landscape, performance and SEO are the pillars of a successful blog. While there are many CMS options available, building a Markdown-based blog with Next.js offers a developer-centric workflow, near-instant load times, and total control over your data.

In this guide, we will build a production-ready blog using the Next.js App Router, TypeScript, and Tailwind CSS.

Why Next.js for Markdown Blogs?

Next.js is uniquely suited for content-heavy sites due to its Static Site Generation (SSG) capabilities. By pre-rendering Markdown files into HTML at build time, you get:

Blazing Speed: Minimal JavaScript and no database queries at runtime.

Superior SEO: Search engines receive fully rendered HTML content.

Security: Since there is no database or server-side runtime for the content, the attack surface is virtually zero.

1. Project Setup

First, initialize a new Next.js project. We will use Tailwind CSS for styling and the Typography plugin to handle Markdown formatting.

npx create-next-app@latest my-markdown-blog --typescript --tailwind --eslint

# Follow prompts: App Router (Yes), src/ directory (Yes), Import Alias (Yes)
cd my-markdown-blog

# Install dependencies for parsing Markdown
npm install gray-matter marked
npm install -D @tailwindcss/typography

Configure Tailwind Typography

Open tailwind.config.ts and add the plugin. This allows us to use the prose class to instantly style raw HTML rendered from Markdown.

TypeScript

// tailwind.config.ts
import type { Config } from "tailwindcss";

const config: Config = {
  content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
  theme: {
    extend: {},
  },
  plugins: [require("@tailwindcss/typography")],
};
export default config;

2. Folder Structure

A clean architecture is essential for scalability. We will store our Markdown files in a root-level folder called content/posts.

my-markdown-blog/
├── content/
│   └── posts/
│       ├── hello-world.md
│       └── nextjs-guide.md
├── src/
│   ├── app/
│   │   ├── blog/
│   │   │   └── [slug]/
│   │   │       └── page.tsx
│   ├── lib/
│   │   └── markdown.ts
└── tailwind.config.ts

3. Data Fetching Utility

We need a robust way to read the local filesystem. We'll use the fs module to grab files and gray-matter to extract the metadata (frontmatter).

TypeScript

// src/lib/markdown.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

const postsDirectory = path.join(process.cwd(), 'content/posts');

export interface PostMetadata {
  title: string;
  date: string;
  excerpt: string;
  slug: string;
}

export function getPostBySlug(slug: string) {
  const fullPath = path.join(postsDirectory, `${slug}.md`);
  const fileContents = fs.readFileSync(fullPath, 'utf8');
  const { data, content } = matter(fileContents);

  return {
    metadata: data as PostMetadata,
    content,
  };
}

export function getAllPosts(): PostMetadata[] {
  const fileNames = fs.readdirSync(postsDirectory);
  
  return fileNames.map((fileName) => {
    const slug = fileName.replace(/\.md$/, '');
    const fullPath = path.join(postsDirectory, fileName);
    const fileContents = fs.readFileSync(fullPath, 'utf8');
    const { data } = matter(fileContents);
    
    return {
      ...(data as PostMetadata),
      slug,
    };
  }).sort((a, b) => (a.date < b.date ? 1 : -1));
}

4. Dynamic Routing & Static Params

To ensure our blog is fast, we use generateStaticParams. This tells Next.js to find all blog slugs at build time and generate static HTML for each.

TypeScript

// src/app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPosts } from '@/lib/markdown';
import { marked } from 'marked';

interface Props {
  params: { slug: string };
}

// 1. Generate static paths for all posts
export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

// 2. Render the page
export default function PostPage({ params }: Props) {
  const { metadata, content } = getPostBySlug(params.slug);
  const htmlContent = marked.parse(content);

  return (
    <article className="max-w-3xl mx-auto py-12 px-4">
      <header className="mb-8">
        <h1 className="text-4xl font-bold mb-2">{metadata.title}</h1>
        <time className="text-gray-500">{metadata.date}</time>
      </header>
      
      {/* The 'prose' class from Tailwind Typography does the heavy lifting */}
      <section 
        className="prose prose-lg dark:prose-invert"
        dangerouslySetInnerHTML={{ __html: htmlContent }} 
      />
    </article>
  );
}

5. SEO & Metadata

Next.js makes dynamic SEO simple. By exporting a generateMetadata function, we can populate tags for every individual post.

TypeScript

// Add this to src/app/blog/[slug]/page.tsx

export async function generateMetadata({ params }: Props) {
  const { metadata } = getPostBySlug(params.slug);
  
  return {
    title: `${metadata.title} | My Dev Blog`,
    description: metadata.excerpt,
    openGraph: {
      title: metadata.title,
      description: metadata.excerpt,
      type: 'article',
      publishedTime: metadata.date,
    },
  };
}

Bonus: Adding Syntax Highlighting

If you are building a technical blog, code blocks need to look good. You can enhance the marked parser or use a dedicated library. To keep it simple and high-performance, install highlight.js:

$ npm install highlight.js

Then, in your component, import a theme (e.g., Github Dark) and ensure the code blocks are processed. For a truly "Next-level" experience, consider migrating to next-mdx-remote with rehype-highlight which handles this automatically during the build phase.

Summary

You now have a static-site blog that:

  • Reads local Markdown files.
  • Pre-renders them into high-performance HTML.
  • Automatically styles them with Tailwind Typography.
  • Provides dynamic SEO metadata for every post.