Back to blog
How to build a technical blog with Next.js and Contentlayer
4 April 2023

6 min read

How to build a technical blog with Next.js and Contentlayer

Bharat Kilaru

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

  1. Let's start by creating a new Next.js app using the Next.js CLI

     npx create-next-app next-blog --ts
  2. 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? … @/*
  3. Change directories to your new app

     cd next-blog

Set up Contentlayer

  1. Install Contentlayer

    npm install contentlayer next-contentlayer
  2. 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.

  1. Update your config file to use the withContentLayer plugin:

Update next.config.js file to use the withContentLayer plugin:

module.exports = withContentlayer(nextConfig);
  1. 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.

  1. 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