Using Notion as headless CMS with Next.js

11min read
Tags: Notion, Next.js, Headless CMS, Blog
Cover image

Throughout the past years my personal homepage has gone through multiple iterations when it comes to its tech stack. From being a hard-coded HTML, CSS, JavaScript project, a Gatsby blog or a full-fledged React application — you name it. In all those stages, I always intended to have a personal blog as its centerpiece. However, none of the mentioned tech stacks really provided the experience and convenience I was striving for. Either because I didn’t want to deal with manually formatting my text by using Markdown or the fact that mixing up source code and written content simply felt wrong (I mean that’s why CMS exist, right?). That’s until I discovered Notion: Often referred to as “second brain” or “personal knowledge base”, I also started to use it on a daily basis to organize my lists and capture my thoughts in one single place. It only felt natural to me, to come up with a solution on how I could elevate it even further and use it as my personal CMS to store all the written content of my blog. With its support for page properties and formatting capabilities, it provided all the pieces I was previously missing out on. Obviously, I could have just gone with a dedicated CMS platform such as Contentful or Sanity, but at that point of time I was already using Notion and I didn’t want to deal with an additional platform.

I quickly scouted the Internet for possible templates or projects that deal with this exact problem. In particular, I was looking for a solution that is easy to setup and also supports more complex features such as syntax highlighting in case I want to share code snippets. This open-source project offered everything out-of-the-box but it doesn’t use or support Notion’s official API which I found to be odd (to be fair, Notion released its official API at a later point of time). I then came across this project that was able to convert Notion pages to Markdown on the fly. In the past, I was already working with a React Markdown renderer, so in theory, if I manage to connect them both, I should achieve the goal I was hoping for. In addition, in case I ever want to move to another platform in the future, I could theoretically export all my written blog entries as Markdown files and migrate them over more easily. After a couple days of tinkering, I eventually ended up with a solution I was quite content with. In fact, this post itself was written and stored in Notion and I have to admit that I feel very comfortable in using Notion as my editor of choice for sharing blog entries. So here’s a technical breakdown of all the steps and hurdles I encountered during implementation:

First off, initialize a new Next.js template:

pnpm create next-app -- notion-blog --ts

I personally use pnpm as package manager and TypeScript as underlying language, but feel free to use whatever you are more comfortable with. Next, let’s install all the dependencies we need:

pnpm add @notionhq/client notion-to-md react-markdown remark-gfm

Here’s what each dependency is used for:

Before we can start implementing, we need to initialize a database in Notion and create an integration so we can programmatically fetch content with it. Just follow this guide on how to get an personal API key and how to share a database with your newly created integration. In this example, I will create a default database with two addtional properties: Published and Slug. The former is a checkbox property so we can have “draft” entries in our database and decide when exactly we would like to publish blog entries to the public. The latter is a rich text property so we can eventually access our blog entry under the path /blog/<slug>.

Notion database with two blog entriesNotion database with two blog entries

