Skip to content

Fix: Contentlayer Not Working — Content Not Generated, Types Missing, or Build Errors

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Contentlayer and Contentlayer2 issues — content source configuration, document type definitions, MDX processing, computed fields, Next.js integration, and migration to alternatives.

The Problem

allPosts is undefined or empty:

import { allPosts } from 'contentlayer/generated';
console.log(allPosts);  // undefined or []

Or the build fails with a content error:

Error: Contentlayer: Content directory not found
// Or: Error: Missing required field "title" in document

Or TypeScript can’t find the generated types:

Module '"contentlayer/generated"' has no exported member 'Post'

Why This Happens

Contentlayer transforms content files (Markdown/MDX) into type-safe JSON data during the build process. It has been in a maintenance state, and Contentlayer2 is the community fork:

  • Content must be generated before use — Contentlayer generates TypeScript types and JSON data from your content files during the build. If the generation step hasn’t run, the contentlayer/generated module is empty or missing.
  • Document type definitions must match content structure — each content file must have frontmatter that matches the fields defined in contentlayer.config.ts. Missing required fields cause build errors.
  • The generated directory must be in tsconfig.json — TypeScript needs to know about the .contentlayer/generated directory. If it’s not in paths or include, imports fail.
  • Original Contentlayer is unmaintained — the original contentlayer package has compatibility issues with newer Next.js versions. contentlayer2 is the maintained fork.

The deeper failure mode is timing. Contentlayer is a build-time tool: it runs before Next.js’s webpack/Turbopack pipeline, writes JSON and .d.ts files into .contentlayer/generated, and exits. Anything downstream that imports contentlayer/generated assumes those artifacts already exist. If the generation step crashes silently — usually because a single MDX file has invalid frontmatter — Next.js continues with stale or empty output. The symptom is a runtime “undefined” on allPosts even though tsc passes. The cause is two builds out of sync.

The second source of pain is the upstream maintenance gap. The original contentlayer package has not received releases that track Next.js 14/15 changes (App Router internals, ESM-first module resolution, React 19 transitions). When you upgrade Next.js, the original package often throws on plugin hooks that no longer exist, or fails to write the generated directory at all. The community fork contentlayer2 keeps pace, but only if you migrate the imports cleanly. Mixed imports — contentlayer in one file and contentlayer2 in another — leave two separate generated trees and break type inference.

The third trap is editor tooling. TypeScript Language Server caches the .contentlayer/generated directory aggressively. If you add a new document type or change a field, the file system updates but VS Code still serves stale types. The build succeeds, the IDE reports errors, and you waste an afternoon debugging code that already works. Restarting the TS server (TypeScript: Restart TS Server in the command palette) is part of the workflow.

Production Incident Lens: When the Docs Site Goes Dark

The blast radius for a Contentlayer failure is the entire documentation or blog surface. When the Vercel build pipeline reports Error: Contentlayer: Content directory not found, your /docs and /blog routes return 404 or render empty arrays. For a product whose docs are the primary support channel, this is a customer-facing outage even though no server crashed.

The on-call playbook should distinguish three failure modes:

  1. Hard build failurenpm run build exits non-zero. Vercel keeps the previous deployment live; no user impact, but the next merge is blocked until the broken MDX file is fixed.
  2. Silent generation failure — generation logs a warning but writes an empty allPosts array. The build succeeds, the new deployment ships, and the docs page renders “No posts found.” This is the dangerous mode because monitoring sees a 200 response.
  3. Type-only failure — code generation succeeds at runtime but .d.ts files are stale. CI passes the build, but PR reviewers see TypeScript errors in their editors and waste cycles debugging.

Treat Contentlayer like any other code-generation pipeline: pin the version, run generation in CI with --strict mode, and add a smoke test that asserts allPosts.length > 0 before the deploy promotes. A trivial integration test that imports allPosts and exits 1 on empty arrays catches the silent failure before it ships. Track the Contentlayer maintenance signal too — if the upstream repo has not seen a commit in 90 days and you depend on a feature for a public-facing surface, plan a migration sprint before Next.js’s next major release forces it.

Fix 1: Setup with Contentlayer2

