Creating a CMS using Next.js 13 + Contentlayer + MDX

Erik Moore

Writing and sharing content is something that is becoming more common. It almost seems necessary as our world becomes increasingly digital and remote.

Company updates, educational articles, blogs, technical documentation... these all fall under the definition of "content".

There are many different ways to manage content. Popular versions include:

  • website + CMS systems like WordPress or Wix
  • headless CMS like Contentful

As a development team we like to keep our content in our repositories. This allows us to leverage git for version control. It also allows us to never leave the codebase to write. This keeps us productive and efficient.

Follow along if you're looking to start sharing content from a new or existing Next.js app.

MDX

What is it?

MDX is a format that enables combining markdown with JSX components.

Why should I use it?

We use MDX because it provides us a simple and straightforward way to apply our codebase styles to our content. We can pass in styles to keep the UI more consistent.

The more powerful feature of MDX is that it can render React components in the markdown file. This means we get all the benefits of both React and markdown. Our content can now become interactive and feel like it's part of the rest of the app.

Tutorial

Create a Next.js app

If you already have a Next.js project then you can skip to install the packages.

The first thing you need to get started is a Next.js 13 app. We use the App Router architecture for our projects.

Note: This tutorial may not work perfectly if you use Next.js with the Pages Router architecture.

We build our projects using TypeScript and TailwindCSS. They provide improved structure and stability to our projects.

# replace my-app with the name of your project
npx create-next-app@latest --typescript --tailwind --eslint my-app

After you've answered the prompts, go to your new directory.

cd my-app

Install the packages

npm install contentlayer next-contentlayer

Setup config files

Update your next.config.js file:

next.config.js
const { withContentlayer } = require("next-contentlayer");
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  // ...
};
 
module.exports = withContentlayer(nextConfig);

Update your tsconfig.json file:

