Quick start
selfstore’s front door is one call. selfstore(app) opens a store that OWNS
your data: you read and write collections through it, and saving, syncing and
multi-device merge happen on their own.
Try it first, install after
Open the playground in StackBlitz: notes that survive a reload, a real file on your disk as their home, and a genuine encrypted ZIP. Nothing to install, and the page it runs on has no backend either.
One caveat worth knowing before you click the file button: the file picker needs the File System Access API, which a cross-origin preview iframe does not get. Open the preview in its own tab first.
1. Install
npm install selfstore
ESM only, TypeScript types included, browser-first. Three small runtime
dependencies (fflate, hash-wasm, idb); the compression and crypto ones
load lazily, so they stay out of your critical bundle until first used.
2. A working app in five lines
import { selfstore } from 'selfstore';
type Todo = { id: string; text: string };
const store = await selfstore<{ todos: Todo }>('todo-app');
await store.put('todos', { id: crypto.randomUUID(), text: 'ship it' });
store.all('todos'); // read your data back (typed)
store.onChange(() => render(store.all('todos'))); // your writes AND other devices
await selfstore(app) resolves once the store is ready: data loaded from the
IndexedDB working copy, any connected destination restored, first sync done.
Auto-save (debounced), save on tab hide, and sync on tab focus and network
return are already wired. In tests and SSR, where there is no IndexedDB, the
same call lands on an in-memory cache, so this exact code runs under vitest.
The one rule that matters: every record needs a non-empty string
id.put()throws aTypeErrorright away if it is missing or not a string, naming the collection and the fix, instead of letting the record silently never sync. See the string-id rule.
3. Read and write
store.all('todos'); // readonly array of every record
store.get('todos', 't1'); // one record by id, or undefined
await store.put('todos', todo); // insert or replace one (auto-saves)
await store.putAll('todos', todos); // many in one save
await store.remove('todos', 't1'); // delete one (propagates to other devices)
await store.clear('todos'); // empty a collection
Your objects stay plain JSON: no proxies, no injected fields, no ORM. all()
returns a readonly snapshot; you change data through put/remove, and
onChange fires afterwards, whether the change came from you, another tab, a
device sync, or a restore.
4. Show the save state
The store is headless: it ships stable keys, you ship the copy and colours.
store.subscribe(() => {
statusBar.textContent = t(store.status.labelKey); // e.g. 'status.synced'
if (store.error) toast(t(store.error.labelKey)); // e.g. 'error.authExpired'
});
5. Sync across the user’s devices
One call, the same on every device. It connects a destination the user owns and tells you exactly what happened.
import { gisDriveAuth } from 'selfstore';
const outcome = await store.connectDrive(gisDriveAuth({ clientId }));
// or await store.connectFile(); // a disk file (Chromium)
// or await store.connectWebdav({ url, username, password }); // Nextcloud & co
// or await store.connectS3({ endpoint, region, bucket, key, accessKeyId, secretAccessKey }); // S3, R2, B2, MinIO
switch (outcome) {
case 'started': break; // destination was empty: this device's data now lives there
case 'merged': break; // it held a backup: both sides folded, nothing lost
case 'manual': break; // no file access here: offer store.downloadBackup()
case 'cancelled': break; // the user closed the picker / consent
}
If the destination holds an encrypted backup and you passed no password,
the call throws PASSWORD_REQUIRED before touching anything: prompt, then
retry with connectDrive(auth, { password }).
6. Encrypt end to end, and hand over a real file
await store.protect('a passphrase the user chose'); // end-to-end from here on
await store.unprotect(); // reversible
await store.downloadBackup(); // a portable .zip (encrypted while protected)
await store.importBackup(pickedFile); // read one back into the store
The download is a genuine ZIP. Without a password it opens in any archive tool; with one it is AES-256-GCM over an Argon2id-derived key, and the format is publicly specified.
The string-id rule
Every record keys the multi-device merge on a non-empty string id. The
simple store enforces it at put():
await store.put('todos', { id: crypto.randomUUID(), text: 'good' });
// Your records key on another field? Map it per collection:
const crm = await selfstore('crm', { sync: { ids: { contacts: 'uuid' } } });
Where next
- The mental model: concepts.
- Build it step by step: the tutorial writes an encrypted notes app from an empty folder to a synced, shareable one.
- Durable homes in depth: disk file, Google Drive, WebDAV, S3-compatible.
- Drop-in UI: the connect/share widgets are web components you theme with CSS.
- A sensitive app (health, legal): the hardening kit makes encryption and a strong password non-optional and locks the local cache.
- Own the data model yourself (Svelte runes, Redux) or write a custom destination: advanced. Bind status to your UI: framework one-liners.