Open up your newly created Next.js project and create a .env.local file in the root folder and paste your Notion integration API key and blog database id in there. You can find the database id by opening up the database page and extract it from the actual URL (e,g. if the URL is https://www.notion.so/dangpg/abcdef12345?v=d8awdj9, your database id is abcdef12345)

// .env.local

NOTION_API_KEY=<integration-api-key>
NOTION_BLOG_DATABASE_ID=<database-id>

In order to maximize performance we will use Next.js’ Static Side Generation (SSG) capability to fetch our Notion pages and pre-render them at build time. Let’s start with fetching all available pages in our database and creating links for them on our root site. We create a new utility file named notion-client.ts to store all Notion-relevant code in it so we don’t unnecessarily pollute the actual page files and can reuse logic if needed. The function queryBlogDatabase uses Notion’s client to query all pages that are saved in our blog database. During querying, we filter for only pages whose Published checkbox property is set to true and sort them based on their creation timestamp in descending order (Disclaimer: This code currently doesn’t handle pagination. Therefore, adjustments will be needed if the number of blog entries exceeds the pagination limit). After receiving a response, we iterate through each result entry and extract all required properties (id, title, slug and date). For simplicity and convenience, we use the page’s creation timestamp as date property. However, you can also create a dedicated property or tweak your Notion database structure based on your personal needs. If we encounter any errors during this extraction phase, we simply skip that entry and log the respective error to the console. With this function in place, we should be able to call it within the getStaticProps method of our root pages/index.tsx file. Adjust the JSX part and we should be able to see a list of our blog entries on the root page of our application (I adjusted the default styling a bit, so the hyperlinks are easier to see):

// utils/notion-client.ts

import { Client } from "@notionhq/client";

const notionClient = new Client({ auth: process.env.NOTION_API_KEY });

export type BlogEntry = {
  id: string;
  title: string;
  slug: string;
  date: string;
};

export async function queryBlogDatabase(): Promise<BlogEntry[]> {
  const response = await notionClient.databases.query({
    database_id: process.env.NOTION_BLOG_DATABASE_ID as string,
    filter: {
      property: "Published",
      checkbox: {
        equals: true,
      },
    },
    sorts: [{ timestamp: "created_time", direction: "descending" }],
  });

  const entries: BlogEntry[] = [];
  for (const _page of response.results) {
    // Workaround, so we have correct typing on object page
    const page = _page as Extract<typeof _page, { parent: unknown }>;

    try {
      // Again workaround, so we have correct typing
      type PagePropertiesType = typeof page.properties[string];
      const nameProperty = page.properties["Name"] as Extract<
        PagePropertiesType,
        { type: "title" }
      >;
      const slugProperty = page.properties["Slug"] as Extract<
        PagePropertiesType,
        { type: "rich_text" }
      >;

      const id = page.id;
      const title = nameProperty.title[0].plain_text.trim();
      const slug = slugProperty.rich_text[0].plain_text.trim();
      const date = page.created_time.substring(0, 10);

      entries.push({ id, title, slug, date });
    } catch (err) {
      // Invalid page object, skipping it
      console.log(err);
      continue;
    }
  }

  return entries;
}
// pages/index.tsx

import type { GetStaticProps, NextPage } from "next";
import Head from "next/head";
import styles from "../styles/Home.module.css";
import { BlogEntry, queryBlogDatabase } from "../utils/notion-client";
import NextLink from "next/link";

interface PageProps {
  blogEntries: BlogEntry[];
}

export const getStaticProps: GetStaticProps<PageProps> = async () => {
  const blogEntries = await queryBlogDatabase();

  return {
    props: {
      blogEntries,
    },
  };
};

const Home: NextPage<PageProps> = ({ blogEntries }) => {
  return (
    <div className={styles.container}>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <h1>My Blog Posts</h1>
        <ul>
          {blogEntries.map(({ id, title, slug, date }) => (
            <li key={id}>
              <NextLink href={`/blog/${slug}`} passHref>
                {`${title} (${date})`}
              </NextLink>
            </li>
          ))}
        </ul>
      </main>
    </div>
  );
};

export default Home;

Home page linking to blog postsHome page linking to blog posts

Since we now have a way to access each blog entry by URL, let’s continue with retrieving and rendering the actual content. We will use Next.js’ Dynamic Routing feature and statically generate each blog entry at build time. Create a new dynamic page pages/blog/[slug].tsx with the following contents:

// pages/blog/[slug].tsx

import { GetStaticPaths, GetStaticProps, NextPage } from "next";
import Head from "next/head";
import {
  BlogEntry,
  getBlogEntryAsMarkdown,
  getBlogEntryBySlug,
  queryBlogDatabase,
} from "../../utils/notion-client";

interface PageProps {
  blogEntry: BlogEntry & { markdown: string };
}

export const getStaticPaths: GetStaticPaths = async () => {
  const blogEntries = await queryBlogDatabase();

  return {
    paths: blogEntries.map(({ slug }) => ({ params: { slug } })),
    fallback: false,
  };
};

export const getStaticProps: GetStaticProps<PageProps> = async (context) => {
  const slug = context.params?.slug as string;

  const blogEntry = await getBlogEntryBySlug(slug);

  const markdown = await getBlogEntryAsMarkdown(blogEntry.id);

  return {
    props: {
      blogEntry: {
        ...blogEntry,
        markdown,
      },
    },
  };
};

const BlogEntryPage: NextPage<PageProps> = ({
  blogEntry: { title, date, markdown },
}) => {
  return (
    <>
      <Head>
        <title>{title}</title>
      </Head>
      <article>
        <h1>{title}</h1>
        <span>
          Published on <time dateTime={date}>{date}</time>
        </span>
        {/* TODO: Render Markdown */}
        <div>{markdown}</div>
      </article>
    </>
  );
};

export default BlogEntryPage;

Beside the JSX part, we define and export two additional functions: getStaticPaths and getStatisProps. Both functions are internally used by Next.js to statically define all available paths and fetch data needed for rendering later on. Within getStaticPaths we reuse our queryBlogDatabase function from earlier and map its result in order to return an array of all available slugs. We set fallback to false so our server returns a 404 page for all blog pages whose slugs are not included in paths. In contrast, getStaticProps will be called for every slug and is responsible for retrieving all page properties and generating the Markdown part of the respective content. Since we need the page id to retrieve its content, but only have access to a page’s slug during this step, we unfortunately have to re-fetch all properties through the Notion client again (as of now there is no direct way to pass already fetched data from getStaticPaths to getStaticProps; however there exist workarounds for that). This is done via our new utility function called getBlogEntryBySlug:

// utils/notion-client.ts

[...]

const n2mClient = new NotionToMarkdown({ notionClient });

// Some duplication code from queryBlogDatabase, can be extracted if needed
export async function getBlogEntryBySlug(slug: string): Promise<BlogEntry> {
  const response = await notionClient.databases.query({
    database_id: process.env.NOTION_BLOG_DATABASE_ID as string,
    filter: {
      and: [
        { property: "Published", checkbox: { equals: true } },
        { property: "Slug", rich_text: { equals: slug } },
      ],
    },
  });

  if (response.results.length !== 1) {
    throw new Error("Received either none or more than one blog entry.");
  }

  const _page = response.results[0];
  const page = _page as Extract<typeof _page, { parent: unknown }>;

  type PagePropertiesType = typeof page.properties[string];
  const nameProperty = page.properties["Name"] as Extract<
    PagePropertiesType,
    { type: "title" }
  >;

  const id = page.id;
  const title = nameProperty.title[0].plain_text.trim();
  const date = page.created_time.substring(0, 10);

  return { id, title, slug, date };
}

After retrieving all properties of a page, we can transform its content to a Markdown string through the notion-to-md package by providing the page’s id:

// utils/notion-client.ts

[...]

export async function getBlogEntryAsMarkdown(id: string): Promise<string> {
  const markdown = n2mClient.toMarkdownString(
    await n2mClient.pageToMarkdown(id)
  );

  return markdown;
}

With these functions in place, we can pass the data to the page function itself and render it accordingly. Access the blog page under /blog/<your-blog-entry-slug> and you should be greeted with a simple page showing the blog entry’s title and timestamp:

Blog entry (missing render of Markdown content)Blog entry (missing render of Markdown content)

To finish up and display the actual content of the blog entry, we use react-markdown to transform Markdown to React elements. In case you want to use custom components, e.g. for syntax highlighting for code or image and link optimization by Next.js, refer to the package’s instructions on how to customize specific tags. Also don’t forget to include remark-gfm as a plugin, so we get support for GitHub Flavored Markdown Spec. And that’s it! Refreshing the page and we should see a correctly formatted blog entry:

// pages/blog/[slug].tsx
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

[...]

const BlogEntryPage: NextPage<PageProps> = ({
  blogEntry: { title, date, markdown },
}) => {
  return (
    <>
      <Head>
        <title>{title}</title>
      </Head>
      <article>
        <h1>{title}</h1>
        <span>
          Published on <time dateTime={date}>{date}</time>
        </span>
        <ReactMarkdown remarkPlugins={[remarkGfm]}>{markdown}</ReactMarkdown>
      </article>
    </>
  );
};

export default BlogEntryPage;

Complete blog entryComplete blog entry

If we run pnpm build, Next.js should retrieve all published blog entries, retrieve their properties and statically pre-generate each blog page for high performance.

Going forward, there are many other open points we can tackle in order to further customize our blog:

  • Add a styling library such as Mantine.dev or Chakra UI to make our blog visually pleasing
  • Include Next.js’ many optimization feature such as image optimization
  • Utilize caching to reduce the number of requests to be made during build time
  • Add search or filter functionality for blog entries, e.g. via tags
  • ... many more ...

As you can see, there are a lot of options to improve upon. The options are almost endless. Go wild with your creativity and add your own little twist and personality to your blog. In case you want to see the whole source code, check the following GitHub repository:

https://github.com/dangpg/notion-next-markdown-blog

Next.js
Mantine
Vercel