@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.
createRequestLimitercaps requests in flight and keeps retrying rate limits (waiting for Notion'sretry_after) and timeouts. PasscreateTimeoutFetch()to the client so a request whose response stalls is aborted and retried as a new one: the client's owntimeoutMsstops at the headers, so a stalled body would otherwise hang a build. - Whole pages in one call.
fetchBlockTreereturns a page's content with nested blocks underchildren, 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.
createFileHandleris 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.
queryDatabaseandgetPropertyText/getPropertyNames/getPropertyDate.
No React, no framework: everything is plain functions and a Web-standard Request → Response handler.
Install
npm install @yokinist/notion-fetch @notionhq/clientWorks 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
| Function | Returns |
|---|---|
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 |