Skip to content

Revisiting blogging with Notion in 2022 🛸

Hey again. It has been a year since I last talked about building a blog with Notion. A lot has changed since my last update, both for me and for Notion. This article that you are reading, though, is still Notion-powered and Next.js rendered. However, I've changed quite a few things over this iteration of my blog update. Let's dive right in.

Things that stayed the same

The basic principle stays the same:

  • We are still using Notion as our data source where we would pull all our blog posts, their titles, authors, published dates, tags, etc., for rendering our blog.
  • We are also still using Next.js as our blog's framework, which takes care of routing, server-side rendering, static regeneration, etc.

Some components that I've mentioned in the former article also remains unchanged:

  • Comments of the blog is still handled by an external service (though I've migrated from Disqus to Giscus - the latter one being a GitHub discussions powered comment system)
  • RSS of the blog is also still generated by feed - an npm package that I trust and love. And it is also generated with getServerSideProps(), which is a data fetching technique that pulls the latest data from the server (which is Notion in our case) and generates the XML feed on each request.

So, that's everything that haven't changed for blogging with Notion. But I must admit that there's a whole new rendering implementation that's going on here in this newly built blog that you are reading now.

What changed? Official API

One of the biggest changes that happened over the iteration is the way Next.js talks to Notion. You probably heard that Notion released its official API last year, and has been slowly growing in terms of block supportability and functionality.

Start building with the Notion API

Creating an integration

So, the great migration begins. To use the API, you would first need to create an integration at Notion - My integrations. You will give it a name, and select the workspace you want to invite it into. Next, you'll need to add that integration into the database that holds your blog posts.

Adding the integration into your blog database

Adding the integration into your blog database

After this, you would be able to query this database with the Internal Integration Token, which is given to you when you created the integration. You will need two things to call the Notion API:

  • The Internal Integration Token. Saved to environment variable NOTION_KEY.
  • The ID of the database, the last part of your Notion database URL. Saved to environment variable NOTION_DATABASE_ID.

Interacting with the API

For the remaining sections, we will be using the [notion-sdk-js](https://github.com/makenotion/notion-sdk-js) package for interacting with Notion's API. We would add a separate script for handling Notion requests, under ./lib/notion.ts, and using the exported functions in Next.js when rendering our blog pages. The actual functions used for querying Notion's API are based on this article:

Building a blog with Notions public API | Samuel Kraft

The basic process is:

  • Query the database for a list of blog post records, used for rendering the list of articles.
  • Add a dynamic route ../[slug].tsx in Next.js, which would be used to render the actual content of each blog post based on the slug of each page.
  • I applied a custom filter to query the database for rendering the page based on slug, as I am not using the native id for each page specified in Notion.
  • Finally, render each article with the blocks queried from each page.

Evidently, I would need to write my custom Notion block renderers, which would take a Notion page query result (a list of blocks), and render them as React components based on the block type. Some blocks like Text, Heading, and List, are pretty straight-forward. Here are the types that I had to use some extra steps for rendering.

Bookmark

You can probably notice that I enjoy embedding bookmarks of links inside an article, which highlights the title, previews and images of an external link. A bookmark block returned by Notion looks like:

{
  "type": "bookmark",
  //...other keys excluded
  "bookmark": {
    "url": "https://website.domain"
  }
}

Yes, only a link. The open graph descriptions of the link are all missing. We would need to parse the link ourselves. The library I used is [unfurl.js](https://www.npmjs.com/package/unfurl.js), and I implemented this as a serverless function under /api/bookmark/[url].ts, so that we can query the preview of the link on the client side.

Why? 🪀

... as making the request on the client side directly would run into CORS issues.

We would then leverage [useSWR](https://github.com/vercel/swr) for requesting the serverless API. Below is a simplified version of the component used for rendering bookmarks:

const { url } = value
const encoded = encodeURIComponent(url)
const fetcher = url => fetch(`/api/bookmark/${encoded}`).then(res => res.json())

const { data, error } = useSWR(url, fetcher)

// Render intermediate states of the bookmark
if (error) return <div>error</div>
if (!data) return <div>loading ...</div>

// Destructure the response
const { title, description, favicon, open_graph } = data
const images = open_graph?.images ?? []
const imgSrc = images[0].url

return ...

Image

Yes, rendering native Notion-served images can be tricky, could you believe that? Initially I tried to use next/image for the task. And in order to use it, I would need the full dimensions of the image when rendering the page. And, yes, there's a serverless function for that.

The page of each article is rendered through getStaticProps, or getServerSideProps (a bit on this later for why I couldn't use static rendering). So, after fetching all blocks from the Notion API, I then proceeded to probe the size of each individual image present in the returned block list, and add their width and height to the block list that is eventually used as the page's props.

await Promise.all(
  blocksWithChildren
    .filter((b: any) => b.type === 'image')
    .map(async (b) => {
      const value = b[b.type]
      const src = value.type === 'external' ? value.external.url : value.file.url

      const { width, height } = await probeImageSize(src)
      value['dim'] = { width, height }
      b[type] = value
    })
)

The library used for probing the image dimensions is [probe-image-size](https://www.npmjs.com/package/probe-image-size), and function probeImageSize() is implemented as:

const probeImageSize = async (url: string) => {
  const dim = await probe(url)
  return { width: dim.width, height: dim.height }
}

With this, we would have both the image's URL, and its dimensions when constructing the image rendering component.

<Image src={imageSrc} alt={imageCaption} width={width} height={height} />

However, I was initially using this method, until I received an email from Vercel, telling me that I have gone over my limits for next/image-based image optimisations. As it turns out, Notion returns an image url that is short-lived, and for each new url returned by Notion, Next.js optimises them every time.

Note

🍋 On the bright side, I still have the dimensions of the images I used, which means I can provide them with width and height so that the page doesn't shift its contents while the images are loading. A slight bonus. (●ˇ∀ˇ●)

This marks a dead end for my optimised image rendering. And as I don't have control over the static generation trigger of my blog, I could only resort to using getServerSideProps which renders the page by pulling data from the server side on each request, so that I could keep my images fresh - not stale.

Finally, yesterday this API caught my eye - Notion Developers - Search.

notion developer docs

I was literally quite excited for a bit. The search integration was also straight-forward:

  • As I only gave permission to the integration read access to my blog database, it would only perform search in this scope, meaning that I won't need to apply custom search conditions.
  • I can simply implement another serverless function /api/search/[query].ts, which queries the database on the server side.

The search function I wrote was something like:

export const searchDatabase = async (query: string) => {
  const response = await notion.search({
    query: query,
    filter: { value: 'page', property: 'object' },
    page_size: 10,
  })
  return response.results
}

I then tinkered with some debounced search inputs with custom React hooks, and we now have search in our blog!

Search using Notion official API

Note

🤖 Still, some caveats. The search is only performed on the database titles - meaning that only keywords existing in the blog titles are indexed. This is nowhere near the capabilities of the built-in search of Notion. Still waiting for the official API to mature.

The end

Well, this marks the end of this article. I believe the official API is mature enough to build a blog around it. However, it's ecosystem is still young, where a lot of libraries like the react-notion-x - my former blog renderer - still uses the old internal private API. I would suggest you using the Notion official API, if and only if, you are confident enough to build the entire infrastructure and peripherals yourself.

On the positive side, you would gain a lot more control over your blog, and you can literally build it anyway you would like, with any framework, implementing anything you wish. I would imagine tags, categories, custom properties, aggregations like word count, and hell - even build the comment system with Notion.

Anyway, thanks for reading. See y'all next year, where I would be definitely revamping my blog once again. 😊