Skip to content
Notion Arena

Notion platform

Two-way data sync

Widgets have always been configurable by URL and persisted by themselves. That is one-way and per-browser. This is the other half: an instance bound to a database or page in your own workspace, where the rows are the state, an edit in either place lands in the other, and the same rows appear on the next device you open it on.

Try it, without a Notion token

Everything below runs against a demo workspace that implements the same adapter interface as the Notion API, including its rate limits and its Free-plan block cap. Two renderers are shown side by side because that is the honest picture of the design: the left frame is the hosted embed, the right frame is the downloaded artifact running the way Notion runs an HTML block, in a sandbox with an opaque origin and no network at all.

1

Connect a workspace

Nothing connected yet. The demo workspace needs no credentials and implements the same adapter interface as the Notion API, including rate limits and the Free-plan block limit.

Or paste a Notion token (PAT or internal connection)

The token is validated by reading its own identity, sealed with AES-256-GCM and stored server-side. It is never placed in a widget URL: the widget gets a scoped instance key instead.

2

Bind a component

0 of … widgets have a first document

Nothing matches “”. The catalog has 0 widgets.

equipment-inventory

equipment-inventory
6

Answer the open questions on your own workspace

Four things cannot be settled from documentation, only from a real Notion page (research §8). The probe file measures the sandbox it is running in and draws the answer:

  1. Does an HTML block allow localStorage, or is the origin opaque? (Q1)
  2. Does the embed frame permit popups and clipboard writes? (Q2)
  3. Does PATCH /v1/blocks/{id} swap an embed's file in place? (Q3, answered by the refresh button above on a real connection)
  4. Does the mobile app render an HTML block where it refuses embeds? (Q4, open the page on a phone)

Ten components, bound for real

Ten components are wired, not illustrated: the playground above binds any of them in one click, and each one carries a schema that has been driven through the whole loop by npm run check:components (138 assertions, one component at a time: bind, write, find the row, edit the row in Notion, find the value in what a device renders, trash the row, find the tombstone). They were picked the way a test suite is picked, so between them they cover every state shape the other 182 widgets use and every property type the mapping layer can write.

ComponentWhat it provesColumns
Habit Heatmap
try it
records
State is {habit: {date: level}}, so inference cannot read it. Declared, and the key is the habit and the day: two ticks in one cell are one row, which is what the grid already means.Habit* · Date* · Level
Expense Tracker
try it
records
An array of flat records, declared rather than inferred: Amount is a number column (sums and rollups work), Category is a select with the widget's own options, Date is a date.Description* · Date* · Amount · Category
Chore Checklist
try it
records
Done is a checkbox, not the string "true". The key is the chore and the day, so the same chore on two days is two rows and ticking it in Notion ticks it on every device.Task* · Day* · Assignee · Done
Action Item Tracker
try it
records
State is one of three values the widget knows about, so it becomes a select with exactly those options. The key is the task and its due date.Task* · Due* · Owner · State
Bookmark Library
try it
records
URL is a real url property, so the cell is clickable in Notion instead of being text that looks like a link. Tag is a select, opened up by the first write if it is a new one.Name* · URL* · Tag
Daily Journal
try it
records
The long-text case: the widget allows 3,000 characters, Notion holds 2,000 per rich text object, so a long entry is written as several objects in one cell and read back whole.Date* · Title · Body
Grocery List
try it
kv-blob
inferred
State-blob mode, and honestly so: the widget keys its lists by a hash of the label, so a record schema would produce a List column full of values no person can read. One row holds the document, with a revision and an updated stamp.Arena key · State · Rev · Updated
Invoice Tracker
try it
records
One of the 84 widgets generated from a SPEC, so its columns come from the widget's own field list: Amount is a number, Issued and Due are dates, State is a select over Draft, Sent, Paid, Overdue, Void.Invoice alias · Client alias · Amount (USD) · Issued · Due · Note · State · Arena key*
Equipment Inventory
try it
records
SPEC family with two selects: the provisioned table offers the same options the widget's dropdown does, because the vocabulary travels with the binding instead of being invented at the first write.Equipment alias · Location alias · Category · Condition · Quantity · Notes · Status · Arena key*
Incident Log
try it
records
The checklist case: five fixed steps per incident, stored in Notion as a multi_select of the steps that are done. The labels are fixed by the widget, so nothing is lost and the column is still queryable.Summary · System alias · Severity · Detected · Owner alias · Impact · State · Checks · Arena key*

