Storage & Buckets
monodit provides two backend-backed storage systems inside the sandbox:
- Document storage via
ctx.storefor shared JSON data - Asset storage via
ctx.assetsfor files stored in Cloudflare R2
In addition, the bridge exposes local cache via ctx.cache for browser-only per-instance data such as response snapshots or favicon caches.
Both systems are organized around the same host concept: a bucket.
This page explains how buckets work, how widgets opt into host-managed bucket sharing, and how quotas are enforced.
For the raw bridge surface, see Bridge API Reference.
The Mental Model
Every storage-capable widget instance starts in an isolated state.
That means:
- a document widget defaults to a bucket derived from its
instanceId - an asset widget also defaults to a bucket derived from its
instanceId
Users can later connect multiple compatible widgets to the same bucket through the host UI.
In practice, that allows patterns like:
- two note widgets sharing one document
- multiple gallery/image widgets sharing one asset bucket
- switching a widget from a private bucket to a shared team bucket
Storage Backends
d1-json
The d1-json backend powers ctx.store.
Characteristics:
- one logical JSON document per bucket
- local-first cache in IndexedDB
- real-time cross-widget updates on the current page
- quota tracked by payload byte size
Typical use cases:
- notes
- todo lists
- counters
- small structured datasets
r2-assets
The r2-assets backend powers ctx.assets.
Characteristics:
- binary file storage in Cloudflare R2
- metadata indexed in D1
- bucket-aware listing and deletion
- quota tracked by object byte size
Typical use cases:
- image galleries
- profile pictures
- attachments
- media libraries
Storage Contracts
If you want the host UI to understand whether a widget can connect to a bucket, your widget must declare a storage contract in its manifest and mark the manifest as "widgetType": "dynamic".
Example:
{
"widgetType": "dynamic",
"storage": {
"bindings": [
{
"key": "primary",
"backend": "d1-json",
"schemaId": "notes.plaintext",
"schemaVersion": 1,
"shareable": true
}
]
}
}
For asset widgets:
{
"widgetType": "dynamic",
"storage": {
"bindings": [
{
"key": "assets",
"backend": "r2-assets",
"schemaId": "gallery.assets",
"schemaVersion": 1,
"configKey": "assetBucketId",
"shareable": true
}
]
}
}
Binding Fields
| Field | Required | Description |
|---|---|---|
key | Yes | Logical binding name used by the widget author |
backend | Yes | d1-json or r2-assets |
schemaId | Yes | Logical schema identifier used for compatibility |
schemaVersion | Yes | Integer version for the schema |
configKey | No | Config field used by the host to store the connected bucket ID |
label | No | Human-readable label for settings UI |
shareable | No | Whether the binding is intended to be shared across widgets |
Why Contracts Matter
Without a storage contract, the host cannot safely answer:
- can this widget connect to that bucket?
- should this bucket appear in the picker?
- is this a document bucket or an asset bucket?
With a contract, the host can validate compatibility using:
- backend
- schema ID
- schema version
Two widgets are considered compatible only when those values match.
In the current widget manifest rules, a storage contract and "widgetType": "dynamic" must travel together. A basic widget cannot declare bucket storage.
Bucket Resolution
Document Buckets
For d1-json bindings:
- if the widget calls
ctx.store.get("explicit-name"), that explicit namespace is used - otherwise the bridge checks
ctx.config.storageNamespace - if that is empty, it falls back to
ctx.instanceId
Asset Buckets
For r2-assets bindings:
- the host checks the binding's
configKeysuch asassetBucketId - if that config field is empty, it falls back to
ctx.instanceId
This is why new widgets appear as isolated by default, but can be re-linked later.
ctx.store: Shared JSON Documents
Document buckets are ideal when the entire state can be represented as one reasonably small JSON value.
await ctx.store.set({ text: "Hello world" });
const value = await ctx.store.get();
Best Practices
- Treat one bucket as one logical document.
- Keep payloads small and coherent.
- Subscribe to updates and re-render from the new value.
- Re-bind if your widget depends on
storageNamespaceand that config changes. - Treat the server bucket as the source of truth and the local browser cache as a fast mirror.
- Assume subscription callbacks may fire more than once for the same logical edit because the host can emit:
- an optimistic local update
- a later confirmation after the server write completes
- a revalidation update after checking server metadata
Runtime Model
For d1-json buckets, the host uses a validation cache:
- the current document is cached in IndexedDB per namespace
ctx.store.get()returns the cached snapshot first when available- the host then checks lightweight server metadata
- the full document is only fetched again when the server indicates a newer value
- active document buckets are re-checked when the window regains focus
This keeps server reads low while still allowing another browser or device to catch up without re-mounting the widget.
Good:
ctx.store.subscribe(null, renderDocument);
Good for text editors:
let pendingRemote = null;
let draftVersion = 0;
let lastSavedVersion = 0;
function hasDirtyDraft() {
return draftVersion !== lastSavedVersion;
}
ctx.store.subscribe(null, (value) => {
if (document.activeElement === textarea && hasDirtyDraft()) {
pendingRemote = value;
return;
}
renderDocument(value);
});
Risky:
- storing huge append-only logs
- writing many unrelated datasets into one document
- assuming the namespace never changes
- blindly assigning
input.value = ...on every subscription callback while the user is typing
ctx.assets: Shared File Buckets
Asset buckets are ideal when the shared state is a set of files instead of one JSON blob.
const url = await ctx.assets.upload(file);
const assets = await ctx.assets.list();
await ctx.assets.delete(assets[0].objectKey);
Best Practices
- Treat
ctx.assets.list()as the source of truth for bucket contents. - If your widget listens for bucket changes, use
ctx.assets.subscribe(...). - Re-bind if your widget depends on an asset bucket config field and that config changes.
Example:
let unsubscribe = null;
function bindBucket() {
if (unsubscribe) unsubscribe();
unsubscribe = ctx.assets.subscribe(loadAssets);
}
ctx.onConfigChange(() => {
bindBucket();
loadAssets();
});
Storage Topology UI
The host exposes a Storage Topology view in account settings.
That UI can:
- show all known buckets
- show connected widgets
- show per-bucket usage
- allow a widget to move to a different compatible bucket
- delete a bucket
Compatibility is driven by the widget's storage contract.
What "Isolated" Means
If the UI shows a widget as Isolated, it means the widget is still using its own default bucket derived from its instanceId.
It is not yet sharing storage with any other widget.
Quotas
Storage is tracked per user across both backends.
Current limits:
- Free: 100 MB logical limit, but storage APIs are gated off
- Pro: 5 GB base limit
- Add-ons: extra storage in 5 GB increments
Quota includes:
- D1 document payload size for
ctx.store - R2 object byte size for
ctx.assets
When a write exceeds quota, the request fails with QUOTA_EXCEEDED.
try {
await ctx.assets.upload(file);
} catch (err) {
if (err.message === "QUOTA_EXCEEDED") {
alert("Storage quota exceeded.");
}
}
Deletion Semantics
Deleting a Document Bucket
When a d1-json bucket is deleted:
- the document is removed from D1
- local cache is invalidated
- connected widgets fall back to isolated buckets if the host disconnects them
Deleting an Asset Bucket
When an r2-assets bucket is deleted:
- all indexed assets in that bucket are deleted from R2
- asset metadata rows are removed from D1
- quota is refunded
- connected widgets fall back to isolated buckets if the host disconnects them
Patterns That Work Well
Shared Notes
- backend:
d1-json - schema:
notes.plaintext - data shape:
{ text, updatedAt }
Shared Image Bucket
- backend:
r2-assets - schema:
gallery.assets - source of truth:
ctx.assets.list()
Mixed Widgets
Some widgets need both:
{
"storage": {
"bindings": [
{
"key": "content",
"backend": "d1-json",
"schemaId": "gallery.settings",
"schemaVersion": 1
},
{
"key": "assets",
"backend": "r2-assets",
"schemaId": "gallery.assets",
"schemaVersion": 1,
"configKey": "assetBucketId"
}
]
}
}
This allows one widget to store:
- structured settings in D1
- files in R2
Guidance for Dynamic Themes
Dynamic Themes can use the same bridge APIs, but they are not first-class participants in widget bucket topology UX.
That means:
ctx.storeandctx.assetsare available- you can still use explicit namespaces/bucket IDs
- but host-managed bucket compatibility UI is primarily designed for widgets
If a Dynamic Theme needs persistent shared state, keep the structure explicit and stable.
Summary
Choose the backend that matches the data:
ctx.storagefor tiny private statectx.storefor shared JSON documentsctx.assetsfor shared files
If you want host-managed reconnection, compatibility checks, and topology UI, declare a storage contract in the widget manifest.