Notion-backed Statically Generated Next.js Blog

The things I did to build my new and shiny blog setup

Published

Recently I decided to redo my personal brand and build a new website to replace my old one. Now that I’ve had more experience with web development, I thought it would be cool if I could design a polished website that’s fast, responsive, and comfortable to read.

I wanted to keep some of my old blog posts and move them over to this new site, and additionally wanted to use Notion as my content management system.

I decided on the following requirements:

  • Clean, consistent design
  • Loads fast and works without JavaScript
  • Zero CLS, no font flicker

The Stack

I went with Next.js for this project. I’m very familiar with its features, and I know I can make use of getStaticProps to statically render my blog posts during build time, as well as other nice features such as next/image to generate multiple resolution images and render them with the right dimensions.

To style the page, I chose to use TailwindCSS, as I’m very familiar with it and can prototype and iterate on different designs quickly. I also wanted to make use of the @tailwindcss/typography plugin to style my posts.

Notion API

Conveniently, Notion has an official API and an official JavaScript client, @notionhq/client. I've never used it before so I spent some time playing around and seeing the type of data it returns.

It’s easy enough to use, I just followed Notion’s Getting Started Guide to create an API key and use it with the client like so:

// Import the client
const { Client } = require("@notionhq/client");
 
// Initialize the client with the API key
const notion = new Client({ auth: process.env.NOTION_API_KEY });
// Query the database of posts
const db = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID,
});

I initially expected to get Markdown back from Notion, and that I could just render it with any markdown renderer from npm, but it turns out Notion returns a list of blocks that you have to render yourself:

{
"page": {
"id": "7950c3f7-d748-4f04-a721-d8034491bb42",
"title": "Notion-backed Statically Generated Next.js Blog",
"subtitle": "A quick tour on my new and shiny blog setup",
"slug": "notion-blog",
"published": false,
"date": "",
"tags": []
},
"blocks": [
{
"object": "block",
"id": "ec1bfe57-5866-46c0-91db-d143d72cb677",
"parent": {
"type": "page_id",
"page_id": "7950c3f7-d748-4f04-a721-d8034491bb42"
},
"created_time": "2022-06-29T06:20:00.000Z",
"last_edited_time": "2022-06-29T16:33:00.000Z",
"created_by": {
"object": "user",
"id": "6702aef9-3ef8-4780-a8cc-d98063089908"
},
"last_edited_by": {
"object": "user",
"id": "6702aef9-3ef8-4780-a8cc-d98063089908"
},
"has_children": false,
"archived": false,
"type": "paragraph",
"paragraph": {
"rich_text": [
{
"type": "text",
"text": {
"content": "Recently I decided to redo my personal brand and build a new website to replace my old one. Now that I’ve had more experience with web development, I thought it would be cool if I could design a polished website that’s fast, responsive, and comfortable to read.",
"link": null
},
"annotations": {
"bold": false,
"italic": false,
"strikethrough": false,
"underline": false,
"code": false,
"color": "default"
},
"plain_text": "Recently I decided to redo my personal brand and build a new website to replace my old one. Now that I’ve had more experience with web development, I thought it would be cool if I could design a polished website that’s fast, responsive, and comfortable to read.",
"href": null
}
],
"color": "default"
}
}
]
}

At first it seems like it’s not very convenient to use this format, but I remembered that Notion supports more things than Markdown, and getting this sort of structure returned would be beneficial if I ever want to implement more block types in the future.

Rendering Posts

As it turns out, there’s already a package that handles this for me: @9gustin/react-notion-render. It takes in the list of blocks returned from the API, and returns React components. Very convenient.

So I built a page to render a blog post in pages/blog/[slug].tsx:

import Image from 'next/image';
import { ParsedBlock, Render } from '@9gustin/react-notion-render';
import { GetStaticPaths, GetStaticProps } from 'next';
import ArticleBase from '../../components/ArticleBase';
import { getPostBySlug, getPosts, Post } from '../../util/notion';
 
type BlogPageProps = {
blocks: any[];
page: Post;
};
 
