Skip to content
engineering

Using ContentLayer with Next.js

What Contentlayer dying under my blog taught me about content infrastructure

  • Contentlayer
  • Next.js
  • MDX
  • Static Site Generation

ContentLayer is effectively unmaintained. In its current state, the project cannot be used with next.js 14 due to dependency conflicts. You can see more info or some alternatives like .

There are that Netlify is working on a new version of ContentLayer, but no release date has been announced yet.

I built this blog on Contentlayer. Then the project stalled, its maintainer moved on, and the Callout above is what I had to prepend to my own enthusiastic draft about it. So instead of paraphrasing its documentation, this is the shorter, more honest post: what Contentlayer actually did for me, what runs this blog today, and what I took away from the experience.

What Contentlayer did well

The pitch was simple: your MDX files become typed, importable data. You describe your content schema in code, and the build turns every file into a validated document you can import { allPosts } from 'contentlayer/generated' anywhere in your app. This blog still works exactly that way. Here is a trimmed excerpt of its real config:

contentlayer.config.js
javascript
import { defineDocumentType, makeSource } from 'contentlayer2/source-files';
export const Post = defineDocumentType(() => ({ 'contentType': 'mdx', 'fields': contentFields.post, // title, date, category (required); tags, summary, series, draft, ... 'computedFields': computedFields, // slug, readingTime, toc, externalLink 'filePathPattern': 'blog/**/*.mdx', 'name': 'Post'}));
export default makeSource({ 'contentDirPath': 'data', 'documentTypes': [ Author, Project, Post ], 'mdx': { 'rehypePlugins': [ /* ... */ ], 'remarkPlugins': [ /* ... */ ] }});

The two ideas that earned their keep:

  • Schema as code: every post is validated at build time against field definitions (category and date are required, draft is a boolean, a series is a nested type with a title and an order). A typo in frontmatter fails the build instead of silently rendering wrong.
  • Computed fields: values derived from the content rather than written by hand. This blog computes each post's slug from its file path, readingTime from the raw body, and the table of contents from the headings:
lib/contentLayer/computedFields.js
javascript
const computedFields = {  'readingTime': { 'resolve': (doc) => readingTime(doc.body.raw), 'type': 'json' },  'slug': { 'resolve': (doc) => `category/${relPath(doc)}`, 'type': 'string' },  'toc': { 'resolve': (doc) => extractTocHeadings(doc.body.raw), 'type': 'json' }};

The MDX pipeline (remark and rehype plugins for code titles, link previews, citations, footnotes) hangs off the same config, so the whole content layer lives in a couple of files.

Then it died

Contentlayer's development stopped, and Next.js 14 exposed hard dependency conflicts that were never going to be fixed upstream. For a blog whose entire content pipeline sat on top of it, that was uncomfortable: the thing worked, but every Next.js upgrade from then on was a gamble.

What I use now

This blog runs on , a community fork maintained by Timothy Lin. The migration was almost embarrassingly small: swap contentlayer and next-contentlayer for contentlayer2 and next-contentlayer2 in package.json, and update the import paths (contentlayer2/source-files, as in the excerpt above). The schema, the computed fields, and every page that imports from contentlayer/generated carried over unchanged.

What I learned about picking content infrastructure

  • Judge the bus factor, not the demo. Contentlayer was a beautifully designed single-maintainer project. That is a risk profile, and I signed up for it without thinking about it.
  • Schema-in-code is what saved me. Because the contract between content and code was explicit and small, moving to a fork was an import rename rather than a rewrite. The more of your pipeline lives in declarative config, the cheaper the exit.
  • Abandoned is not the end when the tool is simple enough to fork. If I were starting today I would look at contentlayer2 or , which exists specifically as a successor for this use case. Both prove the idea outlived the original project.

The irony is that Contentlayer dying turned out to be the best endorsement of its design: it was replaceable precisely because it was well factored.

The opinions and views expressed on this blog are solely my own and do not reflect the opinions, views, or positions of my employer or any affiliated organizations. All content provided on this blog is for informational purposes only.