Skip to main content

Contentlayer vs Velite vs next-mdx-remote: MDX Content Pipelines for Next.js (2026)

·PkgPulse Team

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

PackageWeekly DownloadsType SafetyApp RouterStatus
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

Featurenext-mdx-remoteVeliteContentlayer
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 impactMinimalBuild onlyBuild 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.

Compare content and MDX packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.