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:
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.
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 clientconst { Client } = require("@notionhq/client");// Initialize the client with the API keyconst notion = new Client({ auth: process.env.NOTION_API_KEY });// Query the database of postsconst 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.
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 (<ArticleBasetitle={props.page.title}subtitle={props.page.subtitle}date={props.page.date ? new Date(props.page.date) : new Date()}><Renderblocks={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.
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 imagesconst webRoot = '/static/images/blog/';const root = getRootDirectory() + '/public' + webRoot;// Create the directory if it doesn't existfs.mkdirSync(root, { recursive: true });// Find all the imagesfor (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 existsif (!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 pathblock.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.
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
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 metadataconst 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 (<Imagewidth={file.width}height={file.height}src={file.url}alt={alt}title={alt}/>);}return null;};return (<ArticleBasetitle={props.page.title}subtitle={props.page.subtitle}date={props.page.date ? new Date(props.page.date) : new Date()}><Renderblocks={props.blocks}simpleTitlescustomOverrides={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.
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!
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:
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} />)}<metaproperty="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 && (<metaproperty="article:published_time"content={props.date.toISOString()}/>)}</Head>);};
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.
<linkrel="alternate"type="application/rss+xml"href={config.baseUrl + '/rss.xml'}/>
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 👀.