Skip to main content

Guide

Contentlayer vs Velite vs next-mdx-remote 2026

Compare Contentlayer, Velite, and next-mdx-remote for managing MDX content in Next.js. Type-safe frontmatter, RSC compatibility, build performance, and which.

·PkgPulse Team·
0

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

Migration Guide

From Contentlayer to Velite

Velite's API was deliberately designed to be familiar to Contentlayer users. The migration is mostly find-and-replace with some schema syntax differences:

# Remove Contentlayer
npm uninstall contentlayer next-contentlayer

# Install Velite
npm install velite
// contentlayer.config.ts (before)
import { defineDocumentType, makeSource } from "contentlayer/source-files"
import rehypeHighlight from "rehype-highlight"

export const Post = defineDocumentType(() => ({
  name: "Post",
  filePathPattern: "blog/**/*.mdx",
  contentType: "mdx",
  fields: {
    title: { type: "string", required: true },
    description: { type: "string", required: true },
    date: { type: "date", required: true },
    tags: { type: "list", of: { type: "string" }, default: [] },
  },
  computedFields: {
    slug: {
      type: "string",
      resolve: (post) => post._raw.flattenedPath.replace("blog/", ""),
    },
  },
}))

export default makeSource({
  contentDirPath: "content",
  documentTypes: [Post],
  mdx: { rehypePlugins: [rehypeHighlight] },
})
// velite.config.ts (after)
import { defineConfig, defineCollection, s } from "velite"
import rehypeHighlight from "rehype-highlight"

const posts = defineCollection({
  name: "Post",
  pattern: "blog/**/*.mdx",
  schema: s.object({
    title: s.string().max(99),
    description: s.string().max(999),
    date: s.isodate(),
    tags: s.array(s.string()).default([]),
    slug: s.path(),
  }),
})

export default defineConfig({
  root: "content",
  collections: { posts },
  mdx: { rehypePlugins: [rehypeHighlight] },
})

Update imports in your page components:

// Before (Contentlayer)
import { allPosts } from "contentlayer/generated"

// After (Velite)
import { posts } from "@/.velite"

Update next.config.js: remove withContentlayer wrapper (Velite does not need a Next.js plugin — it runs as a separate build step invoked before next build).


Build Performance and Hot Reload

Build-time MDX processing performance becomes a meaningful concern as content collections grow beyond a few hundred files. Velite processes all content files during the Next.js build and writes generated TypeScript to .velite/ — on a collection of 500 MDX files, initial build times are in the 8–15 second range depending on the rehype/remark plugin chain. Velite's incremental rebuild on content changes (when running next dev) is fast because it watches for file changes and reprocesses only the modified files, typically completing in under a second. The .velite/ output directory must be added to .gitignore but should be generated before the Next.js build in CI pipelines (add velite build before next build in your build script).

next-mdx-remote in App Router mode has no separate build step — MDX is compiled at request time in the React Server Component. For static sites using generateStaticParams, this compilation happens during the next build phase for each static route in parallel. The compilation cost per file is roughly comparable to Velite, but without Velite's incremental caching, large static sites may see longer build times when rebuilding from scratch. For sites with hundreds of MDX pages, configuring rehype plugins carefully matters — heavy plugins like rehype-pretty-code (which runs Shiki for syntax highlighting) add significant per-file compile time and warrant caching strategies.

TypeScript Schema Design with Velite

Velite's schema system is built on a Zod-like API that enforces content quality at build time. Mandatory fields fail the build when missing, preventing accidentally empty titles or malformed dates from reaching production. The s.isodate() type validates that date strings are valid ISO 8601 — a common source of content errors in team-managed MDX files. Computed fields via .transform() enable derived properties like reading time, slug normalization, and permalink construction to live in the schema definition rather than scattered across page components. One powerful pattern is using s.path() to generate slugs from the file path — a file at content/blog/my-post.mdx automatically gets slug: "my-post" without any manual frontmatter entry.

For multi-author blogs and content networks, the schema field on each collection can include an author enum validated against a predefined list, ensuring every post references a valid author identifier. Combined with s.array(s.string()) for tags and an enum for categories, Velite's schema serves as the content governance layer that editorial tooling (like a CMS integration or Notion sync) must conform to, catching errors at import time rather than at runtime.

Community Adoption in 2026

next-mdx-remote reaches approximately 500,000 weekly downloads, maintained by HashiCorp who use it across their own documentation sites (Terraform, Vault, Nomad). Its flexibility — rendering MDX from any source, not just the filesystem — makes it the choice for hybrid architectures where some content lives in a headless CMS and some lives in the repository. The v5 release added full React Server Component support (import { MDXRemote } from 'next-mdx-remote/rsc'), eliminating the previous requirement for a client boundary. For CMS-driven sites with a Next.js frontend, next-mdx-remote is the standard integration layer.

Velite sits at approximately 50,000 weekly downloads but is growing rapidly as Contentlayer's drop-in replacement. The growth story mirrors Contentlayer's early adoption curve — developers migrating from stalled Contentlayer installations are the primary adopters. Several prominent Next.js starter kits (including create-t3-app's blog template and shadcn/ui's documentation setup) have moved to Velite. The TypeScript-first schema API — inspired by Zod but built for content collections — is the primary differentiator over next-mdx-remote for filesystem-based content. Velite's computed fields and schema transformation capabilities (calculating reading time, generating slugs from file paths, validating ISO dates) reduce boilerplate that would otherwise live in utility functions.

Contentlayer at approximately 200,000 weekly downloads represents existing projects that haven't migrated away yet. The maintainers (Layer0/Edgio) faced significant challenges in keeping up with Next.js's rapid App Router evolution, and the last meaningful code changes were in mid-2023. A community fork (next-contentlayer2) emerged to patch the most critical Next.js 14+ incompatibilities, accounting for a portion of the continued download count. For new projects, the recommendation across the Next.js ecosystem community — including from Lee Robinson at Vercel — is to use Velite as the direct replacement. Teams with existing Contentlayer setups that are working adequately on Next.js 13 or earlier may choose to stay on next-contentlayer2 until they do a full Next.js upgrade, at which point the Velite migration is worth completing.


Methodology

Download data from npm registry (weekly average, February 2026). Contentlayer maintenance status based on GitHub commit history and open issue response rate. Feature comparison based on next-mdx-remote v5.x, Velite v0.6.x, and Contentlayer v0.3.x. App Router compatibility verified against Next.js 15.x release notes.

Compare content and MDX packages on PkgPulse →

If you're choosing a content library as part of a larger stack decision, see which SaaS starter kits use Velite for content management — several popular boilerplates have already made the Contentlayer → Velite migration and include a working MDX blog setup out of the box.

See also: Next.js vs Remix and Next.js vs Nuxt.js, better-auth vs Lucia vs NextAuth 2026.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.