• codingvillain
  • POSTS
© 2023 Cheolwan-ParkPowered by nextjs & notion API
Loading comments...

Creating a Blog Using the Notion API

"Developing a Blog Using Notion as a CMS"This project was built with Next.js and the Notion API.

  • #notion
  • #next.js
  • #blog

April 18, 2023 · Cheolwan Park

I wanted to improve my writing and study habits, so I started a blog. After trying different platforms, I realized I wasn’t satisfied with their editors or publishing process. Since I enjoy writing in Notion, I decided to use it as a CMS and build my own blog with a PaperMod-inspired design. For the framework, I chose Next.js, which I had also been wanting to explore.

React Notion X vs Notion API

When I looked into using Notion as a CMS, I found there were two main approaches: using the React Notion X package, or using the official Notion API .

React Notion X fetches data via Notion’s unofficial API and renders it with React components. It also indirectly supports the official API by converting official API responses into the unofficial format used by the library.

import { NotionAPI } from 'notion-client'

const notion = new NotionAPI()

const recordMap = await notion.getPage('067dd719a912471ea9a3ac10710e7fdf')

...

import { NotionRenderer } from 'react-notion-x'

export default ({ recordMap }) => (
  <NotionRenderer recordMap={recordMap} fullPage={true} darkMode={false} />
)
Notion Document Rendering Using React Notion X

At first, I tried building my blog with React Notion X , but I gave up because of a few drawbacks. To customize styles or add new features in React Notion X, you need to re-implement the specific blocks you want to modify and inject them yourself. However, since the unofficial API format isn’t very intuitive, it was inconvenient to adjust and add multiple blocks the way I wanted.

I also found it difficult to understand the codebase of nextjs-notion-starter-kit , which was built by the same developer, because it included a lot of unnecessary features in addition to the essential ones. On top of that, the reliance on an unofficial API made the solution feel unstable.

Because of these reasons, I decided to use the official Notion API instead, and implement the rendering logic on my own.

Notion API

With the official Notion API, you can fetch posts stored in a database. The API is fairly well-designed, and since it returns data in JSON format, it’s easy to read and work with.

However, there were a few limitations I had to take into account.