const BlogPage = (props: BlogPageProps) => {
return (
<ArticleBase
title={props.page.title}
subtitle={props.page.subtitle}
date={props.page.date ? new Date(props.page.date) : new Date()}
>
<Render
blocks={props.blocks}
simpleTitles
/>
</ArticleBase>
);
};
 
export const getStaticProps: GetStaticProps = async (ctx) => {
const { page, blocks } = await getPostBySlug(ctx.params?.slug as string);
if (!page || !blocks) return { notFound: true };
return {
props: { page, blocks },
};
};
 
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await getPosts();
return {
paths: posts
.filter((post) => post.published)
.map((post) => ({
params: { slug: post.slug },
})),
fallback: false,
};
};
 
export default BlogPage;

I’ve separated out the functions interacting with Notion into a separate file util/notion.ts, so I can simply call higher-level helper functions getPosts and getPostBySlug.

As per the initial requirements, I wanted to statically render the posts during build time. For that, I’ll need getStaticProps to provide the page props for a single page, and also getStaticPaths to tell Next.js all of the different [slug]s that are available, so that it can generate a page for each blog post.

et voilà! I now have my posts rendered, just like the way you see it now.

Screenshot of a blog post

Fetching Images

There’s still an issue with images. The Notion API returns images with expiring S3 URLs like this:

{
"object": "block",
// ...
"type": "image",
"image": {
// ...
"type": "file",
"file": {
"url": "https://s3.us-west-2.amazonaws.com/secure.notion-static.com/4ed6a4c0-e2e5-4013-bb01-dd4649e90526/Untitled.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AKIAT73L2G45EIPT3X45%2F20220703%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20220703T075655Z&X-Amz-Expires=3600&X-Amz-Signature=3b04c0a4bdcafa327de1c031146fbb2c6f18e62f080904bfee172b106374c7e0&X-Amz-SignedHeaders=host&x-id=GetObject",
"expiry_time": "2022-07-03T08:56:55.176Z"
}
}
}

Although the images render just fine now, they will stop working once the expiry_time is reached, and the blog post would have to be regenerated to refresh the image URL. That doesn’t seem great, and accessing S3 directly like that meant I couldn’t control how the images are served and cached.

So I wrote a little function that goes through all of the image blocks for a given post, fetches the image file, and saves them into the public/static directory.

const fetchPostImages = async (blocks: any[]) => {
// Get the root directory for images
const webRoot = '/static/images/blog/';
const root = getRootDirectory() + '/public' + webRoot;
 
// Create the directory if it doesn't exist
fs.mkdirSync(root, { recursive: true });
 
// Find all the images
for (const block of blocks) {
if (block.type !== 'image') continue;
 
const id = block.id;
const url = block.image.file.url;
const ext = new URL(url).pathname.split('.').pop();
const fileName = root + id + '.' + ext;
 
// Check if file exists
if (!fs.existsSync(fileName)) {
const file = fs.createWriteStream(fileName);
const res = await fetch(url);
if (!res.ok) throw new Error(`unexpected response ${res.statusText}`);
await streamPipeline(res.body, file);
console.log('Downloaded image', id);
}
 
// Update the block with the local file path
block.image.file.url = webRoot + id + '.' + ext;
}
 
return blocks;
};

This function will run during build time, and will substitute the image URLs with the ones that have been saved locally, before passing the blocks list to the renderer.

Zero CLS

Another issue with the images is that Notion doesn’t give any information on the image dimensions. Furthermore, the library that I was using to render the Notion blocks only used <img /> tags to render images, so when the page loads for the first time, the content might shift around:

So I need a way to

  1. Get the size of the image
  2. Override the render function to create a fixed-size image

Getting the image size is easy. I just added the image-size package and passed the downloaded image’s filename to the sizeOf function to get the width and height. I can then inject it into the block metadata like so:

// Add extra metadata
const dimensions = sizeOf(fileName);
block.image.file.width = dimensions.width;
block.image.file.height = dimensions.height;

Now, I just need to replace the <img /> tag with <Image /> from next/image. But at the time of me doing this, the Notion renderer I was using didn’t support using custom elements. There was an issue requesting it but it hasn’t been active for quite some time, so I decided to fork the project and try adding the feature in myself.

