Notion is one of the most pleasant places on the internet to write. Databases, toggles, callouts, embeds, code blocks — it all just works while you type. The trouble starts when you want to publish that content as a real website. Notion's own hosting is slow, its official API is lossy when you try to re-render a page faithfully, and "Publish to web" leaves you with no control over branding, SEO, or speed. React Notion X solves this beautifully: it fetches the full data behind a Notion page and renders it in React with claimed 10–100x better performance than Notion itself, while staying pixel-faithful to the original. It's the engine behind thousands of Notion-powered blogs and marketing sites, and it's the reason "write in Notion, ship a fast site" is now a genuinely viable workflow.
Why It Exists
The official Notion API is block-oriented and surprisingly lossy. It doesn't return collection (database) views, and it only partially supports a handful of block types — which means you simply can't rebuild a page faithfully from it. React Notion X sidesteps this by reading the same full page record map that the Notion web app itself uses, via an unofficial client. The result is high-fidelity rendering: rich text formatting, columns, callouts, equations, embeds, and full database views, all reproduced in React with Lighthouse scores in the 95–100 range.
The project is split into two cooperating halves, and understanding that split is the key to using it well:
notion-clientruns on the server. It fetches the page data and cannot run in the browser (Notion's API has CORS restrictions).react-notion-xruns anywhere React does. It takes that fetched data and renders it.
The typical flow is: fetch the record map on the server at build or request time, pass it down as a prop, and render it on the client.
What You Get Out of the Box
The renderer covers an impressively wide block vocabulary: pages, fully formatted rich text, headings, lists, quotes, callouts, to-dos, tables of contents, dividers, columns, toggles, equations, images, video, audio, files, bookmarks, code blocks, tweets, Figma and Google Maps embeds, PDFs, and collections rendered as table, gallery, board, or list views. The core renderer is around 28kb gzipped because the heavyweight blocks aren't bundled until you ask for them.
Around the two main packages sits a small ecosystem: notion-types (universal TypeScript definitions for Notion's data model), notion-utils (traversal and transformation helpers), notion-compat (a work-in-progress layer to render from the official API), and notion-x-to-md (Notion to Markdown conversion). TypeScript batteries are included throughout.
One known gap worth mentioning up front: calendar collection views are not supported.
Installing the Pieces
You'll almost always want both halves — the fetcher and the renderer.
npm install react-notion-x notion-client
Or with Yarn:
yarn add react-notion-x notion-client
The core styles are a required import. The two optional stylesheets only matter if you use code highlighting or equations.
import 'react-notion-x/styles.css' // required
import 'prismjs/themes/prism-tomorrow.css' // only if rendering code blocks
import 'katex/dist/katex.min.css' // only if rendering equations
Fetching a Page on the Server
The notion-client package does the heavy lifting of pulling down the full record map. For public Notion pages, no authentication is needed at all — you just need the page ID, which is the long hex string in the page's URL.
import { NotionAPI } from 'notion-client'
const notion = new NotionAPI()
const recordMap = await notion.getPage(
'067dd719a912471ea9a3ac10710e7fdf'
)
This runs happily in Node.js, Bun, Deno, and Cloudflare Workers. For private pages you supply your token_v2 and notion_user_id cookies to the NotionAPI constructor — note these are browser session cookies, not the official integration token. Because this call hits Notion's servers, you'll want to cache the result aggressively rather than fetching on every request; most production setups fetch statically at build time.
Rendering the Record Map
Once you have a recordMap, handing it to NotionRenderer is delightfully simple.
import { NotionRenderer } from 'react-notion-x'
export default function NotionPage({ recordMap }) {
return (
<NotionRenderer
recordMap={recordMap}
fullPage={true}
darkMode={false}
/>
)
}
The fullPage prop is the difference between rendering just the content blocks and rendering the full page chrome — cover image, page icon, title, and the comfortable padding Notion users expect. The darkMode boolean flips the whole tree into a dark theme by setting a dark-mode class on the root, so you can wire it straight to a theme toggle.
Wiring It Into Next.js
React Notion X pairs most naturally with Next.js. You fetch in getStaticProps, cache the result by virtue of static generation, and pass the record map down as a page prop.
import { NotionAPI } from 'notion-client'
import { NotionRenderer } from 'react-notion-x'
import type { GetStaticProps } from 'next'
export const getStaticProps: GetStaticProps = async () => {
const notion = new NotionAPI()
const recordMap = await notion.getPage(
'067dd719a912471ea9a3ac10710e7fdf'
)
return { props: { recordMap }, revalidate: 60 }
}
export default function Page({ recordMap }) {
return <NotionRenderer recordMap={recordMap} fullPage darkMode={false} />
}
To get the most out of the framework, inject Next's own Image and Link components through the components slot. The first enables next/image optimization plus low-quality image placeholders for a smooth blur-up effect; the second turns internal Notion page links into client-side navigations.
import Image from 'next/image'
import Link from 'next/link'
<NotionRenderer
recordMap={recordMap}
fullPage
components={{ nextImage: Image, nextLink: Link }}
/>
If you'd rather not assemble all this yourself, the nextjs-notion-starter-kit is the canonical shortcut: clone it, set your root Notion page ID, and deploy to Vercel. It comes with SEO, RSS, search, dark mode, and image previews already wired up.
Keeping the Bundle Tiny With Lazy Blocks
The core renderer stays around 28kb because the genuinely heavy blocks — syntax highlighting, database views, math, PDFs, and the preview modal — aren't included by default. You opt into each one by importing it from react-notion-x/third-party/* and passing it through the components prop. The recommended pattern is to lazy-load them with next/dynamic, so a reader who never hits a code block never downloads Prism.
import dynamic from 'next/dynamic'
import { NotionRenderer } from 'react-notion-x'
const Code = dynamic(() =>
import('react-notion-x/third-party/code').then((m) => m.Code)
)
const Collection = dynamic(() =>
import('react-notion-x/third-party/collection').then((m) => m.Collection)
)
const Equation = dynamic(() =>
import('react-notion-x/third-party/equation').then((m) => m.Equation)
)
const Pdf = dynamic(
() => import('react-notion-x/third-party/pdf').then((m) => m.Pdf),
{ ssr: false }
)
const Modal = dynamic(
() => import('react-notion-x/third-party/modal').then((m) => m.Modal),
{ ssr: false }
)
export default function Page({ recordMap }) {
return (
<NotionRenderer
recordMap={recordMap}
fullPage
components={{ Code, Collection, Equation, Pdf, Modal }}
/>
)
}
Code pulls in prismjs, Equation pulls in katex, and Collection (the database renderer) is the heaviest of the five. Loading all of them eagerly is the single fastest way to bloat your bundle, so reach for next/dynamic unless you're certain every page needs them. Notice that Pdf and Modal are loaded with { ssr: false } — they're browser-only.
Rewriting URLs and Adding Previews
Two props give you real control over how the rendered page connects to the rest of your site. mapPageUrl decides what internal Notion links point to — handy when you want clean, slug-based routes instead of raw page IDs. mapImageUrl lets you rewrite Notion's signed image URLs through your own proxy or CDN, which sidesteps the expiring-URL problem Notion images are notorious for.
<NotionRenderer
recordMap={recordMap}
fullPage
rootPageId="067dd719a912471ea9a3ac10710e7fdf"
mapPageUrl={(pageId) => `/${pageId}`}
mapImageUrl={(url, block) => `/api/notion-image?url=${encodeURIComponent(url)}`}
previewImages
/>
Setting previewImages enables those blur-up LQIP placeholders, so images fade in gracefully instead of popping. Combined with rootPageId and rootDomain, you get correctly resolved internal links across a single-domain site — exactly what you need for a multi-page Notion blog.
A Note on the Unofficial API
It's worth being clear-eyed about the trade-off React Notion X makes. The reason it can render pages so faithfully is that notion-client reads Notion's private, internal API — the same one the web app uses — rather than the official integration API. That's also the source of its main caveats. The internal API can change without warning, it sits outside Notion's official surface, and heavy traffic should always be cached rather than hammered against Notion directly. The project does offer notion-compat for rendering from the official API instead, but it's slower and currently can't render collections at all, because the official API simply doesn't return enough data. For most people building a Notion-backed site, the unofficial path remains the highest-fidelity, fastest option — which is exactly why it dominates the space.
Conclusion
React Notion X has earned its place as the default way to publish Notion content as a fast, branded website. The mental model is small once it clicks: fetch the full record map on the server with notion-client, cache it, then hand it to NotionRenderer on the client. Keep the core bundle lean by lazy-loading the five heavy third-party blocks, inject your framework's image and link components for free optimization, and rewrite image URLs through a proxy so they never expire. Pair it with the starter kit if you want a finished site in minutes, or wire it up by hand for full control. Either way, you get to keep writing where writing feels good — and your readers get a site that loads in a blink.