notion-kit

@yokinist/notion-fetch

Rate-limit-safe requests, whole pages in one call, and files that do not expire.

Small helpers for building sites on the official Notion API (@notionhq/client):

  • Rate-limit safe requests. Notion allows about 3 requests per second per integration. Statically generating many pages at once hits that quickly, and the official client gives up after a couple of retries. createRequestLimiter caps requests in flight and keeps retrying rate limits (waiting for Notion's retry_after) and timeouts. Pass createTimeoutFetch() to the client so a request whose response stalls is aborted and retried as a new one: the client's own timeoutMs stops at the headers, so a stalled body would otherwise hang a build.
  • Whole pages in one call. fetchBlockTree returns a page's content with nested blocks under children, following pagination.
  • Files that do not expire. Files uploaded to Notion get signed URLs that expire after an hour, so pages cached by a CDN or ISR end up with broken images. createFileHandler is a request handler you link to instead: it redirects to a fresh URL and lets the CDN cache the redirect. No image optimization service needed.
  • Database rows and property values. queryDatabase and getPropertyText / getPropertyNames / getPropertyDate.

No React, no framework: everything is plain functions and a Web-standard Request → Response handler.

Install

npm install @yokinist/notion-fetch @notionhq/client

Works with @notionhq/client 5.x (Notion API version 2025-09-03, with data sources). CI tests the oldest and newest 5.x releases, and checks each new release weekly.

Create an internal integration, and share your database with it (database page → ⋯ → Connections).

Usage

import { Client } from "@notionhq/client";
import {
  createRequestLimiter,
  createTimeoutFetch,
  fetchBlockTree,
  getPropertyDate,
  getPropertyText,
  queryDatabase,
} from "@yokinist/notion-fetch";

const notion = new Client({
  auth: process.env.NOTION_TOKEN,
  fetch: createTimeoutFetch(), // aborts requests that stall, body included
});
const limit = createRequestLimiter(); // share one per process

const rows = await queryDatabase(notion, process.env.NOTION_DATABASE_ID, { limit });
const posts = rows.map((row) => ({
  id: row.id,
  title: getPropertyText(row.properties.title),
  date: getPropertyDate(row.properties.date),
}));

const blocks = await fetchBlockTree(notion, posts[0].id, { limit });

Files

Serve the handler at a route whose last path segment is the block ID, and point <img>, <video> and links at it:

// Next.js: app/api/notion/file/[id]/route.ts
import { Client } from "@notionhq/client";
import { createFileHandler } from "@yokinist/notion-fetch";

export const GET = createFileHandler({
  notion: new Client({ auth: process.env.NOTION_TOKEN }),
});
<img src={`/api/notion/file/${block.id}`} alt="" />

It works for image, video, audio, file and PDF blocks; external URLs are redirected as they are. The redirect is cached by the CDN for 40 minutes by default (cacheSeconds), well within the URL's one-hour lifetime. The same handler works with Hono, Cloudflare Workers and anything else that speaks Request/Response.

Sharing work between pages

When pages are generated in parallel, createDedupe lets them share one fetch of the same data:

import { createDedupe } from "@yokinist/notion-fetch";

const dedupe = createDedupe();
const rows = await dedupe(`rows:${databaseId}`, () =>
  queryDatabase(notion, databaseId, { limit }),
);

A failed fetch is not kept, so the next caller tries again.

API

FunctionReturns
createRequestLimiter({ concurrency = 2, maxRetries = 10, timeoutMs = 30_000 })limit(send): runs send within the cap, retrying 429s and attempts slower than timeoutMs (body included). Wrap one request per call
createTimeoutFetch({ timeoutMs = 30_000 })a fetch for new Client({ fetch }) that aborts each request, body included, after timeoutMs. Its signal also stops Next.js from sharing a stalled GET with the retry
fetchBlockTree(notion, pageId, { limit })NotionBlock[]; nested blocks under children. Child pages and databases are not descended into
queryDatabase(notion, databaseId, { limit })every row (PageObjectResponse[]) of the database's first data source
createFileHandler({ notion, cacheSeconds = 2400, limit })(request: Request) => Promise<Response>
createDedupe()dedupe(key, fetch)
getPropertyText(property)text of title, rich text, select, status and url properties
getPropertyNames(property)option names of a multi-select property
getPropertyDate(property)start of a date property

On this page