Title image of Using Notion as a headless Blog CMS

Using Notion as a headless Blog CMS

17 December 2022

·
Cloudflare
Notion

I started this blog as an experiment. I didn’t know if I would enjoy writing a blog. To focus on the writing side I started with the quickest blog setup: WordPress. There’s a reason why WordPress is used by 43.2% of all websites: it’s really good at what it does. It’s very simple to set up, easy to add content and can be extended by 1000’s of plugins.

Now that I’ve written a few posts I’ve decided I quite like it and want to start doing more with this blog. To do anything with a WordPress blog you need to use plugins. But quite frankly the WordPress plugin ecosystem is not something I’m very enthusiastic to learn. It’s more fun for me to create a custom blog than to learn the WordPress ecosystem properly.

So I think it’s time to leave the WordPress nest and go off on my own.

The plan

The plan is to have complete control over the front end so it is easy for me to customize. But I still want a pleasant writing experience. I don’t want to be writing my posts in markdown files. So I need a headless CMS that I can write into and serve the content in my own blog front end.

There are a few headless CMS solutions, but none of them really blew me away…

A tool I have been enjoying is Notion. It’s a very flexible note-taking app that has exploded in popularity over the past year. It lets you define the structure of your data, provides a pleasant writing experience and has an Api. All the ingredients of a CMS.

Getting my content from Notion into the blog is going to require some work. The Notion API doesn’t have the functionality to export content directly to HTML. Instead, I’m going to have to do that conversion myself…

Here’s the plan:

Layers of the planned architecture

Content is written and stored in Notion. It’s sucked up and converted to HTML in an Azure function API. Cached in Cloudflare. Then displayed in a custom Nuxt front end.

The Azure function is going to provide all of the data needed for the front end. These are the endpoints that it needs:

EndpointMethodReturnsUse
/api/feedGetA list of all posts’ metadata.To show a list of all blog posts on the home page.
/api/post/{slug}GetThe metadata of a single post.To show the featured image etc on the post page
/api/post/{slug}/contentGetThe content of a postTo show the post on the post page
/api/post/{slug}/images/{imageName}GetAn image from a postTo serve the image of a post on the post page

Notion setup

Notion structures content in three levels: Databases, Pages and Blocks. Databases are collections of pages. A Notion account can have multiple databases. They are listed on the left side of the Notion app.

Pages are made out of blocks. Every paragraph, heading or image on a page is a separate block.

For the blog, I created a Blog CMS database in Notion. Each blog post will be on a separate page inside the database. A Notion page has properties as well as it’s content. Almost like the metadata of a page. This is helpful for a blog because you can use them to contain stuff like the slug or featured image of a post.

This is what the Blog CMS database looks like:

Notion blog database setup

Blog Homepage feed

The home page of the blog is going to list all of my posts. To do this we can use the Notion Api to query the pages of the Blog CMS database. We want to show all of the published posts in chronological order.

The Notion endpoint for this is the query database endpoint with the following request:

// Post request to https://api.notion.com/v1/databases/{DatabaseId}/query?
{
    "filter":{
        "property": "Status",
        "select":{
            "equals": "Published"
        }
    },
    "sorts":[
        {
            "property": "PublishedDate",
            "direction": "descending"
        }
    ]
}

We can’t call this endpoint directly from the front end because it would leak our database id and API key. So we have to call it from our Azure function back end.

The Notion api comes back with a lot of information that we don’t care about. In the Azure function we can simplify it to only the stuff we want. Something like this:

// Response from Azure function api
[
	{
		"title": "Trivia game in Blazor",
		"created": "2022-11-25T00:00:00",
		"featuredImage": "whats-bigger-in-blazor.webp",
		"slug": "trivia-game-in-blazor",
		"tags": [{
				"name": "Blazor",
				"colour": "purple"
			}
		],
		"description": "Building a fun trivia game in Blazor."
	}, {
		"title": "Running a data importer in Azure Container Instances",
		"created": "2022-11-04T00:00:00",
		"featuredImage": "containers-in-ACI.webp",
		"slug": "running-a-data-importer-in-azure-container-instances",
		"tags": [{
				"name": "Azure",
				"colour": "brown"
			}
		],
		"description": "Walk through on how to build and deploy a container to Azure container instances."
	}
]

Using this list of posts we can populate the home page with our latest posts:

Home page of the blog

Home page done 🙂

Converting individual posts to HTML

From the home page, a blog post’s slug can be used to view each post. The link for each post will be: liamhunt.blog/posts/{slug}. Using the slug we can get the post content and display it.

Two Notion API calls are needed for this.

First, a database query call to find the page id for the slug:

// Post request to https://api.notion.com/v1/databases/{DatabaseId}/query?
{
    "filter":{
        "property": "Slug",
        "rich_text":{
            "equals": "notion-headless-cms-for-my-blog"
        }
    }
}

Then the retrieve block children endpoint to get all of the blocks for the post:

GET https://api.notion.com/v1/blocks/{PageId}/children?page_size=100

Paragraphs

Each block type needs to be handled differently to convert it to HTML. It’s all standard boring code so I won’t go through every block type. But I will show the paragraph conversion as they’re the most common.

