So, I hear you like Next.js and Notion š So do I. I built a scalable production blog in under 12 hours using Next.js with Notion as my CMS.
Ā

Ā

šĀ Motivation
Why Notion?
I have been in love with Notion since July 2019. I initially fell for the simplicity of creating Markdown notes. After three years, my passion for Notion grows with every new feature release. Notionās block-based data model establishes the base for an incredible information playground, especially if you have a software or data modeling background.
Ā
In March 2022, Notion released an official API. With this API, we can now programmatically interact with our notion data. This API inspired me to see how far I could take Notion as a CMS for my blog.
Ā
Here are a few final reasons why I chose Notion as my CMS.
- I work in Notion for my personal and professional life every day.
- Notion goes beyond markdown to create the best document editing system I have ever used.
- With the release of their API, Notion can be used as both a CMS for document content and a simple relational database solution, perfect for small-scale personal projects.
- There is no need to spin up a table in Postgres or a collection in Mongo anymore. Create a Notion database with all the columns you need, and youāre good to go. Check out the Advanced Concepts section to learn more.
Ā
Why Next.js?
In Web 1, we had static HTML. Javascript was written in
<script>
tags, and websites were presentational and simple.Ā
In Web 2, Javascript becomes king. Capability and complexity explode along with page size. Websites are dynamic, contentful, and slow. Speed is exchanged for power.
Ā
This is where Next.js comes in.
Ā
Next.js combines the power of Web 2 with the simplicity of Web 1. With Next.js, you can write dynamic and complex React applications that build to simple static files for optimum page performance. Next.js is not the solution for all web applications, but itās the best solution in 2022 for a simple blog that doesnāt depend on a unique logged-in experience.
Ā
š Ā Content
Now that Iāve convinced you to use Notion and Next.js, hereās what weāll cover.
Basics
- 1ļøā£Ā Create Your CMS Notion Database
- š»Ā Rendering your Notion data in React
Ā
Advanced
- š½Ā Using Notion as a Relational Database
- šĀ Setting up your notion API integration
- š°ļøĀ Dead-simple like and view features
- š§ Ā A key-value string cache to store based64 blurred images for beautiful image loading
Ā
šĀ Letās Get Started
āļøĀ Table of Contents
šĀ Motivationš
Ā ContentšĀ Letās Get StartedāļøĀ Table of Contents1ļøā£Ā Create Your CMS Notion Databaseš»Ā Fetching and Rendering Your Notion ContentšÆĀ NotionXšĀ Thatās it! šĀ Advanced ConceptsšĀ Using Notion As A Database1ļøā£Ā Create Your Notion Integration2ļøā£Ā Add Your Integration to Your Notion Blog PageĀ 3ļøā£Ā Setup Your Next AppāØļøĀ Interacting With Your Notion Databaseš
°ļøĀ Pre-Req: Improved Typescript Definitionsā¤ļøĀ Upvote and View FeaturesšĀ Updating A Property šĀ Image Blurringā”Ā Static Page Generationā
Ā Thatās Actually It!
Ā
1ļøā£Ā Create Your CMS Notion Database
Our new notion database will host our blog content and a few simple database properties. Duplicate this database template to start. This page makes sure you have all the appropriate properties to power your blog.
Ā

Once you have duplicated the blog template as a page in your notion space
- Click the share button in the top right.
- Click the Share to web button and allow comments.
Why share to the web?
By sharing publicly on the web, readers will be able to comment on your blog. The Notion team plans to add comments to the API by May 2022. Once this is available, we will be able to build native comments into our blog.

Ā

