Search selfstore
v1.8.21

Tutorial - an encrypted notes app

This tutorial builds a working notes app in the order the features actually arrive: notes that persist, then a home the user owns, then end-to-end encryption, then sync - each one a single call added to the last step. Every snippet is real; paste them in order and you have the app. There is no server at any point.

Step 0 - a page

Any bundler, or none. An HTML file with an import map is enough to run selfstore straight from npm’s CDN:

<!doctype html>
<main>
  <input id="new" placeholder="Write a note, Enter to save" />
  <ul id="list"></ul>
</main>
<script type="module">
  import { selfstore } from 'https://esm.sh/selfstore';
  // ...the rest of this tutorial goes here
</script>

With a bundler, npm install selfstore and import { selfstore } from 'selfstore'.

Step 1 - a store that owns the notes

One call opens the store. It loads any saved data, wires auto-save, and resolves when it is ready:

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

const add = (text) =>
  store.put('notes', { id: crypto.randomUUID(), text, at: Date.now() });

document.getElementById('new').addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && e.target.value.trim()) {
    add(e.target.value.trim());
    e.target.value = '';
  }
});

put auto-saves (debounced) to an IndexedDB working copy. That is already a persistent app: type a note, reload the page, it is still there. No server, no fetch.

Step 2 - render, and re-render on every change

onChange fires after any change to the data - your writes, and later, edits arriving from another tab or device. Render from all(), which returns a readonly snapshot:

function render() {
  const notes = [...store.all('notes')].sort((a, b) => b.at - a.at);
  document.getElementById('list').innerHTML = notes
    .map((n) => `<li>${escapeHtml(n.text)}</li>`)
    .join('');
}

store.onChange(render);
render();

Notice the shape of the data: plain objects with a string id. That id is the one rule - it is what the multi-device merge keys on, so put throws right away if it is missing. Everything else is yours.

Step 3 - a home the user controls

So far the notes live only in this browser. To keep them across the user’s devices, connect a durable home the user owns. The quickest to demo is a file on disk (Chromium); the same shape works for Drive, WebDAV and S3:

document.getElementById('connect').addEventListener('click', async () => {
  const outcome = await store.connectFile(); // opens a save dialog
  if (outcome === 'manual') {
    // Non-Chromium: no direct file access. Offer store.downloadBackup() instead.
  }
});

From now on, every change also writes to that file. Point a second device at the same file (or the same Drive folder) and their notes converge - see step 5.

Prefer not to hand-roll the picker? Drop in the connect widget and it renders the whole journey, styled as your own.

Step 4 - encrypt it end to end

A backup should be unreadable to whatever holds it. One call turns on AES-256-GCM over an Argon2id-derived key; from here the home only ever sees ciphertext:

await store.protect(passphraseTheUserChose);
// await store.unprotect(); // reversible

Download the result and inspect it: store.downloadBackup() hands you a real .zip. Without a password it opens in any archive tool; with one, it is the specified encrypted format, ciphertext through and through.

Step 5 - sync across devices (and tabs)

There is no step 5 to write. Once two devices point at the same home, they converge on their own: on tab focus, on network return, on a slow interval, and when you call store.sync(). Open the app in a second tab right now, edit a note, and watch the first tab update - that is the same merge that runs between devices, in miniature.

Concurrent edits resolve by a logical clock (last writer wins), and deletions propagate. The sync guide has the model and how to surface a conflict.

Where to take it

  • Show the state - a saving/synced/offline indicator: the status descriptor.
  • Share it - hand another person a link to read or co-edit: peers and groups.
  • Harden it - make encryption and a strong password non-optional and lock the local cache: sensitive apps.
  • Frameworks - the same store binds to React, Svelte and others in a few lines: framework bindings.