Configuring Contentlayer with a Next.js App

created
2022-11-22
modified
2026-02-20
words
~468
reading
~3 min
Table of Contents

Previously, we talked through the choices behind the tech stack used to build my developer blog. The step towards building a functional blog is wiring up contentlayer1, an SDK for transforming unstructured content into type-safe json data structures. Contentlayer is a breeze to use, but in case you're stuck, here is how I set it up.

Installation §

Installing contentlayer is a fairly straighforward process.

1. Install Contentlayer

Begin by adding contentlayer and next-contentlayer to your project.

yarn add contentlayer next-contentlayer

contentlayer is the actual package, while next-contentlayer provides the utilities for interacting with your Next.js app.

2. Wrap your Next Config with the withContentLayer Function

import { withContentLayer } from "next-contentlayer";

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
};

module.exports = withContentlayer(nextConfig);

3. Add the Generated Contentlayer to your tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.tsx",
    "**/*.ts",
    ".contentlayer/generated"
  ]
}

Defining Schemas §

I started by creating a /content folder within my project directory. Within the /content folder, I created two more folders, /definitions and /posts.

├── definitions
└── posts

I then put my content schemas within the /definitions folder, starting with Post.

import { defineDocumentType } from "contentlayer/source-files";

export const Post = defineDocumentType(() => ({
  name: "Post",
  filePathPattern: "posts/*.mdx",
  contentType: "mdx",
  fields: {
    title: { type: "string", required: true },
    publishedAt: { type: "string", required: true },
    description: { type: "string" },
    status: {
      type: "enum",
      options: ["draft", "published"],
      required: true,
    },
  },
}));

There's two more schemas I need to define: Tag and Series. Tags will be used to properly divide post topics, while Series will link togther posts that are sequential.

import { defineNestedType } from "contentlayer/source-files";

// define tags elsewhere (in a constants file)
import { tagNames, tagSlugs } from "../../lib/contentlayer";

export const Tag = defineNestedType(() => ({
  name: "Tag",
  fields: {
    title: {
      type: "enum",
      required: true,
      options: tagNames,
    },
    slug: {
      type: "enum",
      required: true,
      options: tagSlugs,
    },
  },
}));
import { defineNestedType } from "contentlayer/source-files";

export const Series = defineNestedType(() => ({
  name: "Series",
  fields: {
    title: {
      type: "string",
      required: true,
    },
    order: {
      type: "number",
      required: true,
    },
  },
}));

Once I've defined Series and Tag, I can import them and use their definitions to finish out the fields in the Post model.

import { defineDocumentType } from "contentlayer/source-files";

import { Tag } from "./tag";
import { Series } from "./series";

export const Post = defineDocumentType(() => ({
  // ...
  fields: {
    title: { type: "string", required: true },
    publishedAt: { type: "string", required: true },
    description: { type: "string" },
    status: {
      type: "enum",
      options: ["draft", "published"],
      required: true,
    },
    series: {
      type: "nested",
      required: false,
      of: Series,
    },
    tags: {
      type: "list",
      required: false,
      of: Tag,
    },
  },
}));

contentlayer.config.js §

Now, we need to feed the post schema definition to contentlayer. In the root directory, create a contentlayer.config.js file.

import { makeSource } from "contentlayer/source-files";

import { Post } from "./content/defintions/post";

export default makeSource({
  contentDirPath: "content",
  documentTypes: [Post],
  mdx: {
    esbuildOptions(options) {
      options.target = "esnext";
      return options;
    },
    remarkPlugins: [],
    rehypePlugins: [],
  },
});

Plugins §

Make note of the remarkPlugins and rehypePlugins fields in contentlayer.config.js. Contentlayer is pretty nifty, and we can use all sorts of plugins during the content generation process.

Github Flavored Markdown §

Github Flavored Markdown2 is a subset of markdown that Github uses in its readme files. We can enable it in our content using remark-gfm. Install it using

yarn add remark-gfm

then, add it to contentlayer.config.js

import { makeSource } from "contentlayer/source-files";
import { Post } from "./content/defintions/post";
import remarkGfm from "remark-gfm";

export default makeSource({
  // ...
  mdx: {
    // ...
    remarkPlugins: [[remarkGfm]],
    rehypePlugins: [],
  },
});

We can automatically wrap our markdown headers with <a> tags for our table of contents. Add the following packages using yarn (or another package manager)

yarn add rehype-autolink-headings github-slugger rehype-slug

then, add it to the list of rehype plugins in your contentlayer.config.js.

import { makeSource } from "contentlayer/source-files";
import { Post } from "./content/defintions/post";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-heading";

export default makeSource({
  // ...
  mdx: {
    // ...
    remarkPlugins: [[remarkGfm]],
    rehypePlugins: [
      [rehypeSlug],
      [
        rehypeAutolinkHeadings,
        {
          behavior: "wrap",
          properties: {
            className: ["<insert class names here>"],
          },
        },
      ],
    ],
  },
});

Finally, you need to go to the Post model and parse the headings directly from the content.

import { defineDocumentType } from "contentlayer/source-files";
import { Tag } from "./tag";
import { Series } from "./series";
import GithubSlugger from "github-slugger";

export const Post = defineDocumentType(() => ({
  // ...
  computedFields: {
    headings: {
      type: "json",
      resolve: async (doc) => {
        const slugger = new GithubSlugger();

        // https://stackoverflow.com/a/70802303
        const regex = /\n\n(?<flag>#{1,6})\s+(?<content>.+)/g;

        const headings =
          Array.from(doc.body.raw.matchAll(regex)).map(
          // @ts-ignore
          ({ groups }) => {
            const flag = groups?.flag;
            const content = groups?.content;
            return {
              heading: flag?.length,
              text: content,
              slug: content ? slugger.slug(content) : undefined,
            };
          }
        );

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

Slug §

We need to generate a slug in order to render a page per post using Next.js. Add another computedField to the Post schema.

import { defineDocumentType } from "contentlayer/source-files";
import { Tag } from "./tag";
import { Series } from "./series";
import GithubSlugger from "github-slugger";

export const Post = defineDocumentType(() => ({
  // ...
  computedFields: {
    // ...
    slug: {
      type: "string",
      resolve: (doc) => doc._raw.sourceFileName.replace(/\.mdx$/, ""),
    },
  },
}));

Rendering Content §

We can render our posts now using Next.js Dynamic Routes3. In the /pages folder, create a new folder /post and a file [slug].tsx. Adding brackets to the file name treats it as a parameter.

We can access the json generated by contentlayer by importing from contentlayer/generated.

import { allPosts } from "contentlayer/generated";

With that import, you grab all generated posts, and can render how you'd like. If you're using a static generator like Next.js, you're going to want to put it in the getStaticProps method of a page.

import { allPosts } from "contentlayer/generated";
import { compareDesc } from "date-fns";

export async function getStaticProps() {
  const posts = allPosts
    .sort((a, b) => {
      return compareDesc(new Date(a.publishedAt), new Date(b.publishedAt));
    })
    .filter((p) => p.status === "published");

  return { props: { posts: posts } };
}

What's Next? §

A nice-to-have for any blog is to be able to have visitors "like" your article, and for you to be able to track analytics. Next up, we will set up a (free) PlanetScale4 database with Prisma5 schemas and type-safe client generation to work with the database.