The star marks a key field. A row is identified by its key columns and nothing else, which is why those columns matter more than they look: a widget that mints its own id (the SPEC family, so the three on the bottom row) gives every record an identity, so two invoices that happen to look identical are two rows. A widget that does not gives its records a key built from their own fields, so two identical expenses on the same day are one row. Each component says which of the two it is, above and in the playground, instead of leaving a user to find out by losing a row.

The ten are curated, not the limit. The palette above lists all 192 widgets, and 147 of them can be bound right now: 10 carry a hand-written first document, 81 are generated from a field list the widget itself carries, and 56 were measured by running the widget headlessly and asking it what it persists —45 persist nothing a table could hold, and the palette says so on the card instead of offering a button that would create an empty table.

Two commands keep that number honest, and both need npm run dev:

  • npm run check:coverage — binds every widget that can produce a first document (147 of them), provisions its table, uploads the document, reads it back and compares, then pushes the same document again to prove the second push adds no row. 585 assertions, and the 45 widgets it cannot bind are listed by name rather than skipped in silence.
  • npm run check:components — the ten above, driven the long way: write from the widget, find the row, edit the row in Notion, find the value in what a device renders, trash the row, find the tombstone. 138 assertions.

Of the 166 widgets that persist state, ten do not use the notion-arena.<slug>.v1 key they look like they should, and the bind reads the key out of the file rather than assuming it, because adopting the wrong key is invisible until a device renders empty.

The model, in four sentences

  • Notion is the source of truth. Arena stores bindings, a sealed instance key, and a cache. Delete Arena and the data is still a database in your workspace, readable by any Notion client.
  • An instance is one component, in one configuration, bound to one data source. It has an id and a capability key, and both live in the URL, so identity travels with the link and never depends on cookies or browser storage.
  • A renderer is anything that draws that instance: the hosted embed, the HTML block, a standalone page, an integrator's site. Every renderer is the same file with a different transport.
  • Widgets keep their own state shape and their own code. The mapping layer, not the widget, decides whether that shape becomes typed rows (records) or one JSON cell (kv-blob).

Two drawings carry this section better than prose does: figure 01 is the problem, three devices and three separate copies with no channel between them, and figure 02 is the shape that replaces it. Both are in the gallery, with the rest of them.

The question that decides everything else
Can a widget inside Notion reach the network? In an HTML block, no. Its sandbox has no network, so there is no fetch, no SSE, and no third-party localStorage to fall back on. Any design that pretends otherwise ships a component that silently loses data on Safari and on every browser that partitions storage. The offline transport is therefore not a degraded mode bolted on at the end: it is the reason the live pull exists at all.

Two channels, one instance

The same instance is reachable two ways. They differ in transport and in what the browser is allowed to do, not in what they show.

Embed blockHTML block
Transportlive: fetch on open, SSE for pushes, 20 s poll as fallbackoutbox: reads the baked snapshot, queues ops locally
Identityinstance id and key in the embed URLsame key, baked into the file at publish time
Storagefirst-party to Arena, so the cache and the outbox workopaque origin: <code>localStorage</code> throws, memory only
Writespushed to the workspace adapter immediatelyqueued, then handed off by sync code or refreshed server-side
Refreshnew revision arrives in place, no reloadserver re-renders and PATCHes the block's file in place
One instance, two renderers, one system of record The embed calls home on every render. The file Notion runs cannot reach the network at all, so it queuesinstead, and Arena rewrites the file when the data moves. Your page in Notionthe page owns the frame, and the frame is the sandboxembed block/embed/habit-grid?i=inst_8f2a&k=sk_…networkHTML blockhabit-grid-2a9c.html, a file we can rewriteno networkArenathe only party holding a Notion tokenbindings index1 instance, 1 data sourcesealed credentialsAES-256-GCM at restchange log, receiptsop ids, so replays areno-opssync engineone rate-limit bucket perconnectionYour workspacethe rows are the state; Arena only caches themHabitDateLevelArena keywater2026-10-115water|2026-10-11water2026-10-123water|2026-10-12read2026-10-121read|2026-10-12Habit Heatmap log, one database per bindingwebhookids only, HMAC-SHA256403the Free plan's capPublish: the file is re-rendered and PATCHed into theblock, so the offline reader refreshes in place.fetch + SSEkey in the URL, never a cookieno network in the sandboxwrites wait in an outboxNotion API writesthe outbox drains, one row at a timethe webhook says a row movedids only, so the engine re-readsTwo renderers, one instance: the same id, the same document, the same key. routes, limits and payload shapes as implemented in app/api/sync/** and lib/sync/**

