Skip to content
Notion Arena

How it works

Three ways a component gets into your page

Notion does not have a plugin system, so there is no single install path. There are three, each with different powers and different failure modes. This page explains all three honestly, including the things none of them can do.

Three delivery channels, an embed link, an uploaded file and a script of API calls, converging on one Notion page and its database.
the artifact

One artifact, deliberately

A widget is a single .html file: inline CSS, inline JavaScript, no dependencies, no CDN, no build step, no webfont requests. It reads its configuration from a JSON script tag or from the URL, renders itself, and never contacts a server again.

That single file is what makes both hosted and offline delivery possible without the two versions drifting apart. The /embed URL streams it with your parameters appended to the query string. The download endpoint streams the same bytes with your parameters baked into the file, because a sandboxed block has no URL to read them from.

the same source, two deliveries
Hosted
GET /embed/pomodoro-timer
      ?work=25&short=5
      &accent=%23e03e3e

→ streams pomodoro-timer.html
→ /embed block iframes it
→ we can ship fixes to you
Downloaded
GET /api/widget/pomodoro-timer
      ?work=25&short=5

→ same HTML, config injected as
  <script id="bk-config">
→ /html block renders it
→ works offline, forever
public/widgets/pomodoro-timer.html. One file, 100% self-contained
channel: embed

Embed URL

The oldest trick in the book, and still the fastest. Notion's /embed block iframes any URL that allows framing. We serve widgets with Content-Security-Policy: frame-ancestors * and no X-Frame-Options, so Notion can wrap them.

  1. 1
    Copy the URL from any component page

    The configurator writes every control into the query string as you change it, so the URL you copy is the URL you configured.

    https://notionarena.vercel.app/embed/pomodoro-timer?work=25&short=5&accent=%23e03e3e
  2. 2
    In Notion, type /embed and press Enter

    Choose “Embed link”, not “Upload”, which is the HTML block path.

  3. 3
    Paste and resize

    Drag the block's bottom edge to set the height. Notion remembers the size per block, and the widget's layout adapts.

💡What you get
Zero setup. Works on the Free plan. No integration, no token, no shared page. The widget keeps receiving our updates, so a bug fix reaches every page it is on without you re-installing anything.
⚠️What it costs you
The block depends on our host being up. If we disappear, the block goes blank. It also means the widget's markup travels over the network on every page load, which matters if your workspace has strict content policies. For anything you are selling or archiving, download the HTML block instead.
channel: html block

Native HTML block

Notion 3.6 shipped a real HTML block. It is not an iframe of a URL. it is a file you upload, stored in Notion's own file storage and rendered in a sandboxed frame with no address. The /html slash command creates one by hand, and the Notion API creates one through the File Upload endpoints.

Because the sandbox has no URL, configuration cannot come from a query string. It is injected into the file at download time as a JSON script tag that the widget reads before it renders:

<!-- injected just before </head> -->
<script id="bk-config" type="application/json">
  {"work":25,"short":5,"accent":"#e03e3e","theme":"auto"}
</script>

Installing one with the API

Three requests. This is the flow Notion documents for HTML blocks and the one Notion MCP uses internally. It just is not obvious from the block reference, because the embed block type does the double duty.

1POST
/v1/file_uploads

Reserve an upload. Responds with an id, an upload_url and a size limit.

2POST
{upload_url}

PUT the bytes straight to Notion's storage host with content-type: text/html.

3PATCH
/v1/blocks/{parent}/children

Append an embed block whose file_upload.id points at the upload.

# 1: reserve
curl -X POST https://api.notion.com/v1/file_uploads \
  -H "Authorization: Bearer $NOTION_TOKEN" \
  -H "Notion-Version: 2026-03-11" \
  -H "Content-Type: application/json" -d '{}'

# 2: upload the bytes to the returned upload_url
curl -X POST "$UPLOAD_URL" \
  -H "Content-Type: text/html" \
  --data-binary @pomodoro-timer.html

# 3: append the block
curl -X PATCH \
  https://api.notion.com/v1/blocks/$PAGE_ID/children \
  -H "Authorization: Bearer $NOTION_TOKEN" \
  -H "Notion-Version: 2026-03-11" \
  -H "Content-Type: application/json" \
  -d '{
    "children": [{
      "object": "block",
      "type": "embed",
      "embed": {
        "file_upload": { "id": "'"$UPLOAD_ID"'" },
        "caption": [{ "type": "text", "text": { "content": "Pomodoro" } }]
      }
    }]
  }'

The sandbox, line by line

This table is the reason our widgets are built the way they are. It is worth reading before you design anything for Notion.

JavaScript, CSS, canvas, SVG, WebAudio
Full. requestAnimationFrame, drag, keyboard, sound all work.
localStorage / IndexedDB
Works, but scoped to the browser. Not synced to teammates, cleared with site data.
Reading the page it is embedded in
No postMessage bridge to the parent, no access to page content or properties.
Writing to Notion
No API access. This is a security boundary, not a missing feature.
External network calls, CDN fonts, analytics
No network at all. Our widgets therefore inline every asset.
Clipboard
Write via the async Clipboard API when you gesture; some browsers gate reads.
Editing the source in place
Replace the block: download a new file and upload it.
Viewing on any plan
Free, Plus, Business, Enterprise. Only AI generation of HTML needs Business+.
channel: native recipe

Native recipe

