Introduction
Building a blog seems deceptively simple—until you try to do it right. You need MDX processing for rich content, type-safe frontmatter validation, static generation for performance, SEO optimization for discoverability, and a developer experience that doesn't make you dread publishing. Next.js provides the framework, and Contentlayer provides the content layer that ties everything together with zero runtime overhead.
Contentlayer transforms your markdown and MDX files into type-safe JSON data at build time. Unlike solutions that parse content at runtime or require a separate CMS, Contentlayer generates TypeScript types from your content schema, validates frontmatter, processes MDX components, and feeds the result directly into Next.js's static generation pipeline. The result is a blog that's fast, type-safe, and delightful to author. This guide walks through building a production-ready blog from scratch.
Understanding Contentlayer: Core Concepts
What Contentlayer Does
Contentlayer sits between your raw content files (MDX/Markdown) and your Next.js pages. At build time, it:
- Reads all content files matching your defined document types
- Validates frontmatter against a Zod schema you define
- Transforms MDX into compiled JSX components
- Generates TypeScript types for your content
- Outputs JSON files that Next.js imports at build time
This means your blog posts are statically analyzed and type-checked before they ever reach the browser. A typo in frontmatter or a missing required field fails the build, not a runtime error in production.
Document Types
Contentlayer organizes content into document types. A blog typically has two: Post for blog articles and Page for static pages like "About" or "Contact." Each document type defines a schema with field names, types, validation rules, and computed fields.
Computed Fields
Beyond frontmatter fields, Contentlayer supports computed fields—values derived from the document's content or metadata. Common examples include slug (derived from the filename), url (derived from the slug), and readingTime (computed from word count).
MDX Components
Contentlayer compiles MDX into React components, allowing you to embed interactive elements directly in your blog posts. You can define a component map that replaces standard HTML elements (like h2, code, a) with custom React components for consistent styling and enhanced functionality.
Architecture and Design Patterns
Content-Driven Routing
Each MDX file in your content directory maps to a URL. A file at content/posts/my-first-post.mdx becomes /blog/my-first-post. This convention-over-configuration approach eliminates routing boilerplate and makes the relationship between files and URLs immediately clear.
Type-Safe Data Pipeline
Contentlayer generates TypeScript types at build time. When you import a post in your Next.js page, TypeScript knows exactly which fields are available, their types, and whether they're optional. This eliminates the any-typed content data that plagues many CMS-driven blogs.
Static Generation with ISR
Contentlayer integrates seamlessly with Next.js's static generation. Blog posts are generated at build time for maximum performance, with optional Incremental Static Regeneration (ISR) for content updates without full rebuilds.
Component Composition
MDX enables composable content. You can create reusable components like <Callout>, <CodeSandbox>, <ImageGallery>, or <Tweet> and use them directly in blog posts. This bridges the gap between static content and interactive experiences.
Step-by-Step Implementation
Project Setup
# Create a Next.js project
npx create-next-app@latest my-blog --typescript --tailwind --app
cd my-blog
# Install Contentlayer and dependencies
npm install contentlayer next-contentlayer2
npm install rehype-pretty-code shiki remark-gfm rehype-slug rehype-autolink-headingsContentlayer Configuration
// contentlayer.config.ts
import { defineDocumentType, makeSource } from "contentlayer2/source-files";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import remarkGfm from "remark-gfm";
const Post = defineDocumentType(() => ({
name: "Post",
filePathPattern: "posts/**/*.mdx",
contentType: "mdx",
fields: {
title: { type: "string", required: true },
description: { type: "string", required: true },
date: { type: "date", required: true },
published: { type: "boolean", default: true },
image: { type: "string", required: false },
tags: { type: "list", of: { type: "string" }, required: true },
author: { type: "string", default: "MinhVo" },
},
computedFields: {
slug: {
type: "string",
resolve: (doc) => doc._raw.flattenedPath.replace("posts/", ""),
},
url: {
type: "string",
resolve: (doc) => `/blog/${doc._raw.flattenedPath.replace("posts/", "")}`,
},
readingTime: {
type: "string",
resolve: (doc) => {
const words = doc.body.raw.split(/\s+/).length;
const minutes = Math.ceil(words / 200);
return `${minutes} min read`;
},
},
},
}));
export default makeSource({
contentDirPath: "content",
documentTypes: [Post],
mdx: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: "wrap" }],
[rehypePrettyCode, { theme: "github-dark" }],
],
},
});Next.js Configuration
// next.config.ts
import { withContentlayer } from "next-contentlayer2";
const nextConfig = {
reactStrictMode: true,
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com" },
],
},
};
export default withContentlayer(nextConfig);Blog Listing Page
// app/blog/page.tsx
import { allPosts } from "contentlayer2/generated";
import Link from "next/link";
import Image from "next/image";
export default function BlogPage() {
const posts = allPosts
.filter((post) => post.published)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const postsByYear = posts.reduce(
(acc, post) => {
const year = new Date(post.date).getFullYear();
if (!acc[year]) acc[year] = [];
acc[year].push(post);
return acc;
},
{} as Record<number, typeof posts>
);
return (
<div className="mx-auto max-w-3xl px-4 py-16">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
{Object.entries(postsByYear)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([year, yearPosts]) => (
<section key={year} className="mb-12">
<h2 className="text-2xl font-semibold mb-4">{year}</h2>
<ul className="space-y-4">
{yearPosts.map((post) => (
<li key={post.slug}>
<Link href={post.url} className="group block">
<article className="flex items-start gap-4">
{post.image && (
<Image
src={post.image}
alt={post.title}
width={120}
height={80}
className="rounded-lg object-cover"
/>
)}
<div>
<h3 className="text-lg font-medium group-hover:text-blue-500">
{post.title}
</h3>
<p className="text-sm text-gray-500">
{new Date(post.date).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
})}{" "}
· {post.readingTime}
</p>
<p className="mt-1 text-gray-600 line-clamp-2">
{post.description}
</p>
</div>
</article>
</Link>
</li>
))}
</ul>
</section>
))}
</div>
);
}Individual Post Page
// app/blog/[slug]/page.tsx
import { allPosts } from "contentlayer2/generated";
import { notFound } from "next/navigation";
import { MDXContent } from "@/components/mdx-content";
export async function generateStaticParams() {
return allPosts
.filter((post) => post.published)
.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = allPosts.find((p) => p.slug === params.slug);
if (!post) return {};
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: "article",
publishedTime: post.date,
authors: [post.author],
images: post.image ? [{ url: post.image }] : [],
},
};
}
export default function PostPage({ params }: { params: { slug: string } }) {
const post = allPosts.find((p) => p.slug === params.slug);
if (!post) notFound();
return (
<article className="mx-auto max-w-3xl px-4 py-16">
<header className="mb-8">
<time className="text-sm text-gray-500">
{new Date(post.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
<h1 className="mt-2 text-4xl font-bold">{post.title}</h1>
<p className="mt-2 text-gray-600">{post.description}</p>
<div className="mt-4 flex flex-wrap gap-2">
{post.tags.map((tag) => (
<span key={tag} className="rounded-full bg-gray-100 px-3 py-1 text-sm">
{tag}
</span>
))}
</div>
</header>
<div className="prose prose-lg max-w-none">
<MDXContent code={post.body.code} />
</div>
</article>
);
}Real-World Use Cases
Developer Blog with Code Snippets
A developer blog with 200 posts uses Contentlayer with rehype-pretty-code for syntax-highlighted code blocks. Each post includes TypeScript code examples with line highlighting, filename annotations, and word-level diffs. The MDX component map replaces <pre> and <code> elements with custom components that add copy buttons and language badges.
Technical Documentation Site
A documentation site uses Contentlayer with two document types: Guide for tutorials and Reference for API documentation. Computed fields generate table-of-contents data from headings, and a custom rehype plugin adds anchor links to all headings for easy deep-linking.
Multi-Author Magazine Blog
A tech magazine with 15 authors uses Contentlayer's frontmatter validation to enforce required fields (title, description, date, author, tags, image). The author field maps to an author profile page, and computed fields generate author-specific RSS feeds.
Portfolio with Blog Integration
A portfolio site uses Contentlayer for both project case studies and blog posts. Different document types (Project and Post) share a common base schema but have different fields and rendering logic. The unified content pipeline means both content types benefit from the same MDX processing, syntax highlighting, and image optimization.
Best Practices for Production
-
Define schemas strictly — Use Contentlayer's Zod-based schema to enforce required fields, valid dates, and URL patterns. Catch content errors at build time, not in production.
-
Use computed fields for derived data — Don't manually maintain
slug,url, orreadingTimein frontmatter. Let Contentlayer compute them from the file path and content. -
Enable syntax highlighting with rehype-pretty-code — It uses Shiki for VS Code-quality syntax highlighting with zero runtime JavaScript. Configure themes to match your site's design.
-
Optimize images with next/image — Use Next.js's
Imagecomponent for blog images. It handles responsive sizing, lazy loading, and WebP/AVIF conversion automatically. -
Generate SEO metadata per post — Use Next.js's
generateMetadatafunction to create OpenGraph tags, Twitter cards, and structured data for each blog post. -
Implement RSS feed generation — Use Contentlayer's
allPostsexport to generate an RSS feed at build time. Libraries likefeedmake this straightforward. -
Use
generateStaticParamsfor static generation — Tell Next.js which slugs to pre-render at build time. This ensures all blog posts are served as static HTML for maximum performance. -
Add table of contents from headings — Parse the MDX headings at build time (using a custom rehype plugin or Contentlayer computed field) to generate a table of contents for long posts.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Contentlayer build errors | Blog fails to build | Validate frontmatter schemas strictly, fix errors before deploying |
| MDX component not found | Runtime error when rendering | Register all custom components in your MDX component map |
| Image paths broken in production | Images don't load | Use absolute URLs or next/image with proper remotePatterns |
| Slow builds with many posts | 500+ posts take minutes to build | Use ISR for incremental updates, paginate the listing page |
| Hot reload not working | Changes not reflected in dev | Restart the dev server, check Contentlayer's file watching configuration |
| TypeScript import errors | contentlayer2/generated not found | Run npx contentlayer2 build once, restart TS server |
Performance Optimization
Build-Time Generation
Contentlayer processes all content at build time, generating optimized JSON files. Each post is a self-contained JSON object with compiled MDX, frontmatter, and computed fields. Next.js imports these JSON files at build time and renders them as static HTML.
Image Optimization
// Use next/image for all blog images
<Image
src={post.image}
alt={post.title}
width={800}
height={400}
priority={isAboveFold} // Load eagerly for hero images
className="rounded-lg"
/>Incremental Static Regeneration
For blogs with frequent updates, enable ISR to revalidate pages without rebuilding the entire site:
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // Revalidate every hourComparison with Alternatives
| Feature | Contentlayer | MDX (raw) | Contentful (CMS) | Notion API |
|---|---|---|---|---|
| Type safety | Full TypeScript | None | Schema validation | Partial |
| Build-time processing | Yes | Yes | Runtime | Runtime |
| MDX support | Native | Native | Via API | No |
| Frontmatter validation | Zod-based | Manual | Built-in | Limited |
| File-based content | Yes | Yes | No (cloud) | No (cloud) |
| Next.js integration | Native | Manual | API-based | API-based |
| Runtime cost | Zero | Zero | API calls | API calls |
| Learning curve | Low | Medium | Medium | Low |
Advanced Patterns
MDX Component Map
// components/mdx-content.tsx
import { useMDXComponent } from "next-contentlayer2/hooks";
import { Callout } from "@/components/callout";
import { CodeBlock } from "@/components/code-block";
import { ImageGallery } from "@/components/image-gallery";
const components = {
Callout,
CodeBlock,
ImageGallery,
h2: (props: any) => <h2 className="scroll-mt-20" {...props} />,
a: (props: any) => <a className="text-blue-500 hover:underline" {...props} />,
pre: (props: any) => <CodeBlock {...props} />,
};
export function MDXContent({ code }: { code: string }) {
const Component = useMDXComponent(code);
return <Component components={components} />;
}RSS Feed Generation
// app/feed.xml/route.ts
import { allPosts } from "contentlayer2/generated";
import { Feed } from "feed";
export async function GET() {
const feed = new Feed({
title: "My Blog",
description: "A developer blog",
id: "https://myblog.com",
link: "https://myblog.com",
language: "en",
copyright: "2023 My Blog",
});
allPosts
.filter((post) => post.published)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.forEach((post) => {
feed.addItem({
title: post.title,
id: post.url,
link: `https://myblog.com${post.url}`,
description: post.description,
date: new Date(post.date),
author: [{ name: post.author }],
});
});
return new Response(feed.rss2(), {
headers: { "Content-Type": "application/xml" },
});
}Sitemap Generation
// app/sitemap.ts
import { allPosts } from "contentlayer2/generated";
import { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
const posts = allPosts
.filter((post) => post.published)
.map((post) => ({
url: `https://myblog.com${post.url}`,
lastModified: new Date(post.date),
changeFrequency: "monthly" as const,
priority: 0.8,
}));
return [
{ url: "https://myblog.com", priority: 1 },
{ url: "https://myblog.com/blog", priority: 0.9 },
...posts,
];
}Testing Strategies
Content Validation Tests
// __tests__/content.test.ts
import { allPosts } from "contentlayer2/generated";
describe("Blog Posts", () => {
it("all published posts have required fields", () => {
const published = allPosts.filter((post) => post.published);
published.forEach((post) => {
expect(post.title).toBeTruthy();
expect(post.description).toBeTruthy();
expect(post.date).toBeTruthy();
expect(post.tags.length).toBeGreaterThan(0);
expect(post.slug).toBeTruthy();
expect(post.url).toBeTruthy();
});
});
it("no duplicate slugs exist", () => {
const slugs = allPosts.map((post) => post.slug);
const unique = new Set(slugs);
expect(unique.size).toBe(slugs.length);
});
it("all posts have valid dates", () => {
allPosts.forEach((post) => {
const date = new Date(post.date);
expect(date.getTime()).not.toBeNaN();
expect(date.getTime()).toBeLessThanOrEqual(Date.now());
});
});
it("reading time is reasonable", () => {
allPosts.forEach((post) => {
const minutes = parseInt(post.readingTime);
expect(minutes).toBeGreaterThan(0);
expect(minutes).toBeLessThan(120);
});
});
});Future Outlook
Contentlayer's development has been taken over by the community (as contentlayer2) after the original maintainer stepped back. The community fork is actively maintained and compatible with Next.js 14+ App Router. The tool's core design—type-safe, build-time content processing—is timeless and aligns with the broader trend toward static-first content delivery.
The MDX ecosystem continues to mature. MDX v3 brings improved performance, better error messages, and tighter integration with React Server Components. Combined with Contentlayer's type-safe pipeline, the authoring experience for technical blogs has never been better.
Looking ahead, the convergence of content tools with AI-assisted authoring (autocomplete for frontmatter, AI-generated summaries, automatic tag suggestions) will further improve the blogging workflow. Contentlayer's schema-driven approach makes it well-positioned to integrate these AI capabilities.
Conclusion
Contentlayer transforms blog development from a configuration nightmare into a type-safe, content-driven workflow. The key takeaways:
- Type safety catches errors at build time — Invalid frontmatter, missing fields, and malformed dates fail the build, not the browser
- MDX enables rich, interactive content — Embed React components directly in blog posts for code demos, interactive examples, and dynamic visualizations
- Zero runtime overhead — Content is processed at build time and served as static HTML. No CMS API calls, no runtime parsing
- Next.js integration is seamless —
generateStaticParams,generateMetadata, and ISR work out of the box - Start simple, extend gradually — Begin with basic MDX files, add components and computed fields as your blog grows
Start by creating a single content/posts/hello-world.mdx file and configuring Contentlayer to process it. Once you see the type-safe import in your Next.js page, you'll understand why this workflow is so compelling. From there, add components, optimize SEO, and scale to hundreds of posts with confidence.