Local-first data sync for Svelte 5.
npm install svelte-locally
<script>
import { init, doc } from 'svelte-locally';
import { onMount } from 'svelte';
let todos = $state(null);
onMount(() => {
init({ sync: 'wss://sync.automerge.org' });
todos = doc('my-todos', { items: [] });
});
function addItem() {
if (!todos) return;
const text = prompt('Todo text:');
if (text) {
todos.change(current => {
current.items.push({ text, done: false });
});
}
}
</script>
{#if !todos?.status.ready}
<p>Loading...</p>
{:else}
<button onclick={addItem}>Add Todo</button>
<ul>
{#each todos.data.items as item}
<li>{item.text}</li>
{/each}
</ul>
{/if}
init(config)Initialize svelte-locally. Call once at app startup.
init({
sync: 'wss://sync.automerge.org', // Sync server (optional)
storage: true, // IndexedDB (default: true)
broadcastChannel: true, // Tab sync (default: true)
sharePolicy: 'explicit', // Only sync shared docs (default)
});
Share Policy:
'explicit' (default) — Only sync documents with issued/received tokens (secure)'all' — Sync all documents with any peer (open)(peerId, docId) => Promise<boolean>doc(id, initial)Create or load a synced document.
const counter = doc('my-counter', { count: 0 });
// Read data (reactive)
counter.data.count // → 0
// Mutate data
counter.change(current => {
current.count = current.count + 1;
});
// Check status
counter.status.ready // true when loaded
counter.status.syncing // true when syncing
counter.status.online // true when online
counter.status.error // error message or null
collection(name)Scalable lists where each item is a separate document.
interface Todo {
text: string;
done: boolean;
}
const todos = collection<Todo>('todos');
// CRUD operations
const id = todos.add({ text: 'Buy milk', done: false });
todos.update(id, current => { current.done = true; });
todos.remove(id);
// Reactive array
todos.items // [{ id: '...', text: 'Buy milk', done: true }]
// Status
todos.status.ready
todos.status.totalCount
todos.status.loadedCount
query(collection)Filter, sort, and limit collection items.
const active = query(todos)
.where(t => !t.done)
.orderBy('createdAt', 'desc')
.limit(10);
// Reactive results
active.items
active.count // total before limit
identity()Get the user's cryptographic identity.
const me = await identity();
me.id // "did:key:z6MkhaXgBZD..."
me.did // same as id
// Import access token someone sent you
me.importAccess(tokenString);
// View all received access
me.accessTokens // [{ docUrl, role, fromDid, ... }]
Role-based access control with shareable tokens.
Roles:
reader — can readwriter — can read + writeadmin — can read + write + delegate// Create a shareable token
const token = await doc.createToken('writer', { expires: '7d' });
// Send this token however you want (DM, email, etc.)
// Grant access to a specific user
await doc.grant(recipientDid, 'reader');
// Revoke access
doc.revokeGrant(recipientDid);
// View who has access
doc.grants // [{ recipientDid, role, expiresAt, ... }]
// Generate one-click invite link
const link = await doc.inviteLink('reader');
// "https://app.com/doc/automerge:...#access=eyJ..."
Receiving access:
const me = await identity();
// Import token someone sent you
const access = me.importAccess(tokenString);
// { docUrl, role, fromDid, expiresAt }
// Now you can open the document
const sharedDoc = docFromUrl(access.docUrl);
Track sync state:
doc.status.pendingChanges // unsynced local changes
doc.status.lastSyncedAt // Date | null
doc.status.online // network available
Export documents to binary format for backup:
// Export a document
const backup = await doc.export();
if (backup) {
// Save to file, cloud storage, etc.
downloadFile(backup, 'my-document.backup');
}
// Restore from backup
import { importDoc, docFromId } from 'svelte-locally';
const fileData = await readFile('my-document.backup');
const docUrl = importDoc<MyType>(fileData); // returns URL
// Load in a reactive context (e.g., $effect or component init)
let viewingId = $state(docUrl);
$effect(() => {
if (viewingId) myDoc = docFromId<MyType>(viewingId);
});
All APIs handle server-side rendering gracefully:
<script>
import { init, doc } from 'svelte-locally';
import { onMount } from 'svelte';
let todos = $state(null);
onMount(() => {
init();
todos = doc('todos', { items: [] });
});
</script>
{#if !todos?.status.ready}
<p>Loading...</p>
{:else}
<!-- render todos -->
{/if}
Built-in retry with exponential backoff:
{#if todos?.status.error}
<p>Error: {todos.status.error}</p>
<button onclick={() => todos.retry()}>Retry</button>
{/if}
TypeScript types are exported for convenience:
import type { DocResult, CollectionResult } from 'svelte-locally';
let settings: DocResult<Settings> | null = $state(null);
let todos: CollectionResult<Todo> | null = $state(null);
init() first// ❌ Wrong - will throw
const todos = doc('todos', { items: [] });
init();
// ✅ Correct
init();
const todos = doc('todos', { items: [] });
doc() inside component initializationdoc() and collection() use Svelte's $effect internally, so they must be called during component init:
// ❌ Wrong - $effect orphan error
async function loadDoc() {
const myDoc = doc('settings', {}); // Error!
}
// ✅ Correct - in component script or $effect
const myDoc = doc('settings', {});
// ✅ Also correct - reactive loading
let docId = $state('doc-1');
let myDoc = $state(null);
$effect(() => {
myDoc = docFromId(docId);
});
onMountIn SvelteKit, browser APIs aren't available during SSR:
<script>
import { init, doc } from 'svelte-locally';
import { onMount } from 'svelte';
let todos = $state(null);
// ✅ Wrap in onMount for SSR safety
onMount(() => {
init();
todos = doc('todos', { items: [] });
});
</script>
importDoc() returns URL, not DocResult// ❌ Wrong - importDoc doesn't return a doc
const restored = importDoc(binary);
restored.change(...); // Error!
// ✅ Correct - use URL with docFromId in reactive context
const docUrl = importDoc(binary);
viewingDocId = docUrl; // Let $effect load it
Anyone with the token string has access. Treat share tokens like passwords:
const token = await doc.createToken('writer');
// ⚠️ Anyone with this string can edit the document
// Share securely (DM, email) - not in public URLs
See the examples directory for working demos:
Full documentation in the docs directory:
MIT © Joe O'Heron