A CRM, an OKR tree or a meeting-notes system is not a widget. It is databases with relations between them, rollups that aggregate across those relations, formulas that compute state, and views that filter the result. All of that exists in the Notion API, and all of it can be created headlessly.

The catch is ordering. Since Notion split databases from data sources, a relation property targets a data_source_id, which does not exist until the target database has been created. So a correct install is inherently two passes, and any tool that shows you a single POST /v1/databases per table is not telling you the whole story.

1
Create the container page

POST /v1/pages: everything else nests inside it, so the whole system can be deleted or duplicated as one unit.

2
Create every database with its base properties

POST /v1/databases: title, text, number, select, status, date, people, unique_id. Relations, rollups and any formula that reads them are deliberately left out.

3
Read back the data source ids

GET /v1/databases/{id}: each new database arrives with a default data source, and its id is what relations must reference.

4
Patch in the dependent properties

PATCH /v1/data_sources/{id}: now relations resolve, rollups can name a relation property, and formulas can reference both.

5
Create the views

POST /v1/views: board groups, timeline dates, chart axes, calendar ranges, form questions. Available since Notion-Version 2026-03-11.

6
Seed rows and append documentation

POST /v1/pages for seed data, then PATCH /v1/blocks/{id}/children in chunks of 100, the hard limit on children per request, and on nesting depth per request.

🔧Every recipe publishes its plan before it runs
POST /api/install with mode: "plan" needs no credentials and returns the exact request sequence: method, path, body and a note for each one. Read it, diff it against what you expected, then spend a token. Nothing is hidden behind an OAuth handshake.
⚠️And the four things it cannot do
No button block type exists in the public API. Database automations have no endpoint. Recurring templates are a Business+ feature with no endpoint. Dashboard view layouts are read-only after creation. Recipes install everything around these gaps and then give you the click path, with the reason it is manual.
the contract

The configuration contract

Every widget implements the same four rules. If you are writing your own, copy them. They are what makes a widget configurable from a URL, from a downloaded file, and from an API call without three different code paths.

01Read baked-in config first

If a <script id="bk-config" type="application/json"> tag exists, parse it. This is the download and API-install path, where there is no URL.

02Overlay the query string

new URLSearchParams(location.search) wins over baked-in values. This is the /embed path, and it lets one file serve many configurations.

03Whitelist declared parameters

Unknown keys are dropped, never passed through. Values are coerced to the declared type: number, boolean, color, date, select, string.

04Two universal parameters

theme (auto | light | dark) and accent (any CSS colour) exist on every widget, so a whole dashboard can be re-skinned from one place.

// the loader every widget ships with
const baked = JSON.parse(
  document.getElementById("bk-config")?.textContent ?? "{}"
);
const qs = new URLSearchParams(location.search);
const cfg = { ...baked };
for (const [k, v] of qs) cfg[k] = v;

const num  = (k, d) => Number.isFinite(+cfg[k]) ? +cfg[k] : d;
const bool = (k, d) => cfg[k] === undefined ? d : ["true","1"].includes(String(cfg[k]));
constraints

Where each channel breaks

The failure modes are the useful part. Everything below has been verified against the Notion API reference and the HTML block documentation, not assumed.

FailureEmbed URLHTML blockNative recipe
Host goes downBlock renders blankUnaffectedUnaffected
No internet at render timeFailsWorksWorks
Reads your Notion dataNoNoYes. It is the data
State shared with teammatesNo (localStorage)No (localStorage)Yes (database rows)
Needs an integration tokenNoOnly to install by APIYes
Works on the Free planYesYesYes (API included)
Editable after installWe ship updatesRe-upload the fileEdit like any database
Rate-limitedOur fair useNoNotion: ~3 req/s average
100-block append limitn/an/aApplies. We chunk
2-level nesting limitn/an/aApplies per request
channel: ai agents

AI Agents & Coding Assistants

You can give Claude Code, Cursor, Windsurf, or Claude Desktop native access to all 203 components via the official MCP server and Agent Skill.

MCP Server (Claude Code, Cursor, Desktop)

Gives your agent 10 tools to search, configure, build embed URLs, and plan installs.

claude mcp add --scope project notion-arena -- npx -y notion-arena-mcp

or pinned: npx -y notion-arena-mcp@0.2.2

Agent Skill (skills.sh)

Teaches your agent the delivery channels, offline contract, and parameter schema.

npx skills add SynergyFlowLabs/notion-arena --skill notion-arena

marketplace: /plugin marketplace add SynergyFlowLabs/notion-arena

decision guide

Which one should I use?

I want it on a page in 30 seconds
Embed URL

Type /embed, paste, resize. No account, no integration, works on Free.

I am putting it in a template I sell
HTML block

Self-contained, offline, frozen at the version you took. Nothing can rot underneath your buyer.

It must show real database numbers
Native recipe

Widgets cannot read Notion. Relations, rollups and chart views can.

My whole team needs to see the same state
Native recipe

localStorage is per browser. A database is shared.

An agent should build it from a prompt
MCP / Skill

claude mcp add --scope project notion-arena -- npx -y notion-arena-mcp@0.2.2

The workspace is air-gapped or offline
HTML block

The only channel with zero network dependency at render time.

I need drag-and-drop inside the block
Widget

Notion cannot script its own blocks; a sandboxed iframe can script itself.

I need it to trigger something in Notion
Neither. Read this

No channel can write to Notion from inside a block. Buttons and automations are the answer, and they are manual.

Still not sure?

Every component page shows all three channels side by side, with the plan for the API path already computed.

Browse components