Shared Storage
Both surfaces (editor and layer) share useExtensionStorage(). The editor writes it; every surface that renders the installation reads it and receives each write in real time via WebSocket.
How it works
Editor writes: setVisible(true) // useExtensionStorage("visible", false)
-> postMessage to Lumio host (iframe boundary)
-> Lumio API: POST /v1/extension-installs/{id}/storage
-> PostgreSQL: upsert the key in extension_storage
-> Redis pub/sub: PUBLISH lumio:ext-storage:{install_id} ...
-> WebSocket broadcast: ext-storage:{install_id} channel
-> Layer receives: storage update event -> re-renders
On start, each surface loads the installation's stored values once (GET /v1/extension-installs/{id}/storage) and then follows the ext-storage:\{install_id\} channel. A key that was never stored keeps the initial value your code passes.
Who can write
Only the editor (the Lumio dashboard, running as the signed-in member) writes storage. A layer — an overlay in OBS or a widget — reads storage but never writes it: its link carries a token that streamers sometimes show on stream, and a raw storage write would let anyone with that link change what the layer renders.
When a layer calls a storage setter, the host refuses the write, rolls the value back to the stored one and reports the error STORAGE_READ_ONLY (Overlay and widget tokens cannot write extension storage). If a layer has to change state — a counter, a vote — call a server function mutation instead. You validate its input, and a layer may call it.
Usage
import { Toggle, useExtensionStorage } from "@zaflun/lumio-sdk";
function MyComponent() {
// One hook call per key; the second argument is the value until one is stored
const [isVisible, setVisible] = useExtensionStorage("visible", false);
const [teamName] = useExtensionStorage("homeTeam", "Home Team");
return (
<Toggle label={`Show ${teamName}`} checked={isVisible} onChange={setVisible} />
);
}
A setter writes only its own key. Two surfaces or components writing different keys never overwrite each other.
Storage type
Storage is a flat key-value store. Each key holds one JSON-serializable value:
type ExtensionStorageValue =
| string | number | boolean | null | string[] | number[] | Record<string, unknown>;
When you store an object under one key, the setter replaces that key's whole value - spread the current one (setScore({ ...score, home: 2 })). For structured data with relations and queries, use server functions instead.
Initial value
Until the stored values have loaded, and for a key that was never stored, the hook returns the initialValue you pass:
const [text] = useExtensionStorage("text", "Default text");
const [count] = useExtensionStorage("count", 0);
const [enabled] = useExtensionStorage("enabled", true);
Storage persistence
Storage is persisted in Lumio's PostgreSQL database, one row per key. It survives:
- Page refreshes
- Browser Source reloads
- Browser restarts
- Extension updates (unless you explicitly reset it in a mutation)
It is deleted together with the installation:
- Uninstall with "delete data now" — deleted immediately.
- Uninstall with the default 30-day retention — no longer readable or writable from the moment of uninstall; deleted when the retention window ends.
- Account dissolution — deleted with the account.
Do not keep anything in storage that must outlive the installation.
Storage vs server functions
useExtensionStorage() | Server functions | |
|---|---|---|
| Where stored | One row per key, per installation | ext_\{id\}.* PostgreSQL tables |
| Who can write | Editor (dashboard) | Server function mutations (from any surface) |
| Real-time sync | Automatic (WebSocket) | Manual (call refetch()) |
| Data model | Flat key-value store | Relational tables with schema |
| Queries | Read one key per hook | Filtered, sorted, paginated rows |
| Best for | Settings, toggles, counters | Lists, leaderboards, complex state |
Scoping
Storage is scoped to the extension installation — one installation per channel (account). Every surface and every overlay of that channel that uses your extension reads and writes the same storage. Two channels that install your extension each have their own independent storage.
Storage limits
Each key is stored separately. The limits apply per installation and are enforced on every write:
| Limit | Value |
|---|---|
| Key length | 256 bytes (UTF-8); not empty, no : |
| One value | 64 KiB of serialized JSON |
| Keys per installation | 1 000 |
| Total size per installation | 1 MiB (keys + JSON values) |
A write over a limit is rejected with 400 Bad Request (GraphQL: code: BAD_REQUEST) and nothing is stored, for example Extension storage is limited to 1000 keys per install. Overwriting an existing key with a smaller value always works, so an installation at its limit can still update its keys. For larger data volumes, use server functions with defineTable.
Optimistic updates
A storage setter applies an optimistic update - the local state updates immediately, and the network write happens asynchronously. If the network write fails (for example over a limit), the host reports STORAGE_WRITE_FAILED with the API's message and the key reverts to its last stored value; a key that was never stored keeps the local value. You do not need to handle loading states for storage writes.