I dug into the code and found the Render component, which takes in the list of blocks, maps them to the components, and returns the mapped array. I added a customOverrides prop which takes in a function that takes in a block and returns either a component or null. Here’s the commit.

Then, I replaced the dependency in my package.json to point to my fork:

"@9gustin/react-notion-render": "git+https://github.com/hizkifw/react-notion-render.git"

So now I can override the components by specifying the customOverrides function through the prop.

const BlogPage = (props: BlogPageProps) => {
const customOverrides = (block: ParsedBlock) => {
const alt = block.content?.caption?.[0]?.plain_text ?? '';
const file = block.content?.file as any;
 
if (block.notionType === 'image' && file) {
if (!file.width || !file.height) return null;
return (
<Image
width={file.width}
height={file.height}
src={file.url}
alt={alt}
title={alt}
/>
);
}
 
return null;
};
 
return (
<ArticleBase
title={props.page.title}
subtitle={props.page.subtitle}
date={props.page.date ? new Date(props.page.date) : new Date()}
>
<Render
blocks={props.blocks}
simpleTitles
customOverrides={customOverrides}
/>
</ArticleBase>
);
};

And it works great! I get multiple resolutions of the image generated by Next.js, and it has a fixed size so the page doesn’t shift around.

Syntax Highlighting

As I’m writing this post and previewing it on my site locally, I noticed that the code blocks looked a bit dull. I found a package called prism-react-renderer that lets me use Prism.js with React. The package wraps Prism.js nicely so that it works with React, and also works nicely with Next.js and static page generation.

A quick yarn add, copying the sample code, and inserting it into the customOverrides above and I have working build-time-rendered syntax highlighting!

OpenGraph

I wanted my page to also look nice when people share the link around, just like how Discord generates an embed with some information when you paste a link:

Screenshot of a Discord embed showing one of my blog posts

To make this work, I added some OpenGraph meta tags to the page’s head. Since this is repetitive, I built a small helper component:

import Head from 'next/head';
 
export type SEOHeadProps = {
title: string;
description?: string;
type?: 'website' | 'article' | 'profile';
image?: string;
date?: Date;
};
 
export const SEOHead = (props: SEOHeadProps) => {
const pageTitle = props.title ? `${props.title} | hizkia.dev` : 'hizkia.dev';
 
return (
<Head>
<title>{pageTitle}</title>
<meta property="og:type" content={props.type || 'website'} />
<meta property="og:title" content={props.title} />
{props.description && (
<meta property="og:description" content={props.description} />
)}
<meta
property="og:image"
content={props.image || 'https://www.hizkia.dev/static/images/logo.png'}
/>
{props.type === 'article' && (
<meta property="og:author" content="https://www.hizkia.dev" />
)}
{props.date && (
<meta
property="article:published_time"
content={props.date.toISOString()}
/>
)}
</Head>
);
};

RSS

Finally, I wanted my blog to also have an RSS feed, so I used the rss package on npm and wrote a function that takes in the list of posts and adds each of them into the feed:

const writeRSS = (posts: Post[]) => {
const feed = new RSS({
title: "Hizkia Felix's Blog",
description: 'I write about stuff sometimes',
site_url: config.baseUrl,
feed_url: config.baseUrl + '/rss.xml',
image_url: config.baseUrl + '/static/images/logo.png',
});
 
posts
.filter((post) => post.published)
.map((post) => {
feed.item({
title: post.title,
description: post.subtitle,
url: `${config.baseUrl}/blog/${post.slug}`,
date: post.date,
guid: post.id,
});
});
 
const xml = feed.xml({ indent: true });
const fileName = getRootDirectory() + '/public/rss.xml';
fs.writeFileSync(fileName, xml);
};

I then call the function after the posts are fetched in my getStaticPaths, so every time the site is rebuilt, the RSS feed also gets rebuilt. Finally, to top it off, I added another tag to the page heads, to let feed readers know where to get the feed XML.

<link
rel="alternate"
type="application/rss+xml"
href={config.baseUrl + '/rss.xml'}
/>

Done!

That’s pretty much it, Hope this general outline has been interesting. I’ll definitely make more changes to my site in the future, and might write a bit more, so be sure to keep an eye out add my site to your feed readers 👀.