App storage
Save per-user data from your app with the ambient window.panoplyStorage API — no backend required
Every deployed app gets an ambient window.panoplyStorage client, so your app can remember what each visitor does without standing up a backend. Write against window.panoplyStorage and it works the same whether the app is backed by a real per-app database or by the visitor's browser.
The API
window.panoplyStorage is available as a global before your own scripts run. Every method returns a Promise, so await them.
get(key)→ the stored value, ornullif the key doesn't exist.list(prefix?)→ an array of{ key, value, updatedAt }. Pass aprefixto return only keys that start with it; omit it to list everything.set(key, value)→{ ok: true }.valueis any JSON-serializable data.delete(key)→{ ok: true }.
await window.panoplyStorage.set('score', 42)
const score = await window.panoplyStorage.get('score') // 42, or null if unset
const games = await window.panoplyStorage.list('game:') // [{ key, value, updatedAt }, …]
await window.panoplyStorage.delete('score')
Keys are strings. Values are anything JSON can represent — objects, arrays, numbers, strings, booleans, null.
Two backings, one API
The client comes in two variants, injected at deploy time. Your code doesn't change between them:
- Server-backed — the app has a storage slot, so it has its own database. Data is saved server-side, scoped to the signed-in user, and encrypted at rest. It follows the user across devices and browsers.
- Browser-backed — the app has no slot (a free-plan app that isn't using its one storage slot, or storage that wasn't requested). The same API is backed by the visitor's
localStorage: data stays on that one device and browser,updatedAtis alwaysnull, and there are no network calls.
Which one an app gets is decided by your plan and whether the app holds a storage slot — see the plan matrix on the pricing page. Design for the browser-backed case if the app might not have a slot: it still works, the data is just per-device.
Scope, persistence, and privacy
- Server-backed rows are per user — each signed-in visitor sees only their own data — and encrypted at rest. It is not a shared or public database, and not a secrets vault: don't store API keys or credentials in it.
- Browser-backed data lives only in that visitor's browser. Clearing site data removes it, and it never leaves the device.
Limits
Server-backed storage has fixed per-user quotas (they are not tier-derived):
| Limit | Value |
|---|---|
| Keys per user | 10,000 |
| Bytes per value | ~1 MB |
| Total bytes per user | ~50 MB |
They suit preferences, saved state, and cached results — not bulk data or file hosting. Browser-backed storage is bounded by the browser's own localStorage limit instead.
Under the hood — the wire contract
You normally only need the window.panoplyStorage methods above. For tools generating an app, or for debugging, this is what the server-backed client does: it POSTs JSON same-origin to /__panoply/storage with an action and the operation's fields.
| action | request | response |
|---|---|---|
get | { key } | { value } (value is null if absent) |
list | { prefix? } | { items: [{ key, value, updatedAt }] } |
set | { key, value } | { ok: true } |
delete | { key } | { ok: true } |
Behavior at the edges:
- POST only — any other method returns
405. - Same-origin only — a request from a foreign
Originreturns403. 501when the app has no database bound — browser-backed apps never call the route, so this only appears if server-backed code runs somewhere it shouldn't.401when the session has expired — the client catches this and re-authenticates with a top-level navigation, so yourawaittransparently continues after the visitor is re-established.