š½Ā DatabaseĀ Properties
Subtitle
, Author
, Tags
, Categories
, Published
, Updated
, Created
ā All of these properties are standard blog fields that will help us identify, search and display each blog post.Upvotes
, Views
ā These two features will act more like traditional relational database columns. We will only interact with them through the notion API. They will count how many upvotes and page views each post gets.CacheId
ā This is a reference to another block in Notion. We will fetch this block during build time and edit its children to store a stringified JSON of an image cache. Ā
Ā
š»Ā Fetching and Rendering Your Notion Content
We will be using the unofficial notion API (i.e., web scraping the same endpoints Notion uses for its clients). Read the below aside to find out why there are no good react renderers for Notionās official API.
š£ļøĀ Aside: Why There Are No Good React Renderers for Notionās Official API (yet)
After Notion moved their API out of Beta, I assumed someone would writeĀ a react renderer for the block objects returned from Notionās API. Nobody has.
Ā
Notionās API is still very immature (as of Mar 28, 2022), and there isnāt support for all block types yet. To get the full range of black content, you need to use the unofficial API. If this is outdated, leave a comment so we can start building react renderers for the official API!
Ā
Aside-to-the-Aside
I tested rendering blocks from the official notion API using notion-to-md. The results were... not good.
NotionX is already aware of the desire to build a renderer that supports the official Notion API. Follow along with the conversation here. I imagine multiple authors will release rendering libraries once all block types are supported in Notionās API.
Ā
šÆĀ NotionX
NotionX is a group dedicated to building next-level hacks for Notion. They created a fantastic notion renderer and a simple client to fetch your notion data using Notionās unofficial API. Follow the steps below to render your first notion page in next.js. This blog post is a simplified introduction. For a more thorough understanding, I recommend reading the docs at NotionX Github.
Install react-notion-x and notion-client
1
npm i --save react-notion-x notion-client
Create a method to fetch a notion page
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Place this file anywhere in your next project.
import { NotionAPI } from 'notion-client';
const api = new NotionAPI({
// This token is your user token.
// This is only necessary if you wish to keep your blog database private
// It is not needed if your database is public.
// See https://www.notion.so/Find-Your-Notion-Token-5da17a8df27a4fb290e9e3b5d9ba89c4
// for how to find your user token.
authToken: notionUserTokenV2,
});
export function getBlogPost(postId: string) {
const post = await api.getPage(postId);
return post;
}
Fetch your data and render it
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// pages/blog.tsx
import { NotionRenderer } from 'react-notion-x';
import { ExtendedRecordMap } from 'notion-types';
export async function getStaticProps(
context: GetStaticPropsContext
): Promise<GetStaticPropsResult<BlogStaticProps>> {
const blogPost = await getBlogPost(postId);
return {
props: {
blogPost:
},
};
}
export default function BlogPost({ blogPost }: { BlogPost: ExtendedRecordMap }) (
<NotionRenderer recordMap={blogPost} />
);
Ā
šĀ Thatās it!
You have everything you need to render notion content in your next.js app. Keep reading to learn about some advanced techniques to power a production blog.
Ā
Ā
šĀ Advanced Concepts
šĀ Using Notion As A Database
Relational databases are no different than a table with rows and columns.
Tables have | rows, and |
tables have | columns. |
So do databases. | ć
¤ |
Ā
Thatās why Notion tables are called databases. We can create a relational database using Notion! To interact with Notion as our database, we need to set up an API integration.
Ā
1ļøā£Ā Create Your Notion Integration
Head to Notionās integration space and create a new integration.
Make sure to allow reading, updating, and inserting content.

Ā
Ā
2ļøā£Ā Add Your Integration to Your Notion Blog PageĀ
You need to give your notion integration access to your blog database page.
- Click the share button in the top right.
- Search for your integration and give it editing privileges.

