Configuring Contentlayer with a Next.js App
Nov 22nd, 2022
•
•
•
1,159 words (5 minutes)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.
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 { definedNestedType } from "contentlayer/source-files";
// define tags elsewhere (in a constants file)
import { tagNames, tagSlugs } from "../../lib/contentlayer";
export const Tag = definedNestedType(() => ({
name: "Tag",
fields: {
title: {
type: "enum",
required: true,
options: tagNames,
},
slug: {
type: "enum",
required: true,
options: tagSlugs,
},
},
}));
import { definedNestedType } from "contentlayer/source-files";
export const Series = definedNestedType(() => ({
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 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
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: [],
},
});
Computed Heading Links
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 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.
contentlayer
Tech
Next.js
MDX