Search selfstore
v1.8.21

MIT TypeScript ESM zero-config

Local-first storage,
one call.

Your users' data lives on their device, syncs across their devices with no server, and leaves as a portable file they truly own. You write two lines; selfstore does the rest.

npm install selfstore
import { selfstore } from 'selfstore';

const store = await selfstore('todo-app');

await store.put('todos', { id: 't1', text: 'ship it' });
store.all('todos');            // read your data back
store.onChange(render);        // your writes AND other devices
Proof, not promises

It is already running on this page.

This site has no backend, yet the box remembers what you type across reloads. Flip its home to a real file on your disk, or download an encrypted backup: all of it happens in your browser, because there is nothing else.

The demo is one selfstore('demo') store and about twenty lines of glue. What you read in the docs is what runs here.

Try it: this box is a real store Loading...
Where it is saved

Default: an IndexedDB working copy. Switch to a file and every change also writes to a real .zip you pick, on your disk (Chromium).

Nothing you type leaves this page: there is no server to send it to. The backup is a real ZIP with a documented layout - the button above opens the one you just made, without leaving the page.

It grows one call at a time

Start tiny. Reach for the next line only when you need it.

Everything below is optional. The five lines above are already a real app; each step here is a single call you add the day it matters.

1

Sync across their devices

Connect a destination the user owns. The same call on every device, and it tells you exactly what happened.

// One call, on every device. The USER owns the destination.
const outcome = await store.connectDrive(gisDriveAuth({ clientId }));
// or store.connectFile()  -  connectWebdav({ url, username, password })  -  connectS3(config)

// It tells you exactly what happened, nothing silent:
//   'started'   the destination was empty; this device's data now lives there
//   'merged'    it held a backup; both sides were folded, nothing lost
//   'manual'    no file access here; offer store.downloadBackup() instead
//   'cancelled' the user closed the picker
2

Encrypt it, hand them the file

Turn on end-to-end encryption and give the user a portable backup, one call each.

// End-to-end encryption of everything that leaves the device. One call.
await store.protect('a passphrase the user chose');

// ...and a portable file they can walk away with (encrypted while protected).
await store.downloadBackup();       // a real .zip, produced in the browser
await store.importBackup(pickedFile); // read one back
The one rule

Every record needs a string id.

It is what the multi-device merge keys on. The simple store enforces it at put() with a clear error, instead of letting a mis-keyed record silently never sync. The most common integration bug is impossible to ship by accident.

Keying on another field is one option away: selfstore('crm', { sync: { ids: { contacts: 'uuid' } } }).

await store.put('todos', { id: 't1', text: 'ok' });

// Forget the id, or make it a number, and put() throws right away -
// naming the collection and the one-line fix - instead of the record
// silently never syncing. The trap is now an error you cannot miss.
What you get

The boring-but-hard parts, owned.

One call to a working store

selfstore(app) opens a ready store that owns your data. Auto-save, tab-focus sync, tab-hide flush: already wired. You write put and all.

Sync across devices, no server

Point every device at a destination the user owns - their Drive, a file, their Nextcloud, an S3 bucket. They converge on their own. Nothing of yours to host.

Real files, not lock-in

A backup is a genuine ZIP with an independent spec and a Python reference reader. Unencrypted, it opens in any archive tool.

Encryption in one call

protect(passphrase) turns on end-to-end encryption for everything that leaves the device. The cloud only ever sees opaque bytes.

Honest outcomes, no silent traps

Connecting resolves to merged / started / manual / cancelled. A missing string id throws at put(). The library refuses to lose your data quietly.

Headless status and errors

The store ships stable keys (status.labelKey, error.labelKey), never copy or colours. Your design system renders them, in your words and language.

Framework-free by design

subscribe() plus a stable state binds to React, Svelte or Vue in a few lines each. There is deliberately no adapter package to install.

Grows without a rewrite

Reach down to store.advanced for peers, groups or a custom destination the day you need them. Same instance, nothing thrown away.

Leaving is easy

Backups are real files, and the format is public.