{
  //...other keys excluded
  "type": "paragraph",
  //...other keys excluded
  "paragraph": {
    "rich_text": [{
      "type": "text",
      "text": {
        "content": "Lacinato kale",
        "link": null
      }
    }],
    "color": "default"
}
json format of a Paragraph block

Rate Limits

The Notion API allows for about three requests per second on average . While the documentation says that occasional spikes are acceptable, it’s hard to assume that this will never cause problems.

According to the docs, when you hit the rate limit , the API responds with a 429 status code , and you can resolve the issue by waiting for the duration specified in the Retry-After header before retrying the request.

To handle this, I implemented an error handler that wraps the Notion API calls, so that whenever a 429 response is returned, it automatically waits and retries.

Notion Hosted Files

Files embedded via external links (images, videos, etc.) are fine, but files uploaded to Notion have expiring URLs when retrieved through the API ( docs ).

That might not seem like a big deal, but it became an issue when I tried to use Vercel’s Image Optimization . I’ll explain this in more detail when I talk about rendering the Image block .

Closer to literal

Links to embedded files (via URL) are fine, but files uploaded through Notion come with expiration on the links returned by the API. Although this might not matter in many cases, I ran into problems when using Vercel Image Optimization —I’ll cover the details in the section on Image block rendering .

Document Rendering

Each block retrieved from the Notion API was mapped to a component by block type to render the full document.

export const Block = ({
  block,
  blocks,
  idx,
}: {
  block: BlockWithChildren;
  blocks: BlockWithChildren[];
  idx: number;
}) => {
  switch (block.type) {
    case "paragraph":
      return <Paragraph block={block} />;
    case "heading_1":
      return <Heading1 block={block} />;
    case "heading_2":
      return <Heading2 block={block} />;
    case "heading_3":
      return <Heading3 block={block} />;
    case "quote":
      return <Quote block={block} />;
    ...
  }
}
Block component for rendering block data by type

Here’s how each block is implemented.

import { ExtendBlock } from "@/services/notion/types/block";
import { ParagraphBlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
import classNames from "classnames";
import { getColorClass } from "./colors";
import { RichText } from "./richtext";

export const Paragraph = ({
  block,
}: {
  block: ExtendBlock<ParagraphBlockObjectResponse>;
}) => {
  return (
    <p className={classNames(getColorClass(block.paragraph.color))}>
      <RichText richTexts={block.paragraph.rich_text} />
    </p>
  );
};
The component rendering a Paragraph block.

Most of the blocks were easy to implement, but some needed extra packages or a bit more thought to handle properly.

Image Block

Blur Placeholder & Image Size

Next.js provides an Image component that makes it easy to implement features like lazy loading and blur placeholders . When working with images included in the project itself, no additional setup is required. However, when loading external images , you need to provide the image’s dimensions in advance as well as a blur version to use as a placeholder.

Using the Plaiceholder package, I was able to easily extract both the blur image and the original image size. I then added this information while fetching data from the Notion API, making it possible to use Next.js’s Image component seamlessly.

Link Expiration Issues

Files hosted by Notion come with expiring links . At the time of writing, the expiration window is about one hour. Initially, I thought this wouldn’t be a problem since Vercel’s Image Optimization caches requests, and increasing the cache duration should take care of it.

But in practice, I found that after some time had passed, images failed to load when accessing the site from a different device. After digging into it, I realized there were two key problems:

  1. Device-dependent image sizes mean the requested URLs differ.
  2. Caching occurs only on the edge network where the request was made.

For example, if a desktop requested a 2x image and it got cached, then two hours later a mobile device requested a 1x image, that request would miss the cache and instead hit the expired Notion link—resulting in a broken image.

One possible workaround was to refresh the links every hour, apply Image Optimization again, and re-cache them. But this approach wasn’t realistic because Vercel’s Hobby plan only optimizes 1,000 images per month , and exceeding that simply blocks further optimizations. Since I was also using ISR (Incremental Static Regeneration) to reduce response times, some visitors inevitably ended up seeing stale pages with expired links.

I considered different solutions, but aside from adding a dedicated image caching server or upgrading to increase the optimization limit (both of which cost money), I couldn’t find a clean fix.

In the end, I added an option to skip optimization for expiring images so that at least they could still be displayed. To handle stale visitors who might encounter broken images, I implemented a fallback: if an image fails to load, the client makes an API request to fetch a fresh link.

Bookmark Block

The official documentation shows that the Bookmark block only comes with the URL.

{
  //...other keys excluded
  "type": "bookmark",
  //...other keys excluded
  "bookmark": {
    "caption": [],
    "url": "https://companywebsite.com"
  }
}
Bookmark block format

However, to render a Bookmark block , you need metadata such as the page’s title, description, and icon—not just the URL. This meant I needed a package that could fetch webpage metadata. Among the various options, I chose the unfurl package because it made retrieving this information simple.

Similar to how I handled the Image block, I implemented the logic so that when fetching data from the Notion API, the Bookmark block would also be enriched with the webpage’s metadata.

KaTex

Both the Equation block and RichText can include formulas written with KaTeX . Using the KaTeX package makes it possible to render them, but since the package is quite large, including it in the initial bundle caused the JavaScript payload for post pages to become too heavy.

I had run into a similar issue with Code blocks , which I render using prism.js . For those, I solved it with dynamic imports , but that approach introduced layout shifting because the final rendered size of the content couldn’t be known until the module finished loading. For KaTeX, the problem was even bigger, since formulas can appear throughout the document within RichText, so I couldn’t use the same workaround.

To address this, I took a similar approach to what I had done for the Image and Bookmark blocks: I decided to pre-render the KaTeX HTML and attach it when fetching data from the Notion API. However, unlike images or bookmarks, RichText can appear in many different contexts inside various blocks , so I had to recursively traverse all the properties of the fetched data and add the rendered KaTeX HTML only when the RichText matched an Equation.

const renderAllEquations = (obj: any): any => {
  if (Array.isArray(obj)) {
    return obj.map(renderAllEquations);
  } else if (obj && typeof obj === "object") {
    if (isRichTextWithEquation(obj)) {
      return {
        ...obj,
        katexHtml: renderKatex(obj.equation.expression) || "",
      };
    }
    for (let key in obj) {
      if (obj.hasOwnProperty(key)) obj[key] = renderAllEquations(obj[key]);
    }
    return obj;
  } else {
    return obj;
  }
};
Function to Render All Equations in RichText

Open Graph Image Generation

With the @vercel/og package, you can generate Open Graph images . You define how it should look using JSX , and it renders that UI directly into an image—so it’s quite straightforward to use if you follow the official docs.

An issue I faced with @vercel/og was the 1MB program size limit on Vercel’s Hobby plan for Edge Functions. Even when restricting Korean fonts to about 2,400 common characters, the size still exceeded the limit. I resolved this by manually removing unused characters to bring the font under 1MB.

Conclusion

This was my first time building a blog. I didn’t expect much since I’m not particularly fond of web development, but it turned out to be more fun than I thought. There were plenty of challenges to think through, and the development experience with Next.js was excellent. In fact, I liked Next.js so much that I’ll probably always choose it whenever I need to do web development in the future.

As for the Notion API , while it still has some limitations, I think it will become mature enough with future updates to be used as a CMS for more complex services. I plan to keep an eye on its progress from time to time.

If you’d like to check out the source code for this blog, you can find it on GitHub .

GitHub - cheolwanpark/next-notion-blog

Contribute to cheolwanpark/next-notion-blog development by creating an account on GitHub.

https://github.com/Cheolwan-Park/next-notion-blog