This is an example paragraph block returned from Notion:

{
	// Other stuff we don't care about
	"type": "paragraph",
	"paragraph": {
		"rich_text": [{
				"type": "text",
				"text": {
					"content": "I started this blog as an experiment. I didn’t know if I liked writing posts so I started with the simplest solution to try it: WordPress. There’s a reason WordPress is used by ",
					"link": null
				},
				"annotations": {
					"bold": false,
					"italic": false,
					"strikethrough": false,
					"underline": false,
					"code": false,
					"color": "default"
				},
				"plain_text": "I started this blog as an experiment. I didn’t know if I liked writing posts so I started with the simplest solution to try it: WordPress. There’s a reason WordPress is used by ",
				"href": null
			}, {
				"type": "text",
				"text": {
					"content": "43.2% of all websites",
					"link": {
						"url": "https://blog.hubspot.com/website/wordpress-stats"
					}
				},
				"annotations": {
					"bold": false,
					"italic": false,
					"strikethrough": false,
					"underline": false,
					"code": false,
					"color": "default"
				},
				"plain_text": "43.2% of all websites",
				"href": "https://blog.hubspot.com/website/wordpress-stats"
			}, {
				"type": "text",
				"text": {
					"content": ": it’s really good at what it does. It’s very simple to set up, easy to add content and can be extended by 1000’s of plugins.",
					"link": null
				},
				"annotations": {
					"bold": false,
					"italic": false,
					"strikethrough": false,
					"underline": false,
					"code": false,
					"color": "default"
				},
				"plain_text": ": it’s really good at what it does. It’s very simple to set up, easy to add content and can be extended by 1000’s of plugins.",
				"href": null
			}
		],
		"color": "default"
	}
}

The paragraph contains a link in the middle so it is split into three rich_text objects. To turn it into HTML we need to loop through them to combine the text and then wrap some

’s around the entire thing. Here’s the C# code to do it:

public static string RenderRichText(IEnumerable<RichText> richTexts, string tag)
{
    var builder = new StringBuilder();

    if (tag != null)
    {
        builder.Append($"<{tag}>");
    }

    foreach (var text in richTexts)
    {
        if (text.href != null)
        {
            builder.Append($@"<a href=""{text.href}"" target=""_blank"" rel=""noopener noreferrer"">{text.plain_text}</a>");
        }
        else
        {
            builder.Append(text.plain_text);
        }
    }

    if (tag != null)
    {
        builder.Append($"</{tag}>");
    }

    return builder.ToString();
}

So that’s paragraphs done 🙂

Images

Any images in the post will need to be converted into an tag with the src set to where ever Notion stores them.

At least that’s how it would work in a perfect world.

Serving images from Notion is a bit more difficult…. here’s what an image block looks like:

{
	"image": {
		"caption": [
			{
				"type": "text",
				"text": {
					"content": "Layers of the planned architecture",
					"link": null
				},
				"annotations": {
					"bold": false,
					"italic": false,
					"strikethrough": false,
					"underline": false,
					"code": false,
					"color": "default"
				},
				"plain_text": "Layers of the planned architecture",
				"href": null
			}
		],
		"type": "file",
		"file": {
			"url": "https://s3.us-west-2.amazonaws.com/secure.notion-static.com/cb8379f1-65b7-467b-a922-d48b22012b57/notion-cms-blog-layers.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AKIAT73L2G45EIPT3X45%2F20221219%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20221219T154911Z&X-Amz-Expires=3600&X-Amz-Signature=4475dd4fc5d3db79b78f6ddae28e6a51b1068612154ed8746c4cef618350588a&X-Amz-SignedHeaders=host&x-id=GetObject",
			"expiry_time": "2022-12-19T16:49:11.215Z"
		}
	}
}

You can see the link to the image file is a AWS s3 link that expires. Because the post HTML is going to be cached an image link that expires isn’t going to work.

There are two methods to get around this:

  • Download the images and reupload them to my own storage.
  • Mask the AWS URL with my own and get a new AWS link every time it’s requested.

Option one doesn’t feel very dynamic. Notion doesn’t provide web hooks so keeping the storage in sync becomes awkward.

I think I prefer option two: masking the URL and getting a fresh AWS link every time. It sounds slow but the images can be cached with Cloudflare so only the first load will be slow.

The image URL will follow this pattern: api.liamhunt.blog/posts/{slug}/images/{imagename}

The slug is needed because of the limitations of the Notion API. There is no endpoint to query just the images of a database. The only way to get them is by getting the post blocks. So this endpoint to has to perform all of the same calls as the blog post content endpoint. Plus an extra one to get the image from AWS.

This is what we end up with:

Flow of serving an image from Notion page

Cloudflare

I’ve mentioned caching a few times during this article. Caching everything on a CDN will make the blog super fast and keep the load on the Azure function within the free tier.

By default Cloudflare cache absolutely everything but we can override that with a page rule:

Caching page rule in Cloudflare

And there we go. There was a lot more work outside of what's listed here to handle all of the different block types and edge cases. A blog post covering everything would be quite long and boring. Hopefully, I’ve included just enough to keep it entertaining.

The most important point is that the article you just read and this entire blog is from content stored in Notion 🙂