Contentlayer vs Velite vs next-mdx-remote: MDX Content Pipelines for Next.js (2026)
TL;DR
next-mdx-remote is the simplest option — fetch MDX from anywhere (database, CMS, filesystem) and render it in Next.js. Contentlayer gave MDX type-safe frontmatter and build-time validation, but development has stalled since 2023 and it has compatibility issues with Next.js 14+. Velite is the modern replacement for Contentlayer — actively maintained, works with Next.js App Router and RSC, and gives you typed content collections. For new projects in 2026, use Velite or next-mdx-remote depending on your needs.
Key Takeaways
- next-mdx-remote: ~500K weekly downloads — fetch MDX from anywhere, RSC-compatible
- contentlayer: ~200K weekly downloads — ⚠️ stalled development, Next.js 14+ issues
- velite: ~50K weekly downloads — typed content collections, Contentlayer alternative, actively maintained
- Contentlayer is NOT recommended for new projects in 2026 (unmaintained)
- Velite is the recommended Contentlayer replacement with App Router support
- next-mdx-remote for dynamic content from CMS/database; Velite for filesystem-based content
Download Trends
| Package | Weekly Downloads | Type Safety | App Router | Status |
|---|---|---|---|---|
next-mdx-remote | ~500K | ✅ | ✅ | ✅ Active |
contentlayer | ~200K | ✅ Excellent | ⚠️ Issues | ⚠️ Stalled |
velite | ~50K | ✅ | ✅ | ✅ Active |
next-mdx-remote
next-mdx-remote renders MDX fetched from any source — filesystem, database, CMS, or API.
App Router (RSC) — Server Component
// app/blog/[slug]/page.tsx
import { MDXRemote } from "next-mdx-remote/rsc"
import { readFile } from "fs/promises"
import path from "path"
import matter from "gray-matter"
interface Props {
params: { slug: string }
}
export default async function BlogPost({ params }: Props) {
// Read MDX file from filesystem:
const filePath = path.join(process.cwd(), "content/blog", `${params.slug}.mdx`)
const source = await readFile(filePath, "utf-8")
// Parse frontmatter:
const { content, data: frontmatter } = matter(source)
return (
<article>
<h1>{frontmatter.title}</h1>
<p>{frontmatter.description}</p>
<time>{frontmatter.date}</time>
{/* Renders MDX in RSC — no client-side JS needed: */}
<MDXRemote
source={content}
components={{
// Override default HTML elements:
h2: ({ children }) => <h2 className="text-2xl font-bold mt-8">{children}</h2>,
code: ({ children }) => <code className="bg-zinc-800 px-1 rounded">{children}</code>,
// Custom MDX components:
Callout: ({ type, children }) => (
<div className={`callout callout-${type}`}>{children}</div>
),
PackageStats: ({ name }) => <PackageStatsWidget name={name} />,
}}
options={{
mdxOptions: {
rehypePlugins: [rehypeHighlight, rehypeSlug],
remarkPlugins: [remarkGfm],
},
}}
/>
</article>
)
}
From database or CMS
// Fetch MDX from any source — database, Contentful, Sanity, etc.:
async function getBlogPost(slug: string) {
// Example: from Supabase
const { data } = await supabase
.from("posts")
.select("title, description, content, published_at")
.eq("slug", slug)
.single()
return data
}
export default async function BlogPost({ params }: Props) {
const post = await getBlogPost(params.slug)
return (
<article>
<h1>{post.title}</h1>
<MDXRemote source={post.content} />
</article>
)
}
Generating static routes
// app/blog/[slug]/page.tsx
import { glob } from "glob"
import path from "path"
export async function generateStaticParams() {
const files = await glob("content/blog/*.mdx", { cwd: process.cwd() })
return files.map((file) => ({
slug: path.basename(file, ".mdx"),
}))
}
Velite
Velite is the recommended Contentlayer alternative — build-time content collections with TypeScript types, schema validation, and App Router support.
Configuration
// velite.config.ts
import { defineConfig, defineCollection, s } from "velite"
import rehypeHighlight from "rehype-highlight"
import remarkGfm from "remark-gfm"
// Define the schema for blog posts:
const posts = defineCollection({
name: "Post",
pattern: "content/blog/**/*.mdx",
schema: s
.object({
title: s.string().max(99),
description: s.string().max(999),
date: s.isodate(), // Validates ISO date format
author: s.string().default("PkgPulse Team"),
tags: s.array(s.string()).default([]),
draft: s.boolean().default(false),
// computed fields:
slug: s.path(), // Derived from file path
permalink: s.string().transform((_, { meta }) => `/blog/${meta.path}`),
})
.transform((data) => ({
...data,
// Add reading time:
readingTime: Math.ceil(data.content?.split(/\s+/).length / 200),
})),
})
// Define package comparison pages:
const comparisons = defineCollection({
name: "Comparison",
pattern: "content/comparisons/**/*.mdx",
schema: s.object({
packageA: s.string(),
packageB: s.string(),
category: s.enum(["framework", "orm", "testing", "tooling", "styling"]),
verdict: s.string().optional(),
date: s.isodate(),
}),
})
export default defineConfig({
root: "content",
output: {
data: ".velite", // TypeScript types output here
assets: "public/static",
},
collections: { posts, comparisons },
mdx: {
rehypePlugins: [rehypeHighlight],
remarkPlugins: [remarkGfm],
},
})
Using Velite collections in Next.js
// app/blog/page.tsx — Blog listing
import { posts } from "@/.velite"
export default function BlogPage() {
// posts is fully typed — TypeScript knows the schema:
const publishedPosts = posts
.filter((p) => !p.draft)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
return (
<div>
{publishedPosts.map((post) => (
<article key={post.slug}>
<h2>{post.title}</h2>
<p>{post.description}</p>
<time>{post.date}</time>
<span>{post.readingTime} min read</span>
<div>
{post.tags.map((tag) => <span key={tag}>{tag}</span>)}
</div>
</article>
))}
</div>
)
}
// app/blog/[slug]/page.tsx — Blog post
import { posts } from "@/.velite"
import { notFound } from "next/navigation"
import { MDXContent } from "@/components/mdx-content"
export async function generateStaticParams() {
return posts.map((post) => ({ slug: post.slug }))
}
export default function BlogPost({ params }: { params: { slug: string } }) {
const post = posts.find((p) => p.slug === params.slug)
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
{/* Velite provides typed MDX content: */}
<MDXContent code={post.content} />
</article>
)
}
// components/mdx-content.tsx
"use client"
import { useMDXComponent } from "next-contentlayer2/hooks" // Or velite's equivalent
const components = {
h2: ({ children }) => <h2 className="text-2xl">{children}</h2>,
PackageStats: ({ name }: { name: string }) => <PackageStatsWidget name={name} />,
Callout: ({ type, children }) => <div className={`callout-${type}`}>{children}</div>,
}
export function MDXContent({ code }: { code: string }) {
const MDXComponent = useMDXComponent(code)
return <MDXComponent components={components} />
}
TypeScript integration
// Velite generates types automatically at build time:
// @/.velite/index.d.ts (auto-generated):
export interface Post {
title: string
description: string
date: string
author: string
tags: string[]
draft: boolean
slug: string
permalink: string
readingTime: number
content: string // Compiled MDX
}
// Full IntelliSense in your editor — no manual type definitions needed
Why Not Contentlayer in 2026?
Contentlayer was the gold standard until 2023, but development has stalled:
// ⚠️ Contentlayer issues in 2026:
// 1. Not updated for Next.js 14+ App Router properly
// 2. GitHub: last meaningful commit was 2023
// 3. Issues with Next.js 15 RSC compilation
// 4. Community has largely migrated to Velite
// Migration from Contentlayer to Velite is straightforward:
// contentlayer.config.ts → velite.config.ts (similar schema API)
// import { allPosts } from "contentlayer/generated" → import { posts } from "@/.velite"
Feature Comparison
| Feature | next-mdx-remote | Velite | Contentlayer |
|---|---|---|---|
| App Router (RSC) | ✅ | ✅ | ⚠️ Issues |
| Build-time typing | ❌ | ✅ | ✅ |
| Schema validation | ❌ | ✅ Zod-like | ✅ |
| Dynamic sources | ✅ Database/CMS | ❌ Filesystem | ❌ Filesystem |
| Computed fields | ❌ | ✅ | ✅ |
| MDX plugins | ✅ | ✅ | ✅ |
| Hot reload | ❌ | ✅ | ✅ |
| TypeScript inference | ❌ | ✅ Generated | ✅ Generated |
| Maintenance status | ✅ Active | ✅ Active | ⚠️ Stalled |
| Bundle impact | Minimal | Build only | Build only |
When to Use Each
Choose next-mdx-remote if:
- MDX content comes from a database, CMS (Contentful, Sanity, Notion), or API
- You need dynamic, runtime-fetched content (not just filesystem)
- You want the simplest possible setup with no build step overhead
- Content can change without a redeploy (ISR-based updates)
Choose Velite if:
- Filesystem-based MDX content (blog posts, docs, comparisons)
- TypeScript type safety for frontmatter is important
- You're migrating from Contentlayer (similar API)
- Build-time validation of content (catch missing required fields)
- Next.js 14+/15 App Router with proper RSC support
Don't use Contentlayer for:
- New projects in 2026 — choose Velite instead
- Next.js 14+ projects — compatibility issues
- Anything where you need ongoing maintenance or bug fixes
Methodology
Download data from npm registry (weekly average, February 2026). Contentlayer maintenance status based on GitHub activity. Feature comparison based on next-mdx-remote v5.x, Velite v0.6.x, and Contentlayer v0.3.x.