# Use the maintained fork
npm install contentlayer2 next-contentlayer2
// contentlayer.config.ts
import { defineDocumentType, makeSource } from 'contentlayer2/source-files';

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: true },
    tags: { type: 'list', of: { type: 'string' }, default: [] },
    image: { type: 'string' },
    author: { type: 'string', default: 'Unknown' },
  },
  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 const Page = defineDocumentType(() => ({
  name: 'Page',
  filePathPattern: 'pages/**/*.mdx',
  contentType: 'mdx',
  fields: {
    title: { type: 'string', required: true },
    description: { type: 'string' },
  },
  computedFields: {
    slug: {
      type: 'string',
      resolve: (doc) => doc._raw.flattenedPath.replace('pages/', ''),
    },
  },
}));

export default makeSource({
  contentDirPath: 'content',  // Content files directory
  documentTypes: [Post, Page],
  mdx: {
    remarkPlugins: [],
    rehypePlugins: [],
  },
});
# Content directory structure
content/
├── posts/
│   ├── getting-started.mdx
│   ├── advanced-typescript.mdx
│   └── react-patterns.mdx
└── pages/
    ├── about.mdx
    └── contact.mdx
---
title: Getting Started with TypeScript
description: A comprehensive guide to TypeScript basics
date: 2026-03-29
tags: [typescript, tutorial]
published: true
---

# Getting Started with TypeScript

This is the content of your MDX file.

You can use **React components** here too.

Fix 2: Next.js Configuration

// next.config.mjs
import { withContentlayer } from 'next-contentlayer2';

const nextConfig = {
  // Your Next.js config
};

export default withContentlayer(nextConfig);
// tsconfig.json — add Contentlayer paths
{
  "compilerOptions": {
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
  },
  "include": [
    ".contentlayer/generated"
  ]
}
// app/blog/page.tsx — list all posts
import { allPosts } from 'contentlayer/generated';
import { compareDesc } from 'date-fns';
import Link from 'next/link';

export default function BlogPage() {
  const posts = allPosts
    .filter(post => post.published)
    .sort((a, b) => compareDesc(new Date(a.date), new Date(b.date)));

  return (
    <div>
      <h1>Blog</h1>
      {posts.map(post => (
        <article key={post.slug}>
          <Link href={post.url}>
            <h2>{post.title}</h2>
          </Link>
          <p>{post.description}</p>
          <span>{post.readingTime}</span>
          <time>{new Date(post.date).toLocaleDateString()}</time>
          <div className="flex gap-2">
            {post.tags.map(tag => (
              <span key={tag} className="text-sm bg-gray-100 px-2 py-1 rounded">{tag}</span>
            ))}
          </div>
        </article>
      ))}
    </div>
  );
}

// app/blog/[slug]/page.tsx — single post
import { allPosts } from 'contentlayer/generated';
import { notFound } from 'next/navigation';
import { useMDXComponent } from 'next-contentlayer2/hooks';

export async function generateStaticParams() {
  return allPosts.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 };
}

export default function PostPage({ params }: { params: { slug: string } }) {
  const post = allPosts.find(p => p.slug === params.slug);
  if (!post) notFound();

  const MDXContent = useMDXComponent(post.body.code);

  return (
    <article className="prose dark:prose-invert max-w-none">
      <h1>{post.title}</h1>
      <time>{new Date(post.date).toLocaleDateString()}</time>
      <MDXContent />
    </article>
  );
}

Fix 3: Custom MDX Components

// components/mdx-components.tsx
import Image from 'next/image';
import Link from 'next/link';

const mdxComponents = {
  h2: ({ children, ...props }: React.HTMLProps<HTMLHeadingElement>) => {
    const id = children?.toString().toLowerCase().replace(/\s+/g, '-');
    return <h2 id={id} {...props}>{children}</h2>;
  },
  a: ({ href, children }: { href?: string; children: React.ReactNode }) => {
    if (href?.startsWith('/')) return <Link href={href}>{children}</Link>;
    return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>;
  },
  img: ({ src, alt }: { src?: string; alt?: string }) => (
    <Image src={src || ''} alt={alt || ''} width={800} height={400} className="rounded-lg" />
  ),
  Callout: ({ type = 'info', children }: { type?: string; children: React.ReactNode }) => (
    <div className={`border-l-4 p-4 my-4 ${
      type === 'warning' ? 'border-yellow-400 bg-yellow-50' :
      type === 'error' ? 'border-red-400 bg-red-50' :
      'border-blue-400 bg-blue-50'
    }`}>
      {children}
    </div>
  ),
};

