Creating a CMS using Next.js 13 + Contentlayer + MDX
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:
const { withContentlayer } = require("next-contentlayer");
/** @type {import('next').NextConfig} */
const nextConfig = {
// ...
};
module.exports = withContentlayer(nextConfig);
Update your tsconfig.json
file:
{
"compilerOptions": {
// rest of options
// ...
"baseUrl": ".",
"paths": {
// existing paths
// ...
"contentlayer/generated": ["./.contentlayer/generated"]
}
},
"include": {
// existing files and filepaths
// ...
".contentlayer/generated"
}
}
Update your .gitignore
file:
# 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 postdescription
of the contentimage
to display as a herotags
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.
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 typefieldPathPattern
: Where we will be getting our files and what extension they havecontentType
: The type of file for the contentfields:
The structure of the metadata for our contentcomputedFields
: 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.
---
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.
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``.
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.
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.
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.
{
// 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
- remarkjs/remark-gfm
- atomiks/rehype-pretty-code
- rehypejs/rehype-highlight
- shikijs/shiki
- rehypejs/rehype-autolink-headings
- rehypejs/rehype-slug
Sources
Helpful Examples
Thank you
If you liked this post or found it helpful let me know.
- Email me at hello@renaissance92.com
- Follow me on Twitter at @erikryanmoore
- If you are interested in the work we are doing, follow us on Twitter at @Renaissance92_