tsconfig.json
{
  "compilerOptions": {
    // rest of options
    // ...
    "baseUrl": ".",
    "paths": {
      // existing paths
      // ...
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
  },
  "include": {
    // existing files and filepaths
    // ...
    ".contentlayer/generated"
  }
}

Update your .gitignore file:

.gitignore
# existing ignored files
# ...
 
.contentlayer

Define content structure

This is where the uniqueness of your content comes into play. What do you want to include?

Bare minimum is likely a title. Other options might be:

  • date of the post
  • description of the content
  • image to display as a hero
  • tags to assist with SEO

Our example uses a simplified structure that is used for our website.

Create contentlayer.config.ts in the root of your project.

contentlayer.config.ts
import { defineDocumentType, makeSource } from "contentlayer/source-files";
 
/** @type {import('contentlayer/source-files').ComputedFields} */
export const Post = defineDocumentType(() => ({
  name: "Post",
  filePathPattern: "**/*.mdx",
  contentType: "mdx",
  fields: {
    title: { type: "string", required: true },
    description: { type: "string", required: true },
    date: { type: "date", required: true },
    tags: { type: "list", of: { type: "string" }, required: true },
  },
  computedFields: {
    slug: {
      type: "string",
      resolve: (post) => `/blog/${post._raw.flattenedPath}`,
    },
  },
}));
 
export default makeSource({ contentDirPath: "_posts", documentTypes: [Post] });

A quick explanation of this file:

In the Blog object:

  • name: The name of our document type
  • fieldPathPattern: Where we will be getting our files and what extension they have
  • contentType: The type of file for the content
  • fields: The structure of the metadata for our content
  • computedFields: The path where the content will be available

In makeSource:

  • contentDirPath: The directory where the content will reside.
  • documentTypes: The content type that will be used.

You can customize this file to fit your needs.

If you need additional info for this you can check out the Contentlayer Field Types documentation.

Note: If you want to know why we included an underscore in _posts for our contentDirPath you can read about private folders in the Next.js documentation.

Create some content

Create a directory with the same name as your contentDirPath:

mkdir _posts

Create a file in that directory. The filename will be the path that viewers access the content.

first-post.md
---
title: First Post
description: This is my first piece of content
date: 2023-08-12
tags: [intro]
---
 
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur non arcu ...

We recommend creating a second post so you can better visualize how the content is accessed and will be rendered.

The directory structure will something like this:

_posts/
├── first-post.md
├── second-post.md
└── # other posts

Generate content

We need to generate our content. To do that we simply need to run our app.

npm run dev

You will see a .contentlayer directory has appeared in the root of your app. It was autogenerated when we ran our script. This is where we'll be accessing our content to render it in our app.

Create content list page

The most common setup for accessing content in a web app or site is through a list page. From there you can select the individual pieces of content you want.

Create a directory in your app directory where you want your content to be accessed. The name of the directory should be the same as in your computedFields path. Ours will be blog.

Create a page.tsx file to display the content list.

app/blog/page.tsx
import { allPosts, type Post } from "contentlayer/generated";
import Link from "next/link";
import { formatDate } from "./util";
 
function BlogPreview(post: Post) {
  return (
    <div className="grid gap-2">
      <h2 className="text-2xl hover:underline">
        <Link href={post.slug}>{post.title}</Link>
      </h2>
      <p className="text-gray-500">{post.description}</p>
      <time dateTime={post.date} className="block text-xs">
        {formatDate(post.date)}
      </time>
    </div>
  );
}
 
export default function Blog() {
  const posts = allPosts.sort(
    (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
  );
  return (
    <section className="container py-20">
      <h1 className="text-4xl/snug">My Blog</h1>
      <div className="grid gap-4 grid-cols-3">
        {posts.map((post) => {
          return <BlogPreview key={post._id} {...post} />;
        })}
      </div>
    </section>
  );
}

If you go to http://localhost:3000/blog you'll be able to see the post list displayed there.

The import from contentlayer/generated references the generated file from earlier. As we update our content files, new files will be regenerated to give us updated versions. allPosts references a list of all the content in our _post directory, and Post is our defineDocumentType from our `contentlayer.config.ts``.

app/blog/utils.ts
export const formatDate = (date: string) => {
  return new Date(date).toLocaleDateString("en-US", {
    month: "long",
    day: "numeric",
    year: "numeric",
  });
};

We've done a basic sort on the allPosts list to display the posts in descending order by date.

We also created a utils.ts file with a formatDate function to improve the display value of our post date. Another option is to use an npm package like date-fns to handle the formatting.

Create content page

The final step is to display the individual post.

We will need a component to render our .mdx content. To improve reusability we'll create a separate file in a components directory.

components/mdx-components.tsx
export function Mdx({ code }: { code: string }) {
  const Component = useMDXComponent(code);
 
  const components: MDXComponents = {
    a: ({ href, children }) => (
      <Link href={href ?? ""} className="text-sky-700 hover:text-sky-700/70">
        {children}
      </Link>
    ),
    CustomComponent: () => (
      <div className="p-4 border flex items-center justify-center bg-sky-100 rounded-md">
        <h1 className="text-5xl font-bold text-sky-700 mb-0">
          My Custom Component
        </h1>
      </div>
    ),
  };
 
  return <Component components={components} />;
}

The Mdx component allows us to render our markdown and use any custom styles or components we want. It takes the code key and converts it into JSX.

In our example we've provided custom coloring for our <a>. We also included a component named CustomComponent which you can call it in your .mdx file. It would look like this:

My Custom Component

The component can be as simple or complex as you need it to be.

You can also setup the Mdx component to take a components prop of type MDXComponents. This would allow you to pass in any custom components you need depending on where you're calling <Mdx />.

In the blog directory we'll create a sub-directory named [slug] and then page.tsx. The path should look like app/blog/[slug]/page.tsx.

How the [slug] directory works can be found in the Next.js Dynamic Routes docs.

app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { formatDate } from "@/app/blog/util";
import { Mdx } from "@/components/mdx-components";
import { Section } from "@/components/section";
import { allPosts } from "contentlayer/generated";
 
export const generateStaticParams = async (): Promise<{ slug: string }[]> =>
  allPosts.map((post) => ({ slug: post._raw.flattenedPath }));
 
export default function BlogPost({
  params,
}: {
  params: { slug: string };
}): React.JSX.Element {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug);
 
  if (!post) {
    return notFound();
  }
 
  return (
    <section className="container py-20">
      <article className="max-w-prose prose prose-slate mx-auto">
        <div className="grid gap-2">
          <h1 className="text-4xl/snug font-bold">{post.title}</h1>
          <time dateTime={post.date} className="block text-xs">
            {formatDate(post.date)}
          </time>
        </div>
        <Mdx code={post.body.code} />
      </article>
    </section>
  );
}

Our BlogPost component takes the entire list of blogs and finds the one that matches the slug param. If there is no match, we'll render the not-found.tsx page that is in the root of our app directory.

Complete

There you have it. You now have a fully functional CMS with TSX capabilities in your codebase. This is very close to how we write the architecture for our internal projects and our clients.

Hopefully it helps you get up and running to begin sharing your content with the world.

Additional info

Typography

The TailwindCSS prose class comes from the @tailwindcss/typography package. It provides really nice spacing to the markdown content without having to write it ourselves.

npm install -D @tailwindcss/typography

In your tailwind.config.js file, add the plugin.

tailwind.config.js
{
  // rest of your config
  // ...
  plugins: [
    // rest of your plugins
    // ...
    require("@tailwindcss/typography"),
  ];
}

If you adopt this method you can use the prose element modifiers rather than passing in styled components as per the example.

Syntax highlighting

If you're including code in your content it's likely that you'll want to add syntax highlighting. There are a number of ways to do this. I may cover it in another post.

A great tutorial on how to do this is Build-Time Syntax Highlighting: Zero Client-Side JS, Support for 100+ Languages and Any VSCode Theme.


Additional repositories

Sources

Helpful Examples

Thank you

If you liked this post or found it helpful let me know.

Share