Follow Us

CodeWithSabir

  • Contact Us
  • Privacy Policy
  • About
  • Terms & Conditions

All Rights Reserved © 2026

  • Light
  • Dark
Next.js

Building a Blog with Next.js and MDX: The Complete Guide

Sabir Soft
Sabir Lkhaloufi
  • January 30, 2026
  • 4 min read

Building a Blog with Next.js and MDX: The Complete Guide

A blog built with Next.js and MDX gives you the best of both worlds: the simplicity of writing in Markdown with the power of embedding React components anywhere in your content. This guide walks through building a production-ready blog using Velite for content management — the same stack powering CodeWithSabir.

Why MDX?

Regular Markdown is great for prose. MDX extends it so you can drop React components directly into your content:

# My Article
 
Here's some regular markdown text.
 
<CodePlayground lang="javascript">
  const x = 1 + 1;
  console.log(x); // 2
</CodePlayground>
 
Back to regular markdown.

This lets you embed interactive demos, callout boxes, custom video players, or anything else React can render — directly in your articles.

Project Setup

npx create-next-app@latest myblog --typescript --tailwind --app
npm install velite zod
npm install -D rehype-pretty-code rehype-slug remark-gfm shiki

Configure Velite

Velite is a content management layer that processes your MDX files at build time and gives you a fully-typed collection to query:

// velite.config.ts
import { defineConfig, s } from 'velite'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import remarkGfm from 'remark-gfm'
import rehypePrettyCode from 'rehype-pretty-code'
 
export default defineConfig({
  root: 'content',
  output: {
    data: '.velite',
    assets: 'public/static',
    base: '/static/',
    name: '[name]-[hash:6].[ext]',
    clean: true,
  },
  collections: {
    posts: {
      name: 'Post',
      pattern: 'posts/**/*.mdx',
      schema: s.object({
        title: s.string().max(200),
        slug: s.slug('posts'),
        date: s.isodate(),
        excerpt: s.string().max(300).optional(),
        thumbnail: s.image().optional(),
        categories: s.array(s.object({ name: s.string(), slug: s.string() })).default([]),
        tags: s.array(s.string()).default([]),
        author: s.string().default('Your Name'),
        published: s.boolean().default(true),
        featured: s.boolean().default(false),
        metadata: s.metadata(),
        body: s.mdx(),
      }).transform(data => ({
        ...data,
        permalink: `/post/${data.slug}`,
      })),
    },
  },
  mdx: {
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { behavior: 'wrap' }],
      [rehypePrettyCode, {
        theme: 'github-dark',
        onVisitLine(node) {
          if (node.children.length === 0) {
            node.children = [{ type: 'text', value: ' ' }]
          }
        },
      }],
    ],
    remarkPlugins: [remarkGfm],
  },
})

Add Velite to your Next.js build process:

// next.config.mjs
import { build } from 'velite'
 
/** @type {import('next').NextConfig} */
export default {
  webpack: (config, { isServer }) => {
    if (isServer) {
      config.plugins.push(new VeliteWebpackPlugin())
    }
    return config
  },
}
 
class VeliteWebpackPlugin {
  static started = false
  apply(compiler) {
    compiler.hooks.beforeCompile.tapPromise('VeliteWebpackPlugin', async () => {
      if (VeliteWebpackPlugin.started) return
      VeliteWebpackPlugin.started = true
      const dev = compiler.options.mode === 'development'
      await build({ watch: dev, logLevel: dev ? 'info' : 'warn' })
    })
  }
}

Writing Your First Post

content/
└── posts/
    └── my-first-post.mdx
---
title: "Getting Started with Next.js 15"
slug: getting-started-nextjs-15
date: 2026-04-01
excerpt: "A beginner-friendly introduction to Next.js 15 and the App Router."
categories:
  - name: Next.js
    slug: nextjs
tags: ["next.js", "react", "beginner"]
author: Your Name
published: true
---
 
# Getting Started with Next.js 15
 
Next.js is a React framework that gives you...

The MDX Content Component

To render MDX body content, you need a component that maps HTML elements to your custom styled versions:

// components/MDXContent.tsx
import * as runtime from 'react/jsx-runtime'
 
interface MDXContentProps {
  code: string
}
 
export function MDXContent({ code }: MDXContentProps) {
  const fn = new Function(code)
  const { default: Component } = fn({ ...runtime })
  return <Component components={mdxComponents} />
}
 
// Custom components that replace default HTML elements in MDX
const mdxComponents = {
  // Custom callout component
  Callout: ({ type = 'info', children }: { type?: string; children: React.ReactNode }) => (
    <div className={`callout callout-${type} my-6 p-4 rounded-lg border-l-4`}>
      {children}
    </div>
  ),
  // Custom code block with copy button
  pre: ({ children, ...props }: any) => (
    <div className="relative group">
      <pre {...props}>{children}</pre>
      <CopyButton code={props['data-code']} />
    </div>
  ),
}