3ļøā£Ā Setup Your Next App
šØĀ Config
We will add an environment variable for the database ID of our blog. The database ID will be the first URL param if you go to your blog page on a web browser.
1
2
3
4
5
// Example
https://www.notion.so/prescottjr/<database_id>?v=fac85496fc5b4cbaa6f315d6b0e4557a
// Real
https://www.notion.so/prescottjr/a7a4a3733f5445d885fecc257f9e5e80?v=fac85496fc5b4cbaa6f315d6b0e4557a
Ā
Add this ID to a
.env.development
file in the root directory of your next project, as shown below. 1
2
NOTION_API_KEY=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
BLOG_DATABASE_ID=databse_id_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Make sure to add these values to your next projectās environment variables, so they are accessible in staging and production environments as well.
Ā
āØļøĀ Interacting With Your Notion Database
This post will use the Notion javascript SDK. Follow the Notion API documentation for more details.
š
°ļøĀ Pre-Req: Improved Typescript Definitions
Unfortunately, the types bundled with @notionhq/client are challenging to use. They are autogenerated and define unions for every field of every type. Therefore, you have to type narrow before accessing any property on the responses. You can find more info in this Github issue.
Ā
Because of these ambiguous type definitions, I created a few typescript helpers to make the data returned from Notionās API more useable. You can find these types here. The most important type to note is
NotionPage
. NotionPage
strongly types all the properties used in the blog database template and narrows types to avoid property may not exist
errors on the auto-generated Notion union types. Ā
npm i --save @notionhq/client
- Query your database in any back end
node.js
environment
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// pages/api/blog/posts/[postId]/like
import { NextApiRequest, NextApiResponse } from 'next';
import { Client } from '@notionhq/client';
import { NotionPage, Properties } from '/src/types/cms/properties';
const notionAPIKey = process.env.NOTION_API_KEY;
/**
* Example next.js API endpoint
*/
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const pageId = req.query.postId as string;
const notion = new Client({
auth: notionAPIKey,
});
const pageData = (await notion.pages.retrieve({
page_id: pageId,
})) as NotionPage;
res.status(200).json({ pageData });
}
Ā
Now that you know how to use Notion as a database, we can build some features!
Ā
ā¤ļøĀ Upvote and View Features
The
Upvotes
and Views
properties are available at blogPost.properties.Upvotes.number
or blogPost.properties.Views.number
in the NotionPage
type and can be used to display in your react application.Ā
Once the data is displayed, we can build simple client interactions and API endpoints to update their values.
šĀ Updating A Property
Endpoint to Update Upvotes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// pages/api/blog/posts/[postId]/like
import { NextApiRequest, NextApiResponse } from 'next';
import { Client } from '@notionhq/client';
import { NotionPage, Properties } from '/src/types/cms/properties';
const notionAPIKey = process.env.NOTION_API_KEY;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const pageId = req.query.postId as string;
const body = JSON.parse(req.body);
const { upvotes } = body;
const notion = new Client({
auth: notionAPIKey,
});
const pageData = (await notion.pages.retrieve({
page_id: pageId,
})) as NotionPage;
const currentLikes = (pageData.properties?.Upvotes?.number as number) || 0;
const updatedVotes =
upvotes < currentLikes + 10
? Math.max(upvotes, currentLikes + 1)
: currentLikes + 1;
const updatedPage = (await notion.pages.update({
page_id: pageData.id,
properties: {
[Properties.Upvotes]: {
number: updatedVotes,
},
},
})) as NotionPage;
res.status(200).json({ pageData: updatedPage });
}
React Callback to Hit Upvote Endpoint
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// BlogPost React Component
const [upvotes, setUpvotes] = useState(
(pageData?.properties?.Upvotes?.number as number) || 0
);
const [hasUpvoted, setHasUpvoted] = useState(Boolean(false));
const updateLikes = useCallback(
throttle(1000, async () => {
const currentUpvotes = upvotes;
setUpvotes(currentUpvotes + 1);
setHasUpvoted(true);
localStorage.setItem(getVotedLocalStorageKey(pageData.id), 'true');
fetch(getApiUrl(`blog/posts/${pageData.id}/like`), {
method: 'POST',
body: JSON.stringify({ upvotes: currentUpvotes + 1 }),
});
}),
[upvotes]
);
Ā
Ā
šĀ Image Blurring
This is my favorite feature of the blog. The idea originated from Travisā tutorial for image caching. Check out his guide for creating base64 image blurs, which relies on the sharp image util. Reference either of these tools to develop base64 strings for blurred images. For example:
Ā
This image is over 2MB.

ThisšĀ is the base64 string to blur the image.
data:image/webp;base64,UklGRlwAAABXRUJQVlA4IFAAAAAQAgCdASoQAAwABUB8JbACdADdsZ4eYyNAAP60Ev/jUvQlDVSKpgCjA6itD0IB9Gggqt674N6Kc8gCLlOimqJskaZ6UH44B6OgXHmMvAAAAA==
Once we have generated our base64 image strings, we want to save them to avoid recomputing them every time our web pages build. Redis is the default answer for a simple string cache. But, if we can use Notion for our database, why canāt we use it as a cache? It will be a slow cache, but weāre not concerned with performance because our pages are statically generated at build time.
In reality, this cache isnāt necessary. Because of SSG, generating base64 blurred images on every page build would be acceptable. But it is a fun exercise to store non-relational data in Notion š¤·Ā And it does reduce the likelihood of timeouts when generating your pages. ā”
Ā
The Approach
Check out my current image cache page to get context before moving on.
Create a Property on the blog database to store an id reference to another notion block
Weāll use this ID to find another notion block and populate its children with our stringified JSON cache.

Ā
Find /create your notion cache ā source
We need to break the stringified JSON into multiple notion blocks because the Notion API limits text property updates to 2000 characters. For posts with more than a few images, the cache will exceed 2000 characters.
- Find the parent notion block (the cache key).
- List all the children of that parent.
- Join all the text from the children. This will then be a string representation of a JSON object.
JSON.parse
it, and now youāve returned your cache from Notion.
During SSG, create your base64 images ā source
If the image is available in the cache, use it. If not, create the base 64 image, save it to an in-memory cache, and return it for the pageās use.
Update the cache in Notion with the in-memory cache ā source
Stringify the in-memory object, chunk it into pieces smaller than 2000 characters, and then individually append the chunks as a new child to the parent block.
The next time your page generates, it will use this cache instead of building new images!
Ā
ā”Ā Static Page Generation
One of the most notable features of Next.js is the ability to generate pages at build time statically (SSG). I recommend following their docs to learn about SSG.
Ā
SSG is not required to power your blog, but static generation will be essential to avoid slow page load speeds if you implement the above features.
Ā
ā Ā Thatās Actually It!
Thanks for reading. Leave a comment if you have questions or new ideas for fun ways to use Notion!

Ā
Ā
Ā
Ā
Ā