npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tanstack-db-notion-adapter

v0.4.0

Published

Offline-first, end-to-end typed TanStack DB collections backed by Notion data sources

Readme

TanStack DB Notion adapter

Build fast, offline-capable apps with TanStack DB while keeping Notion as the source of truth. Reads and writes happen locally first, then sync through your server with generated end-to-end types.

Best for private tools that people actively use: journals, task managers, lightweight CRMs, and small-team workflows.

Quick start

1. Install and generate a schema

npm install tanstack-db-notion-adapter @tanstack/react-db
npx tanstack-db-notion init

init asks for your PAT and a Notion database link or ID. It stores the server configuration in .env.local and creates:

  • notion.schema.json — the checked-in schema contract
  • notion.generated.ts — the generated TypeScript schema and types

Keep the PAT server-only. Do not expose it to browser code.

2. Apply the schema

Preview, then apply, the one-time Notion schema setup:

npx tanstack-db-notion push --dry-run
npx tanstack-db-notion push

push adds the visible Client ID rich-text property when it is missing. The adapter uses that property as the stable client-owned key, which lets offline inserts reconcile after retries instead of creating duplicate Notion pages.

3. Add the server route

The Notion PAT must never enter the browser. Mount one handler and expose both GET and POST at the same path:

// app/api/journal/route.ts — server
import {
  createMemoryNotionIdempotencyStore,
  createNotionSyncHandler,
} from 'tanstack-db-notion-adapter/server'
import { notionDataSourceSchema } from '../../../notion.generated'

const sync = createNotionSyncHandler({
  token: process.env.NOTION_PAT!,
  schema: notionDataSourceSchema,
  idempotencyStore: createMemoryNotionIdempotencyStore(),
  dangerouslyAllowUnauthenticated: true,
})

export const GET = sync
export const POST = sync

This is the shortest local-development route. See production recommendations when deciding how to deploy it.

4. Create the collection

// data/journal.ts — client
import { createCollection } from '@tanstack/react-db'
import { notionCollectionOptions } from 'tanstack-db-notion-adapter'
import { notionDataSourceSchema } from '../notion.generated'

export const entries = createCollection(
  notionCollectionOptions({
    id: 'journal',
    endpoint: '/api/journal',
    schema: notionDataSourceSchema,
  }),
)

Next.js users should mount the collection below a client-only boundary; see framework integration.

5. Read it with useLiveQuery

// Journal.tsx — client
import { useLiveQuery } from '@tanstack/react-db'
import { entries } from './data/journal'

export function Journal() {
  const { data = [] } = useLiveQuery((query) =>
    query.from({ entry: entries }),
  )

  return data.map((entry) => <article key={entry.id}>{entry.title}</article>)
}

Mutations are optimistic and persist offline immediately:

entries.insert({ title: 'Today' })
entries.update(entry.id, (draft) => {
  draft.title = 'A better title'
})
entries.delete(entry.id)

The generated file also exports NotionDataSourceSchemaInput for inserts and update helpers, and NotionDataSourceSchemaRow for fetched records. The input type omits read-only Notion metadata.

Production recommendations

Choose the safeguards that fit how your app is deployed:

  • Protect the sync route. Add authorize or enforce access before the handler when the endpoint should not be public. See authentication.
  • Use shared durable idempotency for multi-instance servers. This keeps retries handled consistently across processes. See durable idempotency stores.
  • Surface blocked mutations when users need recovery controls. tx.isPersisted means the mutation is durable on this device, not that Notion accepted it. See errors and recovery.
  • Scope browser storage before supporting account switching. Resolve an opaque stable account/workspace ID before constructing either client, then pass it as storageScope. See authentication.

Mutations and recovery

This adapter differs from a server-confirmed TanStack DB workflow: it writes local mutations into the synced base itself and resolves the mutation handler once the write is durable locally. tx.isPersisted therefore means “saved on this device,” not “saved in Notion.” A server rejection never rejects the transaction and there is no automatic rollback. Observe failures through sync state and recover them explicitly.

Use the returned TanStack transaction as an adapter receipt when one product action needs an exact remote outcome:

const tx = entries.update(entry.id, (draft) => {
  draft.title = 'A better title'
})

await tx.isPersisted.promise // durable locally
const result = await entries.utils.awaitRemote(tx)
// result.status: 'synced' | 'blocked' | 'cancelled'

awaitRemote observes the FIFO outbox; it does not bypass earlier work or force a synchronization attempt. getRemoteTransactionStatus(tx) provides the non-blocking form and reports bounded-chunk progress for bulk transactions. The latest 100 terminal receipts are durable by default.

Undo may cancel a transaction only before any of its chunks begins delivery:

await entries.utils.cancelRemoteTransaction(tx)

Cancellation atomically rolls back its optimistic rows and rebases later pending updates. If delivery was attempted or acknowledged, enqueue an ordinary inverse collection mutation instead.

Use blockedMutation from useNotionSyncState to show when the FIFO outbox head needs attention:

import { useNotionSyncState } from 'tanstack-db-notion-adapter/react'
import { entries } from './data/journal'

export function SyncNotice() {
  const sync = useNotionSyncState(entries)
  const blocked = sync.blockedMutation
  if (!blocked) return null

  return (
    <aside>
      <p>Could not save changes: {blocked.error.message}</p>
      <button
        onClick={() =>
          void entries.utils.retryPendingMutation(blocked.entryId)
        }
      >
        Retry
      </button>
      <button
        onClick={() =>
          void entries.utils.discardPendingMutation(blocked.entryId, {
            acceptDataLoss: true,
          })
        }
      >
        Discard local change
      </button>
    </aside>
  )
}

