Skip to main content
Technology & EngineeringSeo Content353 lines

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."

Quick Summary28 lines
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 lines
Paste into your CLAUDE.md or agent config

Contentlayer & 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>&middot;</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 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.

Anti-Patterns

  • 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.
  • Using runtime content fetching for local files: If your content is local Markdown, it should be processed at build time. Do not fs.readFile in API routes when Contentlayer/Velite already provides typed, compiled data.
  • 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.
  • 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

Get CLI access →