Every backup is a genuine ZIP: unencrypted, it opens in any archive tool as a JSON manifest plus your files; encrypted, it is still a valid ZIP carrying the ciphertext, the cleartext parameters and a readme, never a mystery blob.

The format is specified independently of the library in SPEC.md, with canonical test vectors and a ~120-line Python reference reader. A file written years ago still reads; a file written by something newer is refused honestly instead of misread.

When you outgrow the defaults

The machinery is right there.

The package root stays small on purpose. The pull-model store, custom destinations, passwordless groups and the bare merge engine are the same install, one import away, and store.advanced is that full store, same instance.

import { createLocalStore, indexedDbCache } from 'selfstore/advanced';
import { identityVault }               from 'selfstore/groups';
import { merge }                       from 'selfstore/sync';

The advanced guide covers createLocalStore, writing a destination, peers and groups.

Honest scope

When selfstore is the wrong tool.

We would rather you pick right than pick us. Do not use selfstore when:

  • Your data is measured in GB. Everything fits in memory; backups are MB-scale by design. Keep large queryable datasets in IndexedDB or SQLite-in-WASM.
  • You need real-time collaboration. The merge targets one person's devices and async sharing. For live cursors and shared text, embed a CRDT document; it travels happily inside a selfstore snapshot.
  • You need history or undo. A last-writer-wins overwrite is final locally. Dated backup copies are the honest time machine, and they are a one-liner.
  • You are on the server. selfstore is browser-first. Node 20+ runs it for tests, but the client is the point.
FAQ

Questions, answered plainly.

What is selfstore?

selfstore is a local-first storage library for browser apps: an automatic working copy in IndexedDB, portable encrypted ZIP backups, durable homes (a disk file, Google Drive, WebDAV, an S3 bucket) and serverless multi-device sync. The front door is one call, selfstore(app): it opens a ready store that owns your data, with saving, syncing and multi-device merge already wired. MIT licensed, TypeScript, ESM, browser-first.

How do I actually start?

npm install selfstore, then: const store = await selfstore("todo-app"); await store.put("todos", { id: "t1", text: "ship it" }); store.all("todos"). That is a working, auto-saving, offline app. Add store.connectDrive / connectFile / connectWebdav / connectS3 for multi-device, and store.protect for end-to-end encryption, one call each.

Where is the data stored, and who can see it?

The working copy lives in IndexedDB, on the device (its collections sealed at rest under a per-device key). Nothing is sent anywhere by default: there is no selfstore server, so nothing could see it. When a backup leaves the device it is encrypted client-side first, so a connected Drive, WebDAV or S3 bucket only ever holds opaque bytes.

Where does createLocalStore live?

One import away. The pull-model store (createLocalStore, indexedDbCache, the targets) lives at the selfstore/advanced subpath; groups at selfstore/groups; the bare merge engine at selfstore/sync. Same install, and store.advanced on a simple store IS that store - the two styles compose.

Do I need a backend or user accounts?

No server is required anywhere in the loop: save, backup, restore and merge all run in the browser. A disk file needs nothing at all. Client-only Google Drive re-consents roughly hourly; a permanent Drive connection needs a small refresh-token broker, documented honestly.

Does it work with React, Svelte or Vue?

Yes, deliberately without adapter packages: store.subscribe() plus a referentially stable store.state binds to any framework in a few lines. The docs show the exact one-liners.

Can other software read the backup files?

Yes. A backup is a standard ZIP with a documented layout: SPEC.md specifies the format independently of the library, with canonical test vectors and a roughly 120-line Python reference reader. Unencrypted backups open in any archive tool.

Is it production-ready?

Yes, and here is what that rests on. The public API has been under semantic versioning since 1.0.0 (23 July 2026): a major is reserved for a change that breaks you, and there has not been one. The file format is stable and specified, and a backup written by any version keeps reading - test vectors pinned at 1.0.0 prove it on every run, and an independent Python reader opens the same files. 814 tests cover the library, including seeded fuzz tests on the merge. It already powers production PWAs.

Ship the app. Skip the server.

npm install selfstore