6 min read
How to build a technical blog with Next.js and Contentlayer
Bharat Kilaru
We're going to walk through how we built this technical blog! We used Next.js and Contentlayer. We'll cover how to set up a Next.js app, how to set up Contentlayer, and how to use MDX to write blog posts.
What is Contentlayer?
Contentlayer is a preprocessor. It allows you to validate and transform your content into type-safe JSON. In our case, we're using it to take our markdown files and transform them into JSON that we can use in our Next.js app.
Why is this useful?
You can write markdown and get type-safe JSON out of it. You can use this JSON to build a blog, a documentation site, or anything else you can think of. In our case, we're using it to take our MDX files, build a blog, and use Next.js to render the blog posts.
In the wild
Check out these examples of Contentlayer in the wild:
Set up Next.js
-
Let's start by creating a new Next.js app using the Next.js CLI
npx create-next-app next-blog --ts
-
The latest version of the Next.js CLI will ask you some setup questions. You can choose your personal preference, but for the sake of the tutorial, we'll use all the defaults (press
return
) - except for the experimental 'app' directory - we'll enable this, but pretty optional in our case!✔ Would you like to use ESLint with this project? … Yes ✔ Would you like to use `src/` directory with this project? … No ? Would you like to use experimental `app/` directory with this project? › Yes ✔ What import alias would you like configured? … @/*
-
Change directories to your new app
cd next-blog
Set up Contentlayer
-
Install Contentlayer
npm install contentlayer next-contentlayer
-
Configure Contentlayer
Set up contentlayer.config.ts
to use the sourceFiles
source plugin. This plugin will allow us to fetch data from our local filesystem. We'll set up Post and Author document types, and we'll set the content directory to blog
.
import { defineDocumentType, makeSource } from "contentlayer/source-files";
export const Post = defineDocumentType(() => ({
name: "Post",
filePathPattern: `posts/*.md`,
fields: {
title: { type: "string", required: true },
author: { type: "string", required: true },
brief: { type: "string", required: true },
heroImage: { type: "string", required: true },
readTimeInMinutes: { type: "number", required: true },
createdAt: { type: "date", required: true },
updatedAt: { type: "date", required: false },
},
computedFields: {
slug: {
type: "string",
resolve: (post) => {
const parts = post._raw.flattenedPath.split("/");
return parts[parts.length - 1];
},
},
url: {
type: "string",
resolve: (post) => {
const parts = post._raw.flattenedPath.split("/");
const slug = parts[parts.length - 1];
return `/blog/${slug}`;
},
},
},
}));
export const Author = defineDocumentType(() => ({
name: "Author",
filePathPattern: `authors/*.md`,
fields: {
name: { type: "string", required: true },
image: { type: "string", required: true },
},
computedFields: {
slug: {
type: "string",
resolve: (author) => {
const parts = author._raw.flattenedPath.split("/");
return parts[parts.length - 1];
},
},
},
}));
export default makeSource({
contentDirPath: "blog",
documentTypes: [Post, Author],
});
This configuration assumes that your content is located in a content directory and that you have a Post document type with a specific schema.
- Update your config file to use the
withContentLayer
plugin:
Update next.config.js
file to use the withContentLayer
plugin:
module.exports = withContentlayer(nextConfig);
- Fetch data with getStaticProps:
In your Next.js pages, you can now fetch data from Contentlayer using the getStaticProps
function. Let's set up a [slug].tsx page to fetch a single post:
import type { GetStaticPaths, GetStaticProps } from "next";
import Image from "next/image";
import Link from "next/link";
import type { Author, Post } from "contentlayer/generated";
import { allAuthors, allPosts } from "contentlayer/generated";
import MarkdownRenderer from "@/components/home/landing-page/MarkdownRenderer";
type Props = {
post: Post;
author: Author;
};
export default function PostPage({ post, author }: Props) {
return (
<div>
<div className="container mx-auto mt-16 max-w-[920px] py-12 px-4">
<div className="mt-4 flex flex-col space-y-4">
{post.heroImage && (
<Image
className="mx-auto mb-8 rounded-md"
src={post.heroImage}
alt={post.title}
width={920}
height={640}
/>
)}
<div className="flex items-center text-base">
<span className="px-2 text-gray-200">•</span>
</div>
<h1 className="mb-3 font-mono text-4xl font-semibold">
{post.title}
</h1>
<div className="flex items-center">
<Link
href={`/blog/author/${author.slug}`}
className="group flex items-center space-x-2"
>
<Image
className="rounded-full"
src={author.image}
alt={author.name}
width={30}
height={30}
/>
<h2 className="font-mono font-medium group-hover:underline">
{author.name}
</h2>
</Link>
</div>
<MarkdownRenderer content={post.body.raw} />
<div className="h-8" />
</div>
</div>
</div>
);
}
export const getStaticPaths: GetStaticPaths = async () => {
const paths = allPosts.map((post) => ({
params: { slug: post.slug },
}));
return {
paths,
fallback: false,
};
};
export const getStaticProps: GetStaticProps = async (context) => {
const { params } = context;
const slug = params?.slug as string;
const post = allPosts.find((p) => p.slug === slug);
if (!post) {
throw new Error(`Post with slug ${slug} not found`);
}
const author = allAuthors.find((a) => a.slug === post.author);
if (!author) {
throw new Error(`Author with slug ${post.author} not found`);
}
return {
props: {
post,
author,
},
};
};
As you can see, we're getting our types from Contentlayer. We're also using the allPosts
and allAuthors
functions to fetch all posts and authors. We're also using the getStaticPaths
function to generate all possible paths for our posts.
Your Next.js app should now be running with data fetched from Contentlayer.
Remember to adjust the example configuration and data fetching according to your specific data source and requirements. You can find more information on how to use Contentlayer in the documentation.
- Markdown Renderer:
Let's now build our markdown renderer. It's built using react-markdown
, react-syntax-highlighter
, and react-twitter-embed
. Here's the code:
import ReactMarkdown from "react-markdown";
import Image from "next/image";
import { TwitterTweetEmbed } from "react-twitter-embed";
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
import tsx from "react-syntax-highlighter/dist/cjs/languages/prism/tsx";
import typescript from "react-syntax-highlighter/dist/cjs/languages/prism/typescript";
import json from "react-syntax-highlighter/dist/cjs/languages/prism/json";
import bash from "react-syntax-highlighter/dist/cjs/languages/prism/bash";
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
SyntaxHighlighter.registerLanguage("tsx", tsx);
SyntaxHighlighter.registerLanguage("typescript", typescript);
SyntaxHighlighter.registerLanguage("json", json);
SyntaxHighlighter.registerLanguage("bash", bash);
export default function MarkdownRenderer({ content }: { content: string }) {
return (
<article className="prose max-w-none leading-normal prose-headings:font-medium">
<ReactMarkdown
className="mb-8"
components={{
img: ({ node: _node, src, placeholder, alt, ...props }) => {
if (typeof src === "string") {
return (
<Image
className="my-4 mx-auto"
alt={alt ?? "markdown image"}
{...props}
width={920}
height={640}
src={src}
/>
);
} else {
return null;
}
},
code({ className, ...props }) {
const languages = /language-(\w+)/.exec(className || "");
const language = languages ? languages[1] : null;
return language ? (
<SyntaxHighlighter
style={oneDark}
language={language}
PreTag="div"
wrapLines={false}
useInlineStyles={true}
>
{props.children as unknown as any}
</SyntaxHighlighter>
) : (
<code
className="rounded bg-gray-800 p-1 text-white before:content-[''] after:content-none"
{...props}
/>
);
},
p: ({ node, ...props }) => {
const str = props.children[0]?.toString() ?? "";
// if tweet use tweet embed
if (str.startsWith("%[https://twitter.com/")) {
// ['%[https://twitter.com/neorepo/status/1636728548080713728?s=20]'] -> tweetId = 1636728548080713728
const tweetUrl = str.slice(2, -1);
const tweetId = tweetUrl.split("/").pop()?.split("?")[0];
if (typeof tweetId === "string") {
return <TwitterTweetEmbed tweetId={tweetId} />;
} else {
return <div>Error showing tweet</div>;
}
}
// if image use next image
if (str.startsWith("![](")) {
const imageUrl = str.slice(4, -1).split(" ")[0];
if (imageUrl) {
return (
<Image
src={imageUrl}
alt={""}
width={920}
height={640}
className="mx-auto"
/>
);
} else {
return <div>Error showing image</div>;
}
}
return <p {...props} />;
},
}}
>
{content}
</ReactMarkdown>
</article>
);
}
Conclusion
In this tutorial, we've learned how to set up a Next.js app with Contentlayer. We've also learned how to fetch data from Contentlayer using getStaticProps. We've also learned how to set up a Supabase database to store our blog posts.
Resources
Author and Acknowledgements
Thanks to Bharat Kilaru for writing this tutorial. Thanks to Harish Kilaru and Yogi Seetharaman for editing and reviewing. If you have any questions, feel free to reach out to them on Twitter. Thanks to GitHub Copilot and ChatGPT for helping write, edit, and proofread parts of this tutorial.
Neorepo is a production-ready SaaS boilerplate
Skip the tedious parts of building auth, org management, payments, and emails
See the demo© 2023 Roadtrip