Contentlayer
"Contentlayer and Velite for type-safe content management: transforming Markdown/MDX into typed data, schema validation, computed fields, Next.js integration, hot reload, and migration between content tools."
Contentlayer (and its successor Velite) treats local content files as a typed data source. Instead of manually parsing Markdown and managing frontmatter, you define schemas that describe your content structure, and the tool generates TypeScript types and validated data at build time. This eliminates an entire class of runtime bugs — missing fields, wrong types, broken references — by catching them during the build. Content becomes as reliable as any other typed data layer in your application. Velite emerged as the actively maintained alternative after Contentlayer's development slowed, offering similar DX with better performance and broader format support. ## Key Points - Use `contentlayer2` (the community fork) rather than the original `contentlayer` package, which is no longer maintained. - For new projects, prefer Velite — it has active development, supports more output formats, and generates lighter bundles. - Define strict schemas with all required fields. The build fails early on missing data, which is the whole point. - Use computed fields for derived data (slugs, reading time, headings) rather than computing them at render time. - Add `generated` or `.velite` to `.gitignore` — these are build artifacts that should be regenerated from source. - Set up path aliases (`#site/content` for Velite, `contentlayer/generated` for Contentlayer) in `tsconfig.json` for clean imports. - Use `generateStaticParams` to pre-render all content pages at build time for optimal performance. - Combine with `next-seo` or the Metadata API for complete SEO coverage per content page. - **Treating generated types as optional**: The entire value of these tools is type safety. Do not cast content to `any` or ignore type errors. - **Storing generated files in version control**: The `.contentlayer` and `.velite` directories should be gitignored. Committing them creates noisy diffs and merge conflicts. - **Skipping the `published` field pattern**: Without a boolean flag to control visibility, draft posts leak to production. Always filter by `published` status. - **Defining schemas without defaults**: Fields without `required: true` or `default` values produce `undefined` at runtime, defeating the purpose of typed content. ## Quick Example ```bash npm install contentlayer2 next-contentlayer2 ``` ```bash npm install velite ```
skilldb get seo-content-skills/ContentlayerFull skill: 353 linesContentlayer & Velite — Type-Safe Content
Core Philosophy
Contentlayer (and its successor Velite) treats local content files as a typed data source. Instead of manually parsing Markdown and managing frontmatter, you define schemas that describe your content structure, and the tool generates TypeScript types and validated data at build time. This eliminates an entire class of runtime bugs — missing fields, wrong types, broken references — by catching them during the build. Content becomes as reliable as any other typed data layer in your application. Velite emerged as the actively maintained alternative after Contentlayer's development slowed, offering similar DX with better performance and broader format support.
Setup
Contentlayer
npm install contentlayer2 next-contentlayer2
// contentlayer.config.ts
import { defineDocumentType, makeSource } from "contentlayer2/source-files";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
import remarkGfm from "remark-gfm";
export 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: false },
tags: { type: "list", of: { type: "string" }, default: [] },
image: { type: "string" },
author: { type: "string", required: true },
},
computedFields: {
slug: {
type: "string",
resolve: (doc) => doc._raw.flattenedPath.replace("posts/", ""),
},
readingTime: {
type: "number",
resolve: (doc) => {
const words = doc.body.raw.split(/\s+/).length;
return Math.ceil(words / 200);
},
},
url: {
type: "string",
resolve: (doc) =>
`/blog/${doc._raw.flattenedPath.replace("posts/", "")}`,
},
},
}));
export default makeSource({
contentDirPath: "content",
documentTypes: [Post],
mdx: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[rehypePrettyCode, { theme: "github-dark" }],
],
},
});
// next.config.mjs
import { withContentlayer } from "next-contentlayer2";
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default withContentlayer(nextConfig);
Velite
npm install velite
// velite.config.ts
import { defineConfig, defineCollection, s } from "velite";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
const posts = defineCollection({
name: "Post",
pattern: "posts/**/*.mdx",
schema: s.object({
title: s.string().max(120),
description: s.string().max(300),
date: s.isodate(),
published: s.boolean().default(false),
tags: s.array(s.string()).default([]),
image: s.string().optional(),
author: s.string(),
slug: s.slug("posts"),
body: s.mdx(),
metadata: s.metadata(),
toc: s.toc(),
}),
});
export default defineConfig({
root: "content",
output: {
data: ".velite",
assets: "public/static",
base: "/static/",
name: "[name]-[hash:6].[ext]",
clean: true,
},
collections: { posts },
mdx: {
rehypePlugins: [
rehypeSlug,
[rehypePrettyCode, { theme: "one-dark-pro" }],
],
},
});
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.plugins.push(new VeliteWebpackPlugin());
return config;
},
};
class VeliteWebpackPlugin {
static started = false;
apply(compiler: { hooks: { beforeCompile: { tapPromise: Function } } }) {
compiler.hooks.beforeCompile.tapPromise("VeliteWebpackPlugin", async () => {
if (VeliteWebpackPlugin.started) return;
VeliteWebpackPlugin.started = true;
const dev = compiler.constructor.name === "Compiler";
const { build } = await import("velite");
await build({ watch: dev, clean: !dev });
});
}
}
export default nextConfig;
Key Techniques
Querying Content (Contentlayer)
// app/blog/page.tsx
import { allPosts } from "contentlayer/generated";
import { compareDesc } from "date-fns";
import Link from "next/link";
export default function BlogIndex() {
const posts = allPosts
.filter((post) => post.published)
.sort((a, b) => compareDesc(new Date(a.date), new Date(b.date)));
return (
<div className="space-y-8">
{posts.map((post) => (
<article key={post.slug}>
<Link href={post.url}>
<h2 className="text-2xl font-bold">{post.title}</h2>
</Link>
<p className="text-gray-600 dark:text-gray-400">
{post.description}
</p>
<div className="flex gap-2 mt-2 text-sm text-gray-500">
<time>{new Date(post.date).toLocaleDateString()}</time>
<span>·</span>
<span>{post.readingTime} min read</span>
</div>
</article>
))}
</div>
);
}
Rendering MDX (Contentlayer)
// app/blog/[slug]/page.tsx
import { allPosts } from "contentlayer/generated";
import { useMDXComponent } from "next-contentlayer2/hooks";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { Callout } from "@/components/callout";
const mdxComponents = { Callout };
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
return allPosts
.filter((p) => p.published)
.map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = allPosts.find((p) => p.slug === slug);
if (!post) return {};
return {
title: post.title,
description: post.description,
openGraph: { images: post.image ? [post.image] : [] },
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = allPosts.find((p) => p.slug === slug);
if (!post) notFound();
return (
<article className="prose dark:prose-invert max-w-none">
<h1>{post.title}</h1>
<MDXBody code={post.body.code} />
</article>
);
}
function MDXBody({ code }: { code: string }) {
const Component = useMDXComponent(code);
return <Component components={mdxComponents} />;
}
Querying Content (Velite)
// lib/content.ts
import { posts } from "#site/content";
export function getPublishedPosts() {
return posts
.filter((p) => p.published)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
export function getPostBySlug(slug: string) {
return posts.find((p) => p.slug === slug);
}
export function getPostsByTag(tag: string) {
return getPublishedPosts().filter((p) => p.tags.includes(tag));
}
export function getAllTags(): { name: string; count: number }[] {
const tagMap = new Map<string, number>();
for (const post of getPublishedPosts()) {
for (const tag of post.tags) {
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
}
}
return Array.from(tagMap, ([name, count]) => ({ name, count })).sort(
(a, b) => b.count - a.count
);
}
Computed Fields and Validation
// contentlayer.config.ts — advanced computed fields
export const Doc = defineDocumentType(() => ({
name: "Doc",
filePathPattern: "docs/**/*.mdx",
contentType: "mdx",
fields: {
title: { type: "string", required: true },
nav_title: { type: "string" },
order: { type: "number", default: 999 },
},
computedFields: {
slug: {
type: "string",
resolve: (doc) => doc._raw.flattenedPath.replace("docs/", ""),
},
navTitle: {
type: "string",
resolve: (doc) => doc.nav_title ?? doc.title,
},
pathSegments: {
type: "list",
resolve: (doc) =>
doc._raw.flattenedPath
.replace("docs/", "")
.split("/")
.map((segment: string) => ({ segment })),
},
headings: {
type: "json",
resolve: (doc) => {
const headingRegex = /^(#{2,4})\s+(.+)$/gm;
const headings: { depth: number; text: string; id: string }[] = [];
let match;
while ((match = headingRegex.exec(doc.body.raw)) !== null) {
const text = match[2].trim();
headings.push({
depth: match[1].length,
text,
id: text.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, ""),
});
}
return headings;
},
},
},
}));
Best Practices
- Use
contentlayer2(the community fork) rather than the originalcontentlayerpackage, which is no longer maintained. - For new projects, prefer Velite — it has active development, supports more output formats, and generates lighter bundles.
- Define strict schemas with all required fields. The build fails early on missing data, which is the whole point.
- Use computed fields for derived data (slugs, reading time, headings) rather than computing them at render time.
- Add
generatedor.veliteto.gitignore— these are build artifacts that should be regenerated from source. - Set up path aliases (
#site/contentfor Velite,contentlayer/generatedfor Contentlayer) intsconfig.jsonfor clean imports. - Use
generateStaticParamsto pre-render all content pages at build time for optimal performance. - Combine with
next-seoor the Metadata API for complete SEO coverage per content page.
Anti-Patterns
- Treating generated types as optional: The entire value of these tools is type safety. Do not cast content to
anyor ignore type errors. - Storing generated files in version control: The
.contentlayerand.velitedirectories should be gitignored. Committing them creates noisy diffs and merge conflicts. - Using runtime content fetching for local files: If your content is local Markdown, it should be processed at build time. Do not
fs.readFilein API routes when Contentlayer/Velite already provides typed, compiled data. - Skipping the
publishedfield pattern: Without a boolean flag to control visibility, draft posts leak to production. Always filter bypublishedstatus. - Defining schemas without defaults: Fields without
required: trueordefaultvalues produceundefinedat runtime, defeating the purpose of typed content. - Ignoring build performance: If you have hundreds of MDX files, enable incremental builds (Contentlayer does this automatically) and avoid expensive rehype plugins in development.
- Mixing content sources: Pick one tool. Running both Contentlayer and Velite creates redundant build steps and conflicting type definitions.
Install this skill directly: skilldb add seo-content-skills
Related Skills
Core Web Vitals
Core Web Vitals optimization: LCP, INP, and CLS measurement, diagnosis, and improvement strategies for better search rankings and user experience.
Fumadocs
"fumadocs documentation framework: Next.js App Router native, MDX content collections, full-text search, OpenAPI integration, TypeScript-first, customizable UI components, and content source adapters."
Mdx
"MDX authoring with Next.js: Markdown + JSX, custom components, frontmatter extraction, @next/mdx, mdx-bundler, contentlayer integration, rehype/remark plugins, and syntax highlighting with Shiki or Prism."
Next SEO
"Next.js SEO and metadata management: meta tags, Open Graph, Twitter cards, JSON-LD structured data, canonical URLs, robots directives, and sitemap generation using the Metadata API and next-seo."
Nextra
"Nextra documentation framework: MDX-powered Next.js docs and blog sites, sidebar navigation, full-text search, i18n, themes (docs and blog), frontmatter configuration, and custom components."
Programmatic SEO
Programmatic SEO strategies: generating thousands of search-optimized pages from structured data, template design, internal linking, and indexing at scale.