Configuring Contentlayer with a Next.js App

Nov 22nd, 2022

0

views

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 contentlayer, 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.

terminal
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

next.config.js
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

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.

./content/definitions/post.tsx
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.

./content/definitions/tag.tsx
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,
    },
  },
}));
./content/definitions/series.tsx
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.

./content/definitions/post.tsx
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.

contentlayer.config.js
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 Markdown 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

terminal
yarn add remark-gfm

then, add it to contentlayer.config.js

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)

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

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

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.

./content/definitions/post.tsx
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.

./content/definitions/post.tsx
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 Routes. 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) PlanetScale database with Prisma schemas and type-safe client generation to work with the database.