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.