blockedMutation is populated only for a non-retryable failure at the head of the outbox. Transient failures remain retryable and do not populate this field. See errors and recovery for conflict handling and durable recovery.

Sync state also reports integrity, lastFullReconciledAt, and nextFullReconciliationAt. Incremental polling finds edited rows but cannot prove that remotely deleted or newly filtered-out rows are gone. Call collection.utils.fullReconcileNow() when the UI needs an explicit integrity refresh; syncNow() remains a compatible alias for a full reconciliation.

When a row was deleted remotely while its local update was pending, resolve the blocked head without throwing away the edit:

await entries.utils.resolveDeletedMutation(blocked.entryId, {
  action: 'recreate',
})
// Or explicitly discard the local row and edit:
await entries.utils.resolveDeletedMutation(blocked.entryId, {
  action: 'discard',
  acceptDataLoss: true,
})

Overlapping property edits include the affected row key, mutation index, and base/local/remote values. Keep every local value and atomically rebase the remaining FIFO outbox without reloading the page:

await entries.utils.resolvePropertyConflict(blocked.entryId, {
  action: 'keep-local',
})

Use action: 'accept-remote' with acceptDataLoss: true to accept every Notion value, or see errors and recovery for mixed field-by-field resolution.

Page contents

Database properties and page bodies are separate in Notion. For journals and notes, enable pageContent: true on the server and create a content client:

// data/journal-content.ts — client
import { createNotionPageContentClient } from 'tanstack-db-notion-adapter'
import { entries } from './journal'

export const entryContent = createNotionPageContentClient({
  id: 'journal',
  endpoint: '/api/journal',
  collection: entries,
})

Attach an existing row before rendering its body. The hook subscribes to local state; attachPage performs the initial fetch:

// Editor.tsx — client
import { useNotionPageContent } from 'tanstack-db-notion-adapter/react'
import { useEffect } from 'react'
import { entryContent } from './data/journal-content'
import type { NotionDataSourceSchemaRow } from './notion.generated'

export function Editor({ entry }: { entry: NotionDataSourceSchemaRow }) {
  const content = useNotionPageContent(entryContent, entry.id)

  useEffect(() => {
    if (entry.notionPageId) {
      void entryContent.attachPage(entry.id, entry.notionPageId)
    }
  }, [entry.id, entry.notionPageId])

  return (
    <textarea
      value={content?.markdown ?? ''}
      disabled={!content}
      onChange={(event) => {
        void entryContent.update(entry.id, event.target.value)
      }}
    />
  )
}

Create a durable body before inserting a new row so both can flush after an offline session:

const id = crypto.randomUUID()
await entryContent.createDraft(id, '# Today')
entries.insert({ id, title: 'Today' })

If creation is cancelled, remove the durable local body explicitly. This does not change an attached Notion page:

await entryContent.discardDraft(id, { acceptDataLoss: true })

The hook watches only the open page. It revalidates that page on window focus and every 60 seconds without refetching every page body.

See page-content sync for creating drafts, debounced writes, direct Notion edits, webhooks, and conflict handling.

Schema commands

The CLI automatically loads .env.local, .env, and notion.schema.json, then regenerates notion.generated.ts:

npx tanstack-db-notion push --dry-run  # preview local changes
npx tanstack-db-notion push            # apply local changes to Notion
npx tanstack-db-notion pull            # accept changes made in Notion
npx tanstack-db-notion check           # detect all remote drift in CI

Advanced paths, non-interactive setup, --out, and destructive changes are covered in the schema workflow.

Limits and unsupported behavior

  • A row deleted in Notion can remain locally until the next full reconciliation, up to the configured interval (one hour by default).
  • More than 10,000 matching rows in one data source is unsupported; Notion truncates pagination at that boundary. See large data sources.
  • If a page is deleted in Notion while a local edit is pending, that mutation blocks until you explicitly recreate the row from the local value or discard it with data-loss acknowledgement.
  • Concurrent edits to the same property surface as property_conflict. There is no automatic semantic merge; resolve the conflict explicitly.
  • Page bodies are Markdown-only. Pages containing content the adapter cannot represent are read-only through the page-content client.
  • Files and offline media are not cached. Notion-hosted file URLs expire.
  • Page-content drafts for deleted rows are not pruned from local storage.

Examples

  • examples/todos — typed properties, optimistic mutations, local filtering, and pagination
  • examples/notes — lazy page bodies, debounced writes, direct-Notion refresh, and conflicts
  • examples/reliability — executable failure and recovery scenarios

Local queries stay fast across thousands of cached rows. Notion limits one data-source query to 10,000 matching pages; the adapter fails closed at that boundary instead of silently returning partial data. See large data sources for filtering, progressive reads, bulk-write costs, and refresh budgets.

Prompt for coding agents

Replace the placeholders and give this to a trusted local coding agent:

Use https://github.com/brianlovin/tanstack-db-notion-adapter and TanStack DB to
build a simple, minimal journaling app using a Notion database as my data
storage.

My Notion PAT is: MY_PAT
My Journal database is: NOTION_DATABASE_URL

Keep the PAT server-only in .env.local. Use the adapter's generated schema,
authenticated server route, offline collection, and page-content client.

The repository includes an agent skill with the complete setup and safety workflow.

License

MIT