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.
Installing contentlayer is a fairly straighforward process.
Begin by adding contentlayer and next-contentlayer to your project.
yarn add contentlayer next-contentlayercontentlayer is the actual package, while next-contentlayer provides the utilities for interacting with your Next.js app.
withContentLayer Functionimport { withContentLayer } from "next-contentlayer";
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
};
module.exports = withContentlayer(nextConfig);tsconfig.json{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"contentlayer/generated": ["./.contentlayer/generated"]
}
},
"include": [
"next-env.d.ts",
"**/*.tsx",
"**/*.ts",
".contentlayer/generated"
]
}I started by creating a /content folder within my project directory. Within the /content folder, I created two more folders, /definitions and /posts.
├── definitions
└── postsI 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: [],
},
});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 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-gfmthen, 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-slugthen, 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;
},
},
},
}));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$/, ""),
},
},
}));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 } };
}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.