// Use in post page
<MDXContent components={mdxComponents} />

Fix 4: Remark and Rehype Plugins

// contentlayer.config.ts
import { makeSource } from 'contentlayer2/source-files';
import remarkGfm from 'remark-gfm';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypePrettyCode from 'rehype-pretty-code';

export default makeSource({
  contentDirPath: 'content',
  documentTypes: [Post, Page],
  mdx: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { behavior: 'wrap' }],
      [rehypePrettyCode, {
        theme: 'github-dark',
        keepBackground: false,
      }],
    ],
  },
});

Fix 5: Table of Contents Generation

// Computed field for TOC
const Post = defineDocumentType(() => ({
  name: 'Post',
  // ...
  computedFields: {
    toc: {
      type: 'json',
      resolve: (doc) => {
        const headingRegex = /^(#{2,3})\s+(.+)$/gm;
        const headings: { level: number; text: string; id: string }[] = [];
        let match;

        while ((match = headingRegex.exec(doc.body.raw)) !== null) {
          const text = match[2].trim();
          headings.push({
            level: match[1].length,
            text,
            id: text.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
          });
        }

        return headings;
      },
    },
  },
}));

// Display TOC in post layout
function TableOfContents({ headings }: { headings: { level: number; text: string; id: string }[] }) {
  return (
    <nav>
      <h3>Table of Contents</h3>
      <ul>
        {headings.map(h => (
          <li key={h.id} style={{ paddingLeft: `${(h.level - 2) * 16}px` }}>
            <a href={`#${h.id}`}>{h.text}</a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Fix 6: Alternatives (If Contentlayer Doesn’t Work)

If Contentlayer compatibility issues persist, consider these alternatives:

// Option 1: Velite — modern Contentlayer alternative
// npm install velite
// velite.config.ts
import { defineConfig, s } from 'velite';

export default defineConfig({
  collections: {
    posts: {
      name: 'Post',
      pattern: 'posts/**/*.mdx',
      schema: s.object({
        title: s.string(),
        date: s.isodate(),
        description: s.string(),
        body: s.mdx(),
      }),
    },
  },
});

// Option 2: Astro Content Collections (if using Astro)
// Option 3: next-mdx-remote with manual file reading
// Option 4: @content-collections/core

Still Not Working?

allPosts is undefined — the generated directory doesn’t exist. Run npm run dev (Contentlayer generates during dev/build). Check that .contentlayer/generated exists. If using Contentlayer2, ensure you import from contentlayer/generated (the alias set in tsconfig.json).

“Content directory not found”contentDirPath in contentlayer.config.ts must point to an existing directory relative to the project root. Default is 'content'. Create the directory and add at least one content file.

TypeScript errors on generated types — add .contentlayer/generated to tsconfig.json’s include array and paths. Restart the TypeScript server in your IDE after the first generation.

MDX components don’t render — pass components to useMDXComponent: <MDXContent components={mdxComponents} />. Without the components prop, custom components render as undefined HTML elements.

Build succeeds but deployed site shows empty post list — the .contentlayer/generated directory is gitignored (correctly) but the deploy environment skipped generation. Confirm that next build runs withContentlayer and that the build command on Vercel/Netlify is next build, not a custom script that bypasses the wrapper. Add a smoke test that fails the build if allPosts.length === 0.

Generation hangs in monorepos — Contentlayer reads the entire content tree on each run. In a Turborepo or pnpm workspace, the file watcher may pick up sibling packages and OOM. Set contentDirExclude in makeSource to exclude node_modules, .next, and other workspace directories.

Stale types after pulling new content from git — Contentlayer caches generation results in .contentlayer/.cache. After a git pull that adds or modifies MDX files, delete the cache and regenerate: rm -rf .contentlayer && npm run dev. CI builds should always start from a clean cache.

Generation succeeds but newly added documents are missing from allPostsfilePathPattern in defineDocumentType uses glob syntax relative to contentDirPath. A pattern like 'posts/**/*.mdx' matches MDX files but skips .md files. If you renamed .mdx to .md or added a subdirectory the pattern doesn’t cover, those documents are silently excluded. Print allDocuments.length versus the on-disk file count before each deploy as a smoke check.

For related content issues, see Fix: MDX Not Working, Fix: Nextra Not Working, Fix: Next.js Build Failed, and Fix: Astro Content Collections Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles