Contentlayer was the obvious choice for Next.js content sites in 2023. Then it lost its maintainer, stopped working with App Router updates, and the community split. In 2026 the practical question for a new project is: MDX bundler or next-mdx-remote, with what tooling around it. This article compares the two paths on the things that actually matter.
Background
The job is the same for both: read .mdx files from disk, parse frontmatter, compile MDX into a React component, serve it in an App Router page. Contentlayer added a build-time schema validator and generated TypeScript types from your frontmatter — that DX is what people miss. MDX bundler and next-mdx-remote do the compilation; you bring your own schema (usually Zod) and your own type generation.
How to tell
- You started a Next.js content site in 2023 with Contentlayer and now CI fails on Next.js 15.
- You want frontmatter type safety without committing to a CMS.
- Your articles use custom MDX components (Callout, CodeBlock, Diagram) and you want them registered in one place.
- Build time matters because you have hundreds of articles.
Quick verdict
For a new project in 2026, use next-mdx-remote/rsc with Zod for frontmatter validation. Build a thin lib/content.ts that mirrors what Contentlayer gave you. Skip MDX bundler unless you need its specific isolation behavior. Skip Contentlayer unless an active fork has stabilized — check the repo activity before committing.
next-mdx-remote with App Router
The most direct path. App Router server components can compile MDX on the server, no client bundle cost:
// app/articles/[slug]/page.tsx
import { compileMDX } from 'next-mdx-remote/rsc';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
import { mdxComponents } from '@/components/mdx';
const FrontmatterSchema = z.object({
title: z.string(),
description: z.string(),
publishedAt: z.coerce.date(),
tags: z.array(z.string()),
});
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const raw = await readFile(
path.join(process.cwd(), 'content', `${params.slug}.mdx`),
'utf8',
);
const { content, frontmatter } = await compileMDX({
source: raw,
components: mdxComponents,
options: { parseFrontmatter: true },
});
const meta = FrontmatterSchema.parse(frontmatter);
return (
<article>
<h1>{meta.title}</h1>
<time>{meta.publishedAt.toISOString()}</time>
{content}
</article>
);
}
generateStaticParams reads the content directory, generateMetadata re-uses the schema. The whole pipeline is roughly 80 lines of code you control.
MDX bundler when you need isolation
MDX bundler (mdx-bundler by Kent C. Dodds) compiles MDX into a self-contained string of JavaScript that you can evaluate at runtime. The win is when you ship MDX from a database or a CMS where each post might import different components — bundler isolates each compile. For file-system MDX, this isolation is overhead.
// when MDX content comes from a remote source
import { bundleMDX } from 'mdx-bundler';
const { code, frontmatter } = await bundleMDX({
source: mdxStringFromCMS,
cwd: process.cwd(),
});
For a file-system content site, next-mdx-remote is simpler. Use MDX bundler when content lives outside the repo.
Replacing Contentlayer’s DX
The three things people loved about Contentlayer:
- Frontmatter type generation. Replace with Zod schemas +
z.infer<typeof FrontmatterSchema>to get the type. A 10-line helper. - Computed fields. Contentlayer let you add
urlSlugfrom filename. Same pattern: a function inlib/content.tsthat maps raw frontmatter to a richer object. - Cached parse across pages. Contentlayer cached parsed MDX between page builds. Replace with a small in-memory cache keyed by file mtime, or skip it — Next 15 caches
fetchand read calls well enough for most sites.
A minimal lib/content.ts ends up around 150-200 lines and gives you the same DX without a dead dependency.
Common mistakes
- Importing client-only MDX components in server-side compile and getting a hydration mismatch — keep interactive components in
'use client'files and pass them through the components map. - Forgetting to call
generateStaticParams— every article becomes a dynamic route, killing SEO and Lighthouse. - Running MDX compilation inside a client component — defeats the point of App Router and ships a huge runtime bundle.
- Validating frontmatter only at runtime — schema errors crash production. Run validation at build time too, ideally as part of CI.
- Leaving a stale Contentlayer dependency installed and getting peer-dep warnings —
npm uninstall contentlayer next-contentlayerafter migrating.
FAQ
- Is Contentlayer dead?: The original repo has been unmaintained for over a year. Forks exist (search for
contentlayer2) but adoption is small. Do not start a new project on it without checking activity first. - What about Velite?: Velite is a strong build-time alternative that does what Contentlayer did. Worth evaluating if you want the type-generation DX back as a managed dependency.
- Can I use the official Next.js
@next/mdxinstead?: Yes for simple sites where each MDX file is a literal page (app/articles/foo/page.mdx). It breaks down when you need dynamic routing from a content directory. - Does next-mdx-remote support remark / rehype plugins?: Yes, pass
remarkPluginsandrehypePluginsin the options. Works the same as MDX itself. - What about Astro?: Astro has first-class MDX with content collections and Zod schemas built in — that is what this article’s host site uses. If you want the Contentlayer DX without the Contentlayer problem and do not need a React-app shell, Astro is the easier path.
Related
- Next.js App Router concepts
- Next.js Content-Site SEO: The Things That Bite
- Next.js On-Demand Revalidation for Content Updates
- When Next.js is wrong for content
Tags: #Indie dev #Next.js #MDX #Content #tooling