Notion as CMS for Next.js
As I wrote in the first post of this blog, I was looking for an easy-to-use, WYSIWYG, simple content management system. I thought Notion might just be the tool I needed, but I had to figure out how to wire it in a simple Next.js app.
Let me mention that there are already several cool, fast, and complete libraries to do that, but I wanted to fully understand the process and the API. The way I do this is by trying to rebuild things from scratch. The result is usually less cool, less fast, and less complete, but I learn a lot in the process.
This is just how I did it and what I learned.
Prerequisites
To get the content of a page, you need to create an integration and grant it permission to read the content of a database. I won't be covering that in this post.
The entire source code lives here: react-notion-render.
I am both lazy and indifferent about the use of the code in the repository, so I'm not going to bother making it a package. I think it will be more useful to be copied, pasted, and adapted on a case-by-case basis. After all, it represents one of many ways to render Notion content.
Understanding Notion’s data structures
The most important concept when working with Notion pages or content, in general, is the Block.
A page’s content is a list of block objects that can be of different types, such as paragraph, heading, etc. A page itself is a special type of block.
Blocks contain many properties, but only a few of them are relevant for my purposes:
id: This is the unique ID of the block. It’s useful for having stable keys when rendering and for headings to have a usable hash to anchor to;type: The discriminant information to distinguish blocks;has_children: A boolean that signals whether a block has additional children. This is important because fetching blocks only returns a shallow list, without nested blocks;
Type
Every block has a property type that can assume one of the values shown in the Block reference page. I’m interested in a small subset of them to begin with:
paragraph: This block naturally represents the most basic one when it comes to content;heading_2,heading_3: Notion provides three levels of headings, but I’m not going to care aboutheading_1because that is going to be used for the title of the blog post. I don’t want to mess with the semantics of the page by allowing multipleh1s;image: The image block represents both referenced images via external URLs and uploaded files;bulleted_list_item,numbered_list_item: These are tricky because they represent single list items, not the list itself;
The block object is going to have an additional property named after the type of the block that contains additional information useful for rendering, like this:
{
"type": "paragraph",
// ... more properties ...
"paragraph": {
// ... paragraph details ...
}
}Rich text
Many block types support rich text content. When that’s the case, the block object has a rich_text property with an array of objects.
These objects represent bits of inline text that share the same characteristics. Take this sentence, for instance:
The Pilatus PC-12 is a pressurized, single-engined, turboprop aircraft manufactured by Pilatus Aircraft of Stans, Switzerland since 1991.
It’s a single paragraph block that contains 10 rich text objects:
- The
- Pilatus
- PC-12
- is a pressurized, single-engined,
- turboprop
- aircraft manufactured by
- Pilatus Aircraft
- of
- Stans, Switzerland
- since 1991.
These pieces shares the same formatting options:
- blocks number 1, 4, 6, 8 and 10 are just plain texts;
- n° 2 is bold;
- n° 3 is bold and italic;
- number 4, 6 and 8 are links.
There are six possible annotations for a rich text block: bold, italic, strikethrough, underline , code and color. All of these are booleans that can be combined, except for the color property, which is an enum with 19 possible values. When the href property has a value, the block is a link.
Just like normal blocks, rich_text objects also have a type property that can assume a range of values: text, mention, equation.
I'm going to mostly care about the text one. The actual value of the block is contained in the plain_text property.
{
"type": "paragraph",
// ... more properties ...
"paragraph": {
// ... paragraph details ...
"rich_text": [
{
"type": "text",
"text": { "content": "The", "link": null },
"annotations": {
"bold": false,
"italic": false,
"strikethrough": false,
"underline": false,
"code": false,
"color": "default"
},
"plain_text": "The",
"href": null
},
{
"type": "text",
"text": { "content": "Pilatus", "link": null },
"annotations": {
"bold": true,
"italic": false,
"strikethrough": false,
"underline": false,
"code": false,
"color": "default"
},
"plain_text": "Pilatus",
"href": null
},
{
"type": "text",
"text": { "content": "PC-12", "link": null },
"annotations": {
"bold": true,
"italic": true,
"strikethrough": false,
"underline": false,
"code": false,
"color": "default"
},
"plain_text": "PC-12",
"href": null
}
],
}
}New lines
One noticeable thing about how text is returned by Notion’s API is that new lines obtained by typing Shift + Enter are returned as \n in the plain_text property. This is the typical case where I want to use a <br /> tag rather than a completely new block element like a paragraph.
Color
Notion allows to set both the text and the background color of most blocks, but not both at the same time.
The set of supported colors is limited to these values: blue, brown, gray, green, orange, pink, red, yellow. When a color is used as background, it has the _background suffix, becoming something like blue_background, brown_background, and so on.
By default, no color is applied, and the property has the value default.
Flat blocks
I’d have expected that the bullet and number list items would have been grouped into some special bullet_list or number_list block object. Well, that’s not the case.
The bulleted_list_item and numbered_list_item blocks are just flattened out in the content of a Notion page, so it’s our job to be aware of this and handle them accordingly.
Model the rendering
Once we have a rough understanding of how data is represented by Notion APIs, we can plan how to render the content. We know that Notion’s data is organized into three levels: content is made of blocks; blocks may contain rich text blocks; rich text blocks have one or more annotation.
- blocks;
- rich text blocks;
- plain text and annotations;
Let’s start by working backward.
You’ll notice that I’m performing computations at render time, without memoizing or using keys when rendering arrays. No need to freak out, I am running this non-interactive code only once, on the server.
Plain text
As mentioned earlier, the plain_text property of a rich text block uses \n characters to indicate a new line that shouldn’t be considered a proper paragraph.
To get the following:
I should
be on
three lines.Notion sends something like:
"I should\nbe on\nthree lines."Splitting it using the \n as separator, we get an array with three elements:
["I should", "be on", "three lines."]The final output can be constructed by adding a <br> after each element, except for the last one. In my repo I made a PlainText component that looks something like this then:
import type { PropsWithChildren, ReactNode } from "react";
export function PlainText({ children }: PropsWithChildren) {
if (typeof children !== "string")
throw new Error("PlainText accepts only strings as children.");
const parts = children.split("\n");
let output: ReactNode[] = [];
for (let index = 0; index < parts.length; index++) {
output.push(parts[index]);
if (index !== parts.length - 1) output.push(<br />);
}
return output;
}Annotations
Most of the available annotations have one or more corresponding HTML tags that we can use to wrap their children nodes, for instance:
bold:<strong>or<b>italic:<em>or<i>strikethrough:<del>or<s>underline:<u>code:<code>color:<mark>
Here we could open a discussion about the semantics of some of the tags above like the use of <strong> versus <b> or even the use of deprecated tags like <u>. But I won’t.
One key consideration when working with annotations is that multiple annotations can be applied simultaneously. This needs to be reflected in a nested HTML tree.
Since I couldn’t find any clear definition in the spec about the nesting order of tags, I made my own priority list:
<code>
<mark>
<strong>
<em>
<u>
<del>
plain text
</del>
</u>
</em>
</strong>
</mark>
</code>To achieve this, I made an Annotations component that takes the annotations object as a prop and keeps wrapping its children as long as formatting options are enabled:
import type { PropsWithChildren, ReactNode } from "react";
import type { RichTextItemResponse } from "@notionhq/client/build/src/api-endpoints";
interface AnnotationsProps {
annotations: RichTextItemResponse["annotations"];
}
export function Annotations({
annotations,
children,
}: PropsWithChildren<AnnotationsProps>) {
if (!children) return null;
const { strikethrough, underline, italic, bold, code, color } = annotations;
let output: ReactNode = children;
if (strikethrough) output = <del>{output}</del>;
if (underline) output = <u>{output}</u>;
if (italic) output = <em>{output}</em>;
if (bold) output = <strong>{output}</strong>;
if (color !== "default") output = <mark data-color={color}>{output}</mark>;
if (code) output = <code>{output}</code>;
return output;
}It’s a bit disappointing that the Notion SDK doesn’t export a proper type for the annotations object. They have it – it’s called AnnotationResponse – but it’s not exported. 🤷♂️
Color
I decided to handle colors for the <mark> tags by using a data-color attribute and styling it via CSS with selectors like:
[data-color="yellow"] {
color: #EAB308;
}
[data-color="blue_background"] {
background-color: #EFF6FF;
}Rich text
Almost every block has rich_text property, which is an array of objects. Text is rendered by composing multiple rich text blocks together.
Notion exports the RichTextItemResponse type to represent these blocks, and we can see that it’s a discriminated union type between three other types:
TextRichTextItemResponseMentionRichTextItemResponseEquationRichTextItemResponse
Just like with normal blocks, the discriminant key is the type property, which can have the values: text, mention and equation.
Let’s delve into the first two types, since I don’t care about the third yet.
Text
A rich text block of type text represents the basic content of every element in a page.
To properly render a piece of text, we have to focus on three properties of the TextRichTextItemResponse type:
plain_text, which contains the actual text to display including\ncharacters for new lines;annotations, that indicate the formatting of the text we’re rendering;href, an optional URL if the text is a link;
For each of these, we need to do a bit of processing, but we have all the components to do so.
New lines will become <br> tags using the PlainText component, styling will be handled by the Annotations component, and if needed, everything can be wrapped in a Link component:
import Link from "next/link";
import type { RichTextItemResponse } from "@notionhq/client/build/src/api-endpoints";
import { PlainText } from "./PlainText";
import { Annotations } from "./Annotations";
interface TextProps {
plainText: string;
annotations: RichTextItemResponse["annotations"];
href: string | null;
}
export function Text({ plainText, annotations, href }: TextProps) {
let output = <PlainText>{plainText}</PlainText>;
output = <Annotations annotations={annotations}>{output}</Annotations>;
if (href) output = <Link href={href}>{output}</Link>;
return output;
}The handling of links can be significantly improved. For instance, it might be useful to open external links in new tabs and to detect when a link points to a Notion page, updating the URL so that it points to the corresponding page on our system. But I’m not gonna do either things now, even if both are implemented for this blog.
Mention
In Notion, a piece of rich text can serve as a link to another entity, such as a user, a date, a link, and more. These entities are represented by objects of type MentionRichTextItemResponse in the rich text blocks array.
In my use case, I’m primarily interested in the link_preview type of mentions. You can find more information about the other types here.
At this stage, this blog treats link previews as normal links, GitHub repository URLs, which receive a special massage to produces this output: react-notion-render
I achieve this with a simple LinkPreview component:
import { cloneElement } from "react";
import Link from "next/link";
interface LinkPreviewProps {
url: string;
}
const GITHUB_REPO = /github.com\/.+?\/(.+?)$/;
export function LinkPreview({ url }: LinkPreviewProps) {
let output = <Link href={url}>{url}</Link>;
if (GITHUB_REPO.test(url)) {
const [_, repo] = url.match(GITHUB_REPO)!;
const attributes = { "data-github": true, target: "_blank" };
output = cloneElement(output, attributes, repo);
}
return output;
}In my scenario, I prefer to keep the HTML as clean as possible and use attributes like data-github to add styling element via CSS, such as the Github Octocat in this case:
[data-github] {
display: inline-flex;
column-gap: 0.25rem;
align-items: baseline;
color: black;
text-decoration: underline #CBD5E1;
text-underline-offset: 0.25rem;
&:hover {
text-decoration-color: black;
}
&:before {
align-self: center;
content: "";
width: 1rem;
height: 1rem;
background-image: url("data:image/svg+xml,%3Csvg width='98' height='96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z' fill='%2324292f'/%3E%3C/svg%3E");
background-size: 1rem;
}
}As already anticipated before, I’m currently only interested in the link_preview type of mentions. However, I plan to add support for a few more types in the near future, so I thought of making a general Mention component. This component takes a rich text object block and uses a switch statement to render the supported type of mentions, or nothing if it’s not yet supported:
import type { MentionRichTextItemResponse } from "@notionhq/client/build/src/api-endpoints";
import { LinkPreview } from "./LinkPreview";
interface MentionProps {
mention: MentionRichTextItemResponse["mention"];
}
export function Mention({ mention }: MentionProps) {
switch (mention.type) {
case "link_preview":
return <LinkPreview url={mention.link_preview.url} />;
default:
return null;
}
}
Putting things together
Once I had the components to render simple text and mentions rich text objects, I could reuse the same mapping concept of the Mention component for a new RichText component:
import type { RichTextItemResponse } from "@notionhq/client/build/src/api-endpoints";
import { Text } from "./Text";
import { Mention } from "./Mention";
interface RichTextProps {
blocks: RichTextItemResponse[];
}
export function RichText({ blocks }: RichTextProps) {
return blocks.map((block) => {
switch (block.type) {
case "text":
return (
<Text
plainText={block.plain_text}
annotations={block.annotations}
href={block.href}
/>
);
case "mention":
return <Mention mention={block.mention} />;
default:
return null;
}
});
}
Blocks
We’re now able to render almost any text. This text is usually contained within element blocks like paragraphs, callout, headings and several others that can be found here.
I won’t go into the details of rendering each block type, because I think the concept can be understood with just a few examples.
Paragraph
This, of course, is going to be the most-rendered block of any Notion content.
Considering the components we’ve discussed so far, rendering a paragraph will be as simple as:
import type { ParagraphBlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
import { RichText } from "./RichText";
interface ParagraphProps {
block: ParagraphBlockObjectResponse;
}
export function Paragraph({ block }: ParagraphProps) {
return (
<p>
<RichText blocks={block.paragraph.rich_text} />
</p>
);
}The use of a generic prop block is intentional; it will soon prove useful in keeping the code clean and concise when rendering many different types of block objects.
Heading, Quote, etc
As you can imagine, most of the element block rendering components share the same core concepts:
<p><RichText blocks={block.paragraph.rich_text} /></p>
// ...
<h2><RichText blocks={block.heading_2.rich_text} /></h2>
// ...
<blockquote><RichText blocks={block.quote.rich_text} /></blockquote>Note that I’m oversimplifying the rendering of these elements to fit my specific use case. You should adapt this sample implementation to suit your own needs.
For instance, as I mentioned earlier, most blocks support the coloring of either the text or the background. In the sample blocks shown before, there’s no trace this because, in this blog, those elements aren’t colored. My Callout component, though, makes use of it:
import type { CalloutBlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
import { RichText } from "./RichText";
interface CalloutProps {
block: CalloutBlockObjectResponse;
}
export function Callout({ block }: CalloutProps) {
const { color, rich_text } = block.callout;
return (
<div role="alert" data-color={color}>
<p>
<RichText blocks={rich_text} />
</p>
</div>
);
}Lists
Unfortunately, the Notion API doesn’t group list items together under some sort of bullet_list or number_list block; instead, it flattens them. Since we’re good people, we don’t want to blindly render <li>s in the middle of the content, but we want to group them into <ul> or <ol> elements.
The first step to achieve that is to extend the typing provided by Notion. The page content is represented as an array of objects of type BlockObjectResponse, which is a union type like this:
type BlockObjectResponse =
| ParagraphBlockObjectResponse
| Heading1BlockObjectResponse
| Heading2BlockObjectResponse
| Heading3BlockObjectResponse
| BulletedListItemBlockObjectResponse
| NumberedListItemBlockObjectResponse
| QuoteBlockObjectResponse
// 26 more block typesAs the described earlier, each block has a type property to differentiate it from others, and a property with the same name of the type with the details of the block:
{
"type": "paragraph",
// ... more properties ...
"paragraph": {
// ... paragraph details ...
}
}Our new types could then be defined as follows:
import type { BulletedListItemBlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
export type BulletedListBlock = {
type: "bulleted_list";
bulleted_list: {
items: BulletedListItemBlockObjectResponse[];
};
};And:
import type { NumberedListItemBlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
export type NumberedListBlock = {
type: "numbered_list";
numbered_list: {
items: NumberedListItemBlockObjectResponse[];
};
};Now we can replace the default BlockObjectResponse type with one that we’re going to use internally:
import type { BlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
import { BulletedListBlock } from "./BulletedListBlock";
import { NumberedListBlock } from "./NumberedListBlock";
export type ExtendedBlockObjectResponse =
| BlockObjectResponse
| BulletedListBlock
| NumberedListBlock;The utility function I wrote for this blog, groupListItems, groups list items at a single level of depth. There’s no support for nested items yet, and maybe there never will be.
To begin with, as usual, let’s reason at type level. A grouping process can be of three kinds: no grouping, grouping a bullet list, or grouping a numbered list:
type Grouping =
| false
| BulletedListBlock["type"] // bulleted_list
| NumberedListBlock["type"]; // numbered_listNo one likes ultra-long names, and I am no exception, so let’s define a union type that represents the type of a single list item:
type ListItemBlock =
| BulletedListItemBlockObjectResponse
| NumberedListItemBlockObjectResponse;At the very beginning, we haven’t processed any block, we are not grouping and our list of items is empty:
let output: ExtendedBlockObjectResponse[] = [];
let grouping: Grouping = false;
let listItems: ListItemBlock[] = [];The general idea is to loop through all the blocks. When a block is a list item of any kind, create a grouping and add the block to it. However, before doing that, the function must verify that it isn’t already grouping a different type of list items.
For example, let’s assume we’re looping and we find a bullet_list_item. If we were previously grouping a numbered_list, we need to stop that grouping and start a new bulleted_list group.
for (const block of blocks) {
switch (block.type) {
case "bulleted_list_item": {
if (grouping !== "bulleted_list") commitPreviousList();
grouping = "bulleted_list";
listItems.push(block);
break;
}
case "numbered_list_item": {
if (grouping !== "numbered_list") commitPreviousList();
grouping = "numbered_list";
listItems.push(block);
break;
}
default: {
commitPreviousList();
output.push(block);
break;
}
}
}When it’s time to commit a list before moving to the next or to the next non-grouped block, we need to create a new block that represents that list and spread the items we’ve collected so far.
Note that this function has access to scope variables like grouping, output and listItems because it’s an inner function defined within the groupListItems utility.
function commitPreviousList() {
if (!grouping) return;
output.push({
type: grouping,
[grouping]: {
items: [...listItems],
},
} as ExtendedBlockObjectResponse);
listItems = [];
grouping = false;
}Once the new block has been added to the output, the list of sub-items is cleared and the grouping is stopped, preparing for a new one.
I won’t paste the full code here, but it can be found here in the repository.
The “routing” point
Just like the RichText and Mention components, rendering block element objects depends on their type property, so the outline of the Blocks component could be something like:
import type { ExtendedBlockObjectResponse } from "./ExtendedBlockObjectResponse";
import { Paragraph } from "./Paragraph";
import { Quote } from "./Quote";
// more imports
interface BlocksProps {
blocks: ExtendedBlockObjectResponse[];
}
export function Blocks({ blocks }: BlocksProps) {
return blocks.map((block) => {
switch (block.type) {
case "paragraph":
return <Paragraph block={block} />;
case "quote":
return <Quote block={block} />;
// more components
default:
return null;
}
});
}This component is then used by other renderers that have block objects as children, such as the BulletList or the NumberList components:
import { Blocks } from "./Blocks";
import type { BulletedListBlock } from "./BulletedListBlock";
interface BulletedListProps {
block: BulletedListBlock;
}
export function BulletedList({ block }: BulletedListProps) {
return (
<ul>
<Blocks blocks={block.bulleted_list.items} />
</ul>
);
}From the response to the render
The entry point of my super simple implementation of a Notion content renderer lies in a component that takes the response of the fetch call, and use the Blocks component after grouping the list items:
import type { BlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
import { Blocks } from "./Blocks";
import { groupListItems } from "./groupListItems";
interface NotionContentProps {
blocks: BlockObjectResponse[];
}
export function NotionContent({ blocks }: NotionContentProps) {
return <Blocks blocks={groupListItems(blocks)} />;
}To recap, the flow is as follows:

Bonus: syntax highlighting
One of the things that annoyed me the most in the past when I worked with CMSes, editors, markdown, or any other writing tool was setting up a decent syntax highlighter thingy.
When working on this blog I found shiki, the syntax highlighter that powers VS Code. It’s a massive package that supports many languages and themes.
Luckily for me, I run its code in a React Server Component, so none of the almost 6MB package size is sent to the client 😬.
Notion provides a block of type code when your page contains syntax-highlighted bits. The actual text is handled like any other block, as an array of rich_text objects. Each of these blocks contains a piece of the actual code you wrote in Notion.
Similar to paragraphs, headings or quotes, the implementation of the renderer for code blocks is quite simple:
import type { CodeBlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
import { SyntaxHighlighter } from "./SyntaxHighlighter";
interface CodeProps {
block: CodeBlockObjectResponse;
}
export function Code({ block }: CodeProps) {
return (
<div data-code>
{block.code.rich_text.map(({ plain_text }, index) => (
<SyntaxHighlighter
key={index}
code={plain_text}
language={block.code.language}
/>
))}
</div>
);
}As usual, I use data-* attributes to style this element with CSS. The juice of the work is done in the SyntaxHighlighter component, which is a server component:
import { codeToHtml } from "shiki";
interface SyntaxHighlighterProps {
code: string;
language: string;
}
export async function SyntaxHighlighter({
code,
language,
}: SyntaxHighlighterProps) {
const lang = language === "typescript" ? "tsx" : language;
const theme = "min-light";
const html = await codeToHtml(code, { lang, theme });
return <div dangerouslySetInnerHTML={{ __html: html }}></div>;
}Shiki provides exactly what I needed: a function that takes in a string of code and gives me back some HTML. The only tweak I’m doing is to consider tsx as the language for Shiki when Notion sends typescript, allowing me to properly highlight React code.
So, I hope you found this excursus into how I use Notion to keep my level of laziness in writing content interesting.
See you in the next post, where I’m probably gonna write about a small touch-enabled sliders component I’m developing for work.