The Blog List Page

// app/page.tsx
import { posts } from '#content'
import PostCard from '@/components/PostCard'
 
export default function HomePage() {
  const published = posts
    .filter(p => p.published)
    .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
 
  return (
    <main className="max-w-4xl mx-auto px-4 py-12">
      <h1 className="text-4xl font-bold mb-12">Latest Articles</h1>
      <div className="space-y-8">
        {published.map(post => (
          <PostCard key={post.slug} post={post} />
        ))}
      </div>
    </main>
  )
}

The Post Page with SEO

// app/post/[slug]/page.tsx
import { posts } from '#content'
import { notFound } from 'next/navigation'
import { MDXContent } from '@/components/MDXContent'
import type { Metadata } from 'next'
 
export function generateStaticParams() {
  return posts.filter(p => p.published).map(p => ({ slug: p.slug }))
}
 
export function generateMetadata({ params }: { params: { slug: string } }): Metadata {
  const post = posts.find(p => p.slug === params.slug && p.published)
  if (!post) return {}
 
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.date,
      authors: [post.author],
    },
  }
}
 
export default function PostPage({ params }: { params: { slug: string } }) {
  const post = posts.find(p => p.slug === params.slug && p.published)
  if (!post) notFound()
 
  return (
    <article className="max-w-3xl mx-auto px-4 py-12">
      <header className="mb-10">
        <div className="flex gap-2 mb-4">
          {post.categories.map(cat => (
            <a key={cat.slug} href={`/category/${cat.slug}`}
               className="text-sm font-semibold text-blue-600 uppercase tracking-wide">
              {cat.name}
            </a>
          ))}
        </div>
        <h1 className="text-4xl font-extrabold leading-tight mb-4">{post.title}</h1>
        <p className="text-gray-500 text-sm">
          By {post.author} · {new Date(post.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
        </p>
      </header>
 
      <div className="prose prose-lg max-w-none">
        <MDXContent code={post.body} />
      </div>
    </article>
  )
}

Syntax Highlighting Styles

Rehype Pretty Code generates the highlighted HTML — you just need to add the CSS variables:

/* styles/code.css */
[data-rehype-pretty-code-figure] pre {
  overflow-x: auto;
  border-radius: 8px;
  padding: 1.25rem;
  font-size: 0.875rem;
  line-height: 1.7;
}
 
[data-line] {
  padding: 0 1rem;
}
 
[data-highlighted-line] {
  background: rgba(255, 255, 255, 0.1);
  border-left: 2px solid #3858F6;
}
 
/* File name tabs */
[data-rehype-pretty-code-title] {
  background: #1e1e2e;
  color: #cdd6f4;
  padding: 0.5rem 1rem;
  font-size: 0.8rem;
  border-radius: 8px 8px 0 0;
  font-family: monospace;
}

Sitemap and RSS Feed

// app/sitemap.ts
import { posts } from '#content'
import type { MetadataRoute } from 'next'
 
export default function sitemap(): MetadataRoute.Sitemap {
  const postEntries = posts
    .filter(p => p.published)
    .map(p => ({
      url: `https://yourdomain.com/post/${p.slug}`,
      lastModified: new Date(p.date),
      changeFrequency: 'weekly' as const,
      priority: 0.8,
    }))
 
  return [
    { url: 'https://yourdomain.com', changeFrequency: 'daily', priority: 1 },
    ...postEntries,
  ]
}

Key Takeaways

  • Velite processes MDX at build time and gives you a fully-typed, queryable content collection
  • MDX lets you embed React components in markdown — great for interactive demos and custom callouts
  • generateStaticParams + generateMetadata handles both static generation and SEO in one place
  • Rehype Pretty Code produces beautiful syntax highlighting with zero client-side JavaScript
  • Always generate a sitemap — it directly improves Google's ability to index your posts
Popular Blogs
Claude AI vs ChatGPT: An Honest Comparison for Developers
  • April 28, 2026
AI Tools Every Developer Should Be Using in 2026
  • April 20, 2026
Using the Claude API in Real Projects: A Practical Developer Guide
  • April 15, 2026
Prompt Engineering for Developers: Write Prompts That Actually Work
  • April 10, 2026
Categories
AIDevOpsNext.jsMobile DevelopmentWeb Development

Related Posts

Next.js
How to Build a Fullstack App with Next.js 15: Complete Guide
Sabir Khaloufi·Feb 25, 2026
Next.js
Next.js Server Components Explained: What They Are and Why They Matter
Sabir Khaloufi·Feb 20, 2026
Next.js
Authentication in Next.js with NextAuth.js v5: The Complete Setup
Sabir Khaloufi·Feb 15, 2026