Skip to Content

Notion

Publish a Notion database as a docs site.

Pages render on demand, so the site needs a server, unless you prerender them.

For a working project, see examples/notion.

Setup

Create an Integration

Create a Notion integration with permission to read content, then connect your database to it.

Store the Credentials

Store the token and the data source ID in server-only environment variables.

.env.local
NOTION_TOKEN=ntn_...
NOTION_DATA_SOURCE_ID=...

Data source ID

It is not the database ID in the URL. See the Notion upgrade guide.

Install the Packages

npm i @fumadocs/notion @notionhq/client

Add the Styles

Add this after the imports in your stylesheet:

src/app.css
@import "@fumadocs/notion/css/preset.css";

Create the Source

Call createNotion() once. Use the result as the content source, and pass it to the plugin:

press.config.tsx
import { createNotion } from "@fumadocs/notion";
import notionPlugin from "@fumadocs/notion/press";
import { Client } from "@notionhq/client";

const notion = createNotion({
  client: new Client({ auth: process.env.NOTION_TOKEN }),
  dataSourceId: process.env.NOTION_DATA_SOURCE_ID!,
});

export default createPress(
  notion.dynamicSource({
    properties: {
      title: "Name",
      slug: "Slug",
      description: "Description",
    },
    query: {
      filter: {
        property: "Published",
        checkbox: { equals: true },
      },
    },
  }),
  { alwaysRevalidate: true },
).plugins(notionPlugin({ notion }));

alwaysRevalidate shows Notion edits right away. To refresh from a webhook instead, see Refreshing Content.

Customization

Page Properties

Without properties, the source reads:

  • The first Notion title property as the page title.
  • A property named Slug as the URL, or a slug generated from the title.
  • A property named Description, when there is one.

A slug can contain / for nested URLs. Two pages with the same path fail with an error.

URLs

Use baseDir to place pages under a prefix, or generatePath to set each path:

press.config.tsx
notion.dynamicSource({
  baseDir: "docs",
  query: {
    sorts: [{ timestamp: "last_edited_time", direction: "descending" }],
  },
  generatePath(page, info) {
    return info.slugs.length > 0 ? `${info.slugs.join("/")}.mdx` : `${page.id}.mdx`;
  },
});

See the Fumadocs Notion integration for all source options.

Custom Blocks

Add or replace block renderers with components:

press.config.tsx
notionPlugin({
  notion,
  components: {
    callout({ block, children, renderRichText }) {
      return (
        <aside className="rounded-lg bg-fd-muted p-4">
          {renderRichText(block.callout.rich_text)}
          {children}
        </aside>
      );
    },
  },
});

File URLs

Notion file URLs expire after one hour, so the plugin serves them through your site. Turn off this proxy with fileProxy: false only when your content uses permanent external URLs, or a custom renderer copies files to permanent storage.

Prerendering Pages

To deploy to a static host, set mode to force-static. The build reads the database once and prerenders its pages, so edits appear on the next build.

press.config.tsx
export default createPress(notion.dynamicSource(), {
  mode: "force-static", 
}).plugins(notionPlugin({ notion, fileProxy: false }));

The file proxy needs a server, so turn it off. Files hosted by Notion then expire one hour after the build.

Refreshing Content

The source is read once, so new content doesn't appear on its own. Choose when to read it again:

press.config.tsx
createPress(source, {
  alwaysRevalidate: true, 
});

Reading on every request works for low traffic. On a busier site, call the webhook when your content changes.

Options

Prop

Type

Last updated on

On this page