notionic
v0.17.0
Published
Render Notion API blocks as React Server Components, styled with Tailwind CSS and shadcn/ui tokens.
Maintainers
Readme
notionic
Render blocks from the official Notion API as React Server Components, styled with Tailwind CSS and shadcn/ui's theme tokens.
- Server-first. Every block renders on the server; notionic ships no client JavaScript.
- Follows your theme. Components use shadcn/ui tokens (
foreground,muted,border…), so they match your app, dark mode included. - Replaceable. Swap the component for any block type.
- Zero runtime dependencies besides React.
Early development. The API may change before 1.0.
Install
pnpm add notionic @notionhq/clientnotionic needs React 19. @notionhq/client is only needed to fetch blocks with notionic/fetch.
Set up Tailwind CSS
notionic needs Tailwind CSS v4. Import its styles after Tailwind:
@import "tailwindcss";
@import "notionic/styles.css";This one line tells Tailwind to scan notionic's components, and defines Notion's nine colors as --notionic-* variables. Their dark values apply under the dark class, data-theme="dark" or data-mode="dark", and on pages whose color-scheme is dark, so a page that follows prefers-color-scheme gets them by declaring color-scheme: light dark.
Match Notion's colors to your brand
Override the text colors after the import. Each background color (--notionic-red-bg…) is mixed from its text color and your --background, so it follows on its own:
:root,
.dark {
--notionic-red: var(--destructive);
--notionic-blue: var(--primary);
}Mapping to tokens that already switch in dark mode, like shadcn's, keeps both modes right. To make every background stronger or subtler, set --notionic-bg-mix (default 14%, 22% in dark mode); to set one background directly, override its --notionic-*-bg.
Use your own theme tokens
The components use shadcn/ui's color tokens, and Tailwind only generates their classes if your theme defines them. If your design system names them differently, map yours to these eight, after importing notionic:
@theme inline {
--color-background: var(--color-page);
--color-foreground: var(--color-text);
--color-muted: var(--color-surface);
--color-muted-foreground: var(--color-text-subtle);
--color-border: var(--color-line);
--color-ring: var(--color-focus);
--color-primary: var(--color-brand);
--color-primary-foreground: var(--color-on-brand);
}Replace each right-hand side with your own token. notionic needs no others.
Render a page
import { Client } from "@notionhq/client";
import { NotionRenderer } from "notionic";
import { fetchBlocks } from "notionic/fetch";
const notion = new Client({ auth: process.env.NOTION_TOKEN });
export default async function Page() {
const blocks = await fetchBlocks(notion, "your-page-id");
return <NotionRenderer blocks={blocks} />;
}To also get the page's title, icon, cover and properties, use fetchPage(notion, pageId), which returns { page, blocks }.
fetchBlocks fetches every block of the page with its nested blocks, following pagination. It limits requests in flight (concurrency, default 3) and nesting depth (maxDepth, default 5). It doesn't cache: use your framework's cache, such as Next.js's "use cache".
For bookmarks, embeds and link previews, fetchBlocks also fetches the linked page and reads its title, description, image and favicon, so the block renders as a card like a Notion bookmark. A page that's slow (over 5 seconds), unreachable or untitled renders as a one-line card with the URL, and private network addresses are never fetched. Pass bookmarkPreviews: false to make no requests outside Notion.
To preview any other link the same way, use fetchBookmarkPreview(url) from notionic/fetch. It returns the page's title, description, image, favicon and siteName, or null, under the same rules. It doesn't cache either.
Favicons on links
Links to other sites can show the site's favicon before their text, as in Notion. It's off by default: pass linkFavicons: true to fetchBlocks, and linkFavicons to NotionRenderer:
const blocks = await fetchBlocks(notion, "your-page-id", { linkFavicons: true });
<NotionRenderer blocks={blocks} linkFavicons />;fetchBlocks looks each linked host up once, at Google's favicon service and then DuckDuckGo's (which knows subdomains Google misses), with a 3 second timeout, and attaches the icons to the blocks as data URLs (a few KB each), so they're cached with the blocks by whatever caches fetchBlocks and need no requests when the page renders. A subdomain without a favicon gets its parent domain's (you.substack.com shows Substack's), and a site with none at all gets a muted globe. A host the services couldn't answer for (a timeout or an outage) is left out, so it shows no icon and the next fetch asks again; private hosts (localhost, 10.x, *.local) are never sent. Only absolute http(s) links get one: paths on your site (/docs), anchors, mailto links and links to Notion pages never do, so a link to your own site by its full URL shows its icon like any other. To look a host up yourself, fetchLinkFavicon(host) from notionic/fetch returns the data URL or null.
Those services keep their own copies of icons, which can be stale or re-encoded (a rounded icon stored as a white-cornered JPEG). To use the icon each site declares for itself instead, pass linkFavicons: { siteIcons: true }: fetchBlocks then reads the site's home page first and takes its SVG icon, or the smallest PNG of 32px or more (never apple-touch-icon), or its /favicon.ico, and asks the services only when none loads. It's off by default because it fetches URLs the linked sites choose. notionic refuses private addresses, both literal ones and names that resolve to them (where the runtime has node:dns), but a DNS server can answer differently between that check and the request; turn it on only where the server can't reach anything private you need to protect, or where outbound traffic is filtered. fetchLinkFavicon(host, { siteIcons: true }) does the same for one host.
The icons are the sites' own favicons, shown as they are: an app-icon tile (a white square, a dark square) reads as a tile, and a dark logo can be faint on a dark theme. To draw your own icons for some sites, pass components.link and render LinkFavicon for the rest.
You can also pass blocks you fetched yourself: NotionRenderer takes the API's block objects with nested blocks attached as children (the NotionBlock type).
Options
Link Notion pages to your routes
Links to other Notion pages point to notion.so by default. Map them to your own routes with pageHref, or return null to render the text without a link:
<NotionRenderer
blocks={blocks}
pageHref={(pageId) => routes.get(pageId) ?? null}
/>pageId is 32 lowercase hex characters, without dashes.
Replace a block's component
import { NotionRenderer, RichText, type BlockProps } from "notionic";
function Callout({ block, context, children }: BlockProps<"callout">) {
return (
<aside className="rounded-lg border p-4">
<RichText text={block.callout.rich_text} context={context} />
{children}
</aside>
);
}
<NotionRenderer blocks={blocks} components={{ callout: Callout }} />;children holds the block's nested blocks, already rendered.
notion-mermaid loads Mermaid in the browser only on pages with a diagram, follows the dark class, and keeps showing the code until the diagram draws, or if its source doesn't parse. Wrap the code component you use: withMermaid(NotionCode), or withMermaid(defaultComponents.code).
Equations inside text have their own key, inline_equation, whose component gets the equation's LaTeX as expression.
Links inside text have their own key too, link. Its component gets the resolved link (href, and external for other sites), the formatted text as children, context, and with linkFavicons on, the site's favicon (a data URL, null for a site without one, undefined for links that get none). LinkFavicon renders that prop as notionic does: the icon, a globe, or nothing. Wrap NotionLink to keep notionic's styling and new-tab behavior:
import { LinkFavicon, NotionLink, type LinkProps } from "notionic";
function Link({ link, favicon, children }: LinkProps) {
return (
<NotionLink link={link} className="text-primary">
<LinkFavicon favicon={favicon} />
{children}
</NotionLink>
);
}
<NotionRenderer blocks={blocks} linkFavicons components={{ link: Link }} />;Interactive demos
Put your own React components in a page, like the demos in a design engineering blog. Each demo is a component in your project, so it can use anything you install (Motion, Sonner, charts, 3D). In Notion, an embed of a URL ending in /demos/<name> marks where it goes, and its caption goes below it.
// components/demos/morph-button.tsx
"use client";
export function MorphButton({ params }: DemoProps) { /* … */ }
// app/page.tsx
<NotionRenderer blocks={blocks} demos={{ "morph-button": MorphButton }} />;In Notion, add /embed with https://your-site.com/demos/morph-button. Only the path counts, so the same embed renders on localhost and previews. The URL's query reaches the demo as params: ?speed=slow is { speed: "slow" }. Unknown names render as links.
The frame fits the demo. To give it a height, add ?height= in pixels to the embed URL: …/demos/morph-button?height=320 makes the frame 320px tall, border included. Notion's API doesn't send the size you drag an embed to, so resizing it in Notion only changes how it looks there.
To see the demo inside Notion while you write, add a page at /demos/[name] that renders the demo alone:
// app/demos/[name]/page.tsx
export default async function DemoPage({ params }: { params: Promise<{ name: string }> }) {
const { name } = await params;
const Demo = Object.hasOwn(demos, name) ? demos[name] : undefined;
if (!Demo) notFound();
return <main className="grid min-h-svh place-items-center"><Demo name={name} params={{}} /></main>;
}Demos render in DemoFrame, a bordered stage with the caption centered below. Replace it with components={{ demo: MyFrame }}; it gets the demo as children, plus name, caption, height (from ?height=, or undefined) and context. Only the demos a page uses send JavaScript to the browser.
Live embeds
Embeds render as preview cards by default, since many sites refuse to be framed and an embedded page runs its own scripts. Pass embeds to load the sites you trust in a sandboxed iframe, with the caption below:
<NotionRenderer blocks={blocks} embeds={["codepen.io", "figma.com", "loom.com"]} />A listed host includes its subdomains. embeds also takes true (every embed) or a function of the embed's URL. Only http(s) URLs are embedded, and demos take precedence. An embed is 16:9; add ?height= in pixels to its URL to set its height instead, as for demos (the parameter also reaches the embedded page). Don't list your own site: a same-origin page could lift its sandbox.
Embedded pages can report their height by posting { type: "notionic:resize", height } to the parent, and the frame takes that height in pixels (up to 9999). ?height=, or 16:9, is the size until the first message. Only frames on the page and origins embeds allows count; with a function, only the embed URL's own origin, so embed a redirecting URL by its final address. On the embedded site:
new ResizeObserver(([entry]) => {
const height = Math.ceil(entry.borderBoxSize[0].blockSize);
window.parent.postMessage({ type: "notionic:resize", height }, "*");
}).observe(document.documentElement);Footnotes
Notion has no footnote block, so notionic reads them from a section:
- At the end of the page, add a heading named "Footnotes", at any level.
- Under it, write each note as its own paragraph or list item.
- In the text, select the word and link it to its note: on the note, ⋯ › Copy link to block, then paste it as the link.
Links into that section render as numbered superscripts, in reading order. The section renders as a footnote list with links back to each reference. Clicking a footnote in Notion still jumps to the note.
<NotionRenderer
blocks={blocks}
footnotes={{
headings: ["Footnotes", "Referências", "Références"], // default ["Footnotes"]; [] turns footnotes off
showTitle: true, // show the heading above the list instead of a separator
title: "Notes", // instead of the heading's text
}}
/>Headings match ignoring case. The section runs to the next heading of the same or a higher level, or, under a toggleable heading, is the toggle's content. A section nothing links to renders as ordinary content. Notes nothing links to are listed after the numbered ones. Replace the list through components={{ footnotes: MyFootnotes }}. Pass the same option to collectHeadings and blocksToMarkdown, which writes GFM footnotes.
Resources
To list the sources of a page, or any useful links, add a heading named "Resources" at the end of the page, and under it one bulleted or numbered item per link: [font-smooth](https://developer.mozilla.org/…) - MDN on font smoothing. Each item that starts with a link renders as one row: the site's favicon, the linked text, and the rest of the item muted, on one line. The separator you type (-, –, —, : or |) is replaced by notionic's own. The heading stays a heading, in the table of contents.
fetchBlocks fetches the page behind each of those links, as for bookmarks, for its favicon and for a description to show when you wrote none. Links elsewhere on the page are never fetched, and bookmarkPreviews: false turns these fetches off too. With linkFavicons, rows use the favicons it fetched.
const resources = { headings: ["Resources", "Recursos"] }; // default ["Resources"]; [] turns resources off
const blocks = await fetchBlocks(notion, "your-page-id", { resources });
<NotionRenderer blocks={blocks} resources={resources} />;Pass resources only to rename the heading, and pass it to fetchBlocks, NotionRenderer and blocksToMarkdown alike. Headings match ignoring case, and the section runs as for footnotes. Other blocks in the section (an intro paragraph, an item that doesn't start with a link) render as they are. If the heading also names the footnotes section, footnotes win. Replace the list through components={{ resources: MyResources }}, which gets each run of rows as items (href, title, description, and the fetched preview).
Supported blocks
Paragraph (with line breaks), headings 1–4 (including toggleable headings), bulleted, numbered and to-do lists, quote, callout, toggle, divider, equation, code, table, columns, tabs, table of contents, synced block, image, video (YouTube or file), audio, file, PDF, bookmark (a card with the page's title, description and image, from fetchBlocks), embed and link preview (cards like bookmarks, or for an embed one of your demos or a live iframe), and child page and link to page (with their icons and titles, from fetchBlocks). Date mentions render as <time>. Footnotes and a Resources list come from sections of the page (see Footnotes and Resources).
Headings render at their Notion level: heading 1 as <h1>, heading 2 as <h2>, and so on. Headings get ids and anchor links.
Tabs render as real tabs with no JavaScript: a row of pills that scrolls sideways, and one panel at a time. To use shadcn's Tabs component instead, add notion-tabs from the shadcn registry.
Code renders as plain text. For syntax highlighting, use notion-code from the shadcn registry below. Equations, in blocks and inside text, show their LaTeX source; notion-equation typesets them with KaTeX.
Unsupported blocks render nothing, with a warning in development.
Page outline
collectHeadings(blocks) returns the page's headings in reading order, with the same anchor ids the rendered headings get, for a sidebar outline:
import { collectHeadings } from "notionic";
const outline = collectHeadings(blocks); // [{ id: "intro", text: "Intro", level: 1, blockId }]shadcn/ui components
notionic's shadcn registry has versions of some blocks built on your own shadcn/ui components. The shadcn CLI copies them into your project, with the shadcn components they use, so you can edit them.
| Item | Built on |
| --- | --- |
| notion-code | Code Block, highlighted on the server with Shiki, with a copy button |
| notion-callout | Alert |
| notion-table | Table |
| notion-toggle | Collapsible |
| notion-tabs | Tabs, with every panel kept in the HTML |
| notion-equation | KaTeX, rendered on the server: equation blocks and equations inside text |
| notion-mermaid | Mermaid: draws mermaid code blocks as diagrams, wrapping your code component |
Add the registry to your components.json:
{
"registries": {
"@notionic": "https://notionic.dev/r/{name}.json"
}
}Then add the items you want, and pass them to components:
npx shadcn@latest add @notionic/notion-code @notionic/notion-callout @notionic/notion-table @notionic/notion-toggle @notionic/notion-tabs @notionic/notion-equation @notionic/notion-mermaidimport { NotionCallout } from "@/components/notion-callout";
import { NotionCode } from "@/components/notion-code";
import { NotionTable } from "@/components/notion-table";
import { NotionToggle } from "@/components/notion-toggle";
import { NotionTabs } from "@/components/notion-tabs";
import { NotionEquation, NotionInlineEquation } from "@/components/notion-equation";
import { withMermaid } from "@/components/notion-mermaid";
<NotionRenderer
blocks={blocks}
components={{
code: withMermaid(NotionCode),
callout: NotionCallout,
table: NotionTable,
toggle: NotionToggle,
tab: NotionTabs,
equation: NotionEquation,
inline_equation: NotionInlineEquation,
}}
/>;notion-code highlights the languages listed in lib/highlighter.tsx, which comes with shadcn's Code Block. Add a language there to highlight it; others render as plain text.
Markdown
notionic/markdown turns the same blocks into GitHub-flavored Markdown, for RSS feeds, llms.txt and other plain-text uses:
import { blocksToMarkdown } from "notionic/markdown";
const markdown = `# ${page.title}\n\n${blocksToMarkdown(blocks, { pageHref, transformFileUrl })}`;It takes the same pageHref and transformFileUrl as NotionRenderer, so links and files match the HTML. Notion's heading 1 becomes #, heading 2 ##, and so on. Toggles become <details>, equations $…$ and $$…$$.
Uploaded files
Notion's URLs for uploaded files (images, videos, files, icons) expire after an hour, which is shorter than most pages are cached. Pass transformFileUrl to point them at a route of yours that fetches a fresh URL:
<NotionRenderer
blocks={blocks}
transformFileUrl={({ blockId }) => `/api/notion/file/${blockId}`}
/>The route retrieves the block with the Notion API (notion.blocks.retrieve) and redirects to its file's current URL. notionic.dev's route is a working example: apps/web/app/api/notion/file/[blockId]/route.ts. External URLs never go through transformFileUrl.
License
MIT
