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.
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.
npx create-next-app@latest myblog --typescript --tailwind --app
npm install velite zod
npm install -D rehype-pretty-code rehype-slug remark-gfm shikiVelite 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' })
})
}
}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...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>
),
}// 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>
)
}// 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>
)
}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;
}// 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,
]
}generateStaticParams + generateMetadata handles both static generation and SEO in one place