The sync code

An offline renderer cannot reach Arena, but a person can carry text between two machines, so that is the bridge. The bridge exposes a sync code: instance id, base revision, and the queued operations, encoded and shown as a short string. Paste it into any reachable renderer on the same instance and the ops are pushed from there, with the same idempotency keys they were born with, so replaying the same code twice cannot double-write.

ARC1.eyJ2IjoxLCJpIjoiaW5zXzAxaDkiLCJiYXNlIjo0MSwib3BzIjpbeyJpZCI6Im9wXzdmMiIsImtleSI6IndhdGVyIiwicGF0aCI6WyIyMDI2LTEwLTEyIl0sInZhbHVlIjoyLCJhdCI6MTc2MDMyMTA0Mn1dfQ

The server-side refresh is the other half: the publish endpoint re-renders the instance with the current revision and swaps the block's file through a single PATCH /v1/blocks/{id} with a fresh file upload, so an offline reader picks up changes without an extra block appearing on the page every time.

Binding a component to your data

Binding answers two questions: which data source, and how does the widget's state become rows. The mapping layer infers the second by default, from the sample document in the widget's own registry entry, and a declared schema overrides it where inference guesses wrong.

ModeState shapeIn NotionExample
recordsarray of flat recordsone row per record, one property per field, inferred typesexpense tracker → Date / Amount / Category / Description
records (declared)nested but rectangularone row per leaf, with the keys promoted to propertieshabit heatmap → Habit / Date / Level
kv-blobnested, arbitraryone row per instance, whole document as JSON in a rich_text cellgrocery list → Component / Key / State / Rev / Updated

A record's identity is a business key, not a row id: the widget says "water" and "2026-10-12" and the engine finds the row that matches. Renaming a component or re-binding it to a different database therefore does not orphan data, and a person editing the table by hand cannot break the link by touching a hidden column.

A click, and the row it becomes One cell in the widget and one row in the table are the same fact. This is the work lib/sync/mapping.tsdoes, and the shape of the change that goes back to every renderer. The widgetstate, as it saves itwater · 2026-10-12set to 3notion-arena.habit-grid.v1The runtime adopted thiswidget's own key by patchingStorage.prototype, so thewidget needed no edit.One operationwhat crosses the networkidop_7f2a9c81pathwater.2026-10-12value3at1786012345678baseRev5The id is minted by the clientso a retry, or a replayed synccode, cannot write the row twice.The path is where it landsin the widget's own document,derived from the business key.The row in NotionHabit Heatmap logHabitDateLevelwater2026-10-115water2026-10-123read2026-10-121Arena keywater|2026-10-12rich_text, hidden in the UISourcewidget · notionA person can retype the key; theengine treats their edit as the truth.1 op1 rowComing backthe engine sends the delta and the document, because a row is not a locationeach renderer replaces its state; its own diff decides what to repaint, so a heatmap never has to understand a business key lib/sync/mapping.ts: businessKey, pathForKey, toPropertyPayload

Conflicts

Two writers can touch one field within the same minute, and the workspace is the only place that knows what actually happened, so the rule is per-field last-writer-wins ordered by last_edited_time, with our own monotonic counter breaking ties. Nothing is silently dropped: the losing value is written to an append-only conflict log and shown as a notice in the widget. Event-shaped data is the exception, where the right answer is keep-both rather than last-writer-wins.

Two writers, one cell Who edited what while the other side was not looking. The policy is per field: the later edit wins, and theloser is written down rather than thrown away. Notionwater|2026-10-11Level2the row nowYour phoneset it to 2, offlineop_4b71 · queued10:02:14Your laptopset it to 5op_1c9a · baseRev 410:02:31water|2026-10-11one cellBoth writers meant this one record, and neither of them knew about the other.1Order by timelast_edited_time decides, and our own counter breaks a tie inside one round, so 2 wins2Write onceone row is updated: no duplicate, no second record for the same habit and day3Keep the loserthe value that lost goes into the append-only conflict log and shows as a noticekeep-both is the other policy, used for event-shaped data: two records, both kept, neither silently merged. lib/sync/engine.ts: materialise and the conflict policy; lib/sync/types.ts: ChangeRecord.conflict

Connections and credentials

Two ways in, because they fail in different places. The public connection (OAuth) is the default for anything a user gives to other people: it can create databases anywhere the user installs it, and it survives nothing on our side, which is the point. A pasted token (an internal connection or a personal access token) is the escape hatch for local work, air-gapped installs and self-hosting.

Public connection (OAuth)Pasted token (PAT / internal)
IdentityInstallation in a workspace, per-user grantsA bot identity inside one workspace
Can create at workspace levelYes, which is what a binding needsYes
LifetimeUntil the user revokes itExpiry chosen at creation, 7 days to 1 year
List all usersYes, when grantedNo
Used here forThe default path, and the marketplace listingLocal development, offline and enterprise setups

A user's Notion token is never placed in a widget URL. It is validated on the way in by reading its own identity, sealed with AES-256-GCM, and stored server-side. What reaches the browser is a capability key scoped to one instance, revocable, rotatable, and useless against anything else in the workspace.

What the platform will refuse

Three limits shape the design, and each one is handled rather than retried.

  • The Free-plan block cap. A multi-member workspace on Free has a lifetime block allowance, and deleting blocks does not give it back. The API says so precisely: a 403 restricted_resource carrying additional_data.block_limit. Retrying will never succeed, so the engine stops and explains. Personal access tokens and any-workspace OAuth installations are exempt.
  • Rate limits per connection. 180 requests per minute on Free and Plus, 600 on Business and above, shared across the whole workspace, with Retry-After that can exceed a minute. Every adapter call goes through one token bucket, and only 429 and 529 are retried, because those are the only two that mean "later".
  • Record size. A page that gets too large is rejected outright, so a blob-mode instance keeps a size ceiling and warns well before it, instead of an unexplained 400 on the next save.
Webhooks are a hint, not a feed
Notion sends ids, never content, at most once, aggregated, up to about five minutes late, and the subscription URL is immutable once verified, which is why a local development server needs a simulator instead of a tunnel. So the webhook only says something changed in this data source; the engine re-reads the changed rows and reconciles. A missed webhook costs latency, never data.

The endpoints

RouteMethodWhat it does
/api/sync/connectionsGET, POSTList connections; connect the demo workspace or store a validated token
/api/sync/datasourcesGETCandidate data sources to bind to, with their property names and types
/api/sync/instancesGET, POSTList by component; create an instance, returning the sync key exactly once
/api/sync/instances/{id}GET, PATCH, DELETESummary, change log and events; re-bind or rename; rotate or revoke the key
/api/sync/instances/{id}/syncPOST, GETThe endpoint both renderers read and write through
/api/sync/instances/{id}/streamGETServer-sent revisions and events for the live renderer
/api/sync/instances/{id}/bundleGET, HEADThe offline artifact, and the same artifact as a download
/api/sync/instances/{id}/publishPOSTInstall or refresh the HTML block in the user's workspace
/api/sync/notion/tableGETThe bound table read back out of Notion, for verification
/api/webhooks/notionPOST, GETSignature-verified webhook fanout into the change log
/api/dev/notionPOSTDevelopment-only simulator for edits made by a person in Notion

/embed/{slug} stays what it always was, and gains the bound form: ?i=<instance>&k=<key> renders that instance live, no key renders the query-string configuration as before.

Still open, and how to close it

Four questions cannot be answered from documentation, because they depend on which Notion client is rendering the page. The probe file measures the environment it is running in, so it answers them from inside a real workspace: upload it as an HTML block, or open the URL in an embed block, and read the table.

QuestionHow to answer it
Does an HTML block allow localStorage, or is the origin opaque?The probe reports its own origin and a storage round trip
Does the embed frame permit popups and clipboard writes?The probe offers both, since real widgets use them for export
Does PATCH on a block swap an embed's file in place?The publish button above counts refreshes
Does mobile render an HTML block where it refuses embeds?Open the same page on a phone

The whole loop, one write end to end, is figure 03; the identity that makes the same instance render the same rows on a second device is figure 06. All six are collected at the sync in figures, each with a PNG to take away.

The deeper research, including the direct citations for every platform limit quoted above and the survey of what two-way-sync vendors do and do not do, is in docs/DATA_SYNC_RESEARCH.md. The phased build order is in docs/SYNC_PLAN.md.