What it does
react-notion takes the raw block map from Notion's internal API (/api/v3/loadPageChunk) and renders it as React components. You handle the data fetching; the library handles turning blocks into HTML.
Why I starred it
The pitch is straightforward: Notion is a great writing tool but a slow page host. Loading a Notion page in an iframe or through Notion's own embed can take several seconds on first paint. react-notion strips that away by letting you fetch the block data at build time via SSG and serve static HTML, while keeping Notion as the editor.
The approach also cleanly separates content from presentation. You can apply your own CSS, match your site's typography, and not live with Notion's layout constraints. For a personal blog or docs site where the writing happens in Notion, this is a sensible setup.
How it works
The entry point is NotionRenderer in src/renderer.tsx. It's a recursive React component that walks the block tree depth-first:
// src/renderer.tsx
export const NotionRenderer: React.FC<NotionRendererProps> = ({
level = 0,
currentId,
mapPageUrl = defaultMapPageUrl,
mapImageUrl = defaultMapImageUrl,
...props
}) => {
const { blockMap } = props;
const id = currentId || Object.keys(blockMap)[0];
const currentBlock = blockMap[id];
return (
<Block key={id} level={level} block={currentBlock} {...props}>
{currentBlock?.value?.content?.map(contentId => (
<NotionRenderer
key={contentId}
currentId={contentId}
level={level + 1}
{...props}
/>
))}
</Block>
);
};
Each Block in src/block.tsx switches on blockValue.type — "text", "header", "image", "code", "bulleted_list", etc. — and returns the appropriate JSX. The level prop threads down through the recursion, which is how the top-level page block knows whether to render the full page header or just its children.
Inline text styling happens in createRenderChildText in src/block.tsx. Notion stores rich text as a DecorationType[] — an array of [text, decorations] tuples where decorations are themselves arrays like ["b"] (bold), ["i"] (italic), ["a", url] (link), or ["h", colorName] (highlight). The renderer reduces the decorations from right to left using reduceRight, wrapping each segment in the appropriate HTML element:
return decorations.reduceRight((element, decorator) => {
switch (decorator[0]) {
case "h": return <span className={`notion-${decorator[1]}`}>{element}</span>;
case "c": return <code className="notion-inline-code">{element}</code>;
case "b": return <b>{element}</b>;
case "a": return <a className="notion-link" href={decorator[1]}>{element}</a>;
// ...
}
}, <>{text}</>);
The reduceRight detail is intentional — decorations nest inside-out, so the last decorator in the array should be the outermost wrapper. It's subtle and easy to get wrong if you read the code too fast.
List numbering is handled by groupBlockContent in src/utils.ts, which scans the full block map and groups consecutive blocks of the same type together. This lets getListNumber return the correct ordinal for a numbered list item without each item needing to know about its siblings:
const groupBlockContent = (blockMap: BlockMapType): string[][] => {
let lastType: string | undefined = undefined;
let index = -1;
Object.keys(blockMap).forEach(id => {
blockMap[id].value?.content?.forEach(blockId => {
const blockType = blockMap[blockId]?.value?.type;
if (blockType && blockType !== lastType) {
index++;
lastType = blockType;
output[index] = [];
}
output[index].push(blockId);
});
lastType = undefined;
});
return output;
};
Image URLs get special handling in defaultMapImageUrl (src/utils.ts). Notion's image URLs are relative paths that need to be proxied through www.notion.so/image/... with query params for table, id, and cache. The renderer constructs those URLs automatically using the block's parent_table and id, which means images work without any extra configuration on your end.
Extensibility comes through two escape hatches: customBlockComponents lets you override rendering for any block type by key (e.g., swap out "code" for your own syntax highlighter), and customDecoratorComponents does the same for inline decorators like bold, italic, and links.
Using it
npm install react-notion
The minimal setup assumes you have a block map already. The companion notion-api-worker handles the Notion API side — you can call the hosted version at https://notion-api.splitbee.io/v1/page/<PAGE_ID> directly.
import "react-notion/src/styles.css";
import "prismjs/themes/prism-tomorrow.css";
import { NotionRenderer } from "react-notion";
// In Next.js getStaticProps:
const data = await fetch(
"https://notion-api.splitbee.io/v1/page/YOUR_PAGE_ID"
).then(res => res.json());
// In the component:
<NotionRenderer blockMap={data} fullPage />
One Prism gotcha: in production builds, Prism only bundles the languages you explicitly import. If you have code blocks in Notion, you need to add the language imports manually:
import "prismjs/components/prism-typescript";
import "prismjs/components/prism-bash";
The README mentions this but it's easy to miss until your production deploy shows unformatted code.
Rough edges
The package hasn't had a meaningful update since late 2021. Last commit was a bug fix for a null content field (src/utils.ts — blockMap[blockId]?.value?.type uses optional chaining that was added to handle exactly this). The repo is functionally done, not actively maintained.
No test files. The package.json has "test": "tsdx test --passWithNoTests" — the --passWithNoTests flag is doing real work there. Zero coverage for the decorator reducer, the list numbering logic, or the URL construction in defaultMapImageUrl.
Databases are explicitly out of scope and not planned. Checkboxes and table of contents are also missing. If your Notion pages rely on inline databases or formulas, this won't work. react-notion-x is the maintained fork that covers those cases, though it's heavier and more opinionated.
The type definitions in src/types.ts are borrowed from samwightt/chorale-renderer (credited in the file header). They're thorough — every block type is discriminated by a type field, decorations are fully typed down to the tuple structure — but they model Notion's unofficial v3 API, which Notion can change without notice.
Bottom line
If you want a lightweight Notion-as-CMS setup for a blog or docs site and your content doesn't need databases or checkboxes, react-notion does the job cleanly with minimal dependencies. For anything more complex, or if you need active maintenance, react-notion-x is the better choice.
