TLDR
Build a real-time, offline-capable todo app with Jazz and Vue 3. Jazz replaces your entire backend with collaborative data structures called CoValues. Combined with community-jazz-vue, you get composables like useCoState that plug into Vue’s reactivity system. The app syncs across tabs and devices, supports drag-and-drop with fractional indexing, and generates shareable URLs.
Why Jazz Replaces Your Entire Backend
You want real-time collaboration, offline support, and shareable URLs. In a traditional stack, that means building a sync server, a conflict resolution strategy, a permissions layer, and an API. Jazz collapses all of that into collaborative data structures called CoValues.
Think of Vue’s ref — it keeps the virtual DOM in sync with the real DOM. CoValues take that further: they sync your data across devices, across users, and across network boundaries. You mutate data locally, Jazz handles the rest.
This is the next post in my local-first series, and it covers how to build a complete Vue 3 app on top of Jazz. You can find the official Jazz documentation at jazz.tools.
Combined with community-jazz-vue, a community-maintained Vue binding, you get composables like useCoState and a provider component that plug directly into Vue’s reactivity system.
If you’re new to local-first, start with the first post in this series:
What is Local-first Web Development? What is Local-first Web Development? What is local-first software and why does it matter? This guide covers local-first architecture, offline-capable apps with automatic sync, data ownership, and how to build a local-first web app with Vue step by step.For comparison, here’s the same app built with Dexie.js and IndexedDB:
Building Local-First Apps with Vue and Dexie.js Building Local-First Apps with Vue and Dexie.js Learn how to create offline-capable, local-first applications using Vue 3 and Dexie.js. Discover patterns for data persistence, synchronization, and optimal user experience.Also worth watching: Anselm Eickhoff, the creator of Jazz, gave a great workshop on local-first at VueConf 2025 where he live-coded with Jazz:
Anselm Eickhoff live vibe-coding a local-first app with Jazz and Vue at VueConf 2025
Your First CoValue: A Synced Counter
Vue’s ref keeps the virtual DOM in sync with the real DOM — you update a ref, Vue re-renders. That’s reactive state within one browser tab. Jazz’s useCoState takes that same idea a step further: it keeps your data in sync across devices, across users, and across network boundaries. You mutate locally, Jazz propagates the change to every connected peer.
The simplest CoValue is a single field:
import { co, z } from "jazz-tools";
const Counter = co.map({ count: z.number() });
That’s one line of schema. Now subscribe to it in a Vue component:
<script setup lang="ts">
import { ref } from "vue";
import { useCoState } from "community-jazz-vue";
import { Counter } from "./schema";
// Create a counter and grab its ID
const counterId = ref<string | undefined>();
const newCounter = Counter.create({ count: 0 });
counterId.value = newCounter.$jazz.id;
// Subscribe — returns a reactive ref that updates on local and remote changes
const counter = useCoState(Counter, counterId);
function increment() {
if (counter.value?.$isLoaded) {
counter.value.$jazz.set("count", (counter.value.count ?? 0) + 1);
}
}
function decrement() {
if (counter.value?.$isLoaded) {
counter.value.$jazz.set("count", Math.max(0, (counter.value.count ?? 0) - 1));
}
}
</script>
<template>
<div v-if="!counter?.$isLoaded">Loading...</div>
<div v-else>
<p>{{ counter.count }}</p>
<button @click="decrement">−</button>
<button @click="increment">+</button>
</div>
</template>
Three things are happening here:
Counter.create({ count: 0 })creates a new CoValue instance and persists it locally.useCoState(Counter, counterId)subscribes to that CoValue. It returns a reactiveRefthat starts unloaded, populates once available, and re-renders on every change — just like Vue’sref, but across the network.$jazz.set("count", ...)mutates the CoValue directly. No API call, no action dispatch — Jazz syncs the change to all connected peers.
This already works for a single user. But there’s a problem: by default, CoValues are private. Only the creator can read or write them. If you shared this counter’s ID with another user, they’d get nothing — the CoValue is invisible to them.
Making It Shareable with Groups
To let others access the counter, you need a Group. Groups are how Jazz controls who gets access and what they can do. Every CoValue has an owner — either an Account (private) or a Group (shared).
import { Group } from "jazz-tools";
const group = Group.create();
group.addMember("everyone", "writer");
const newCounter = Counter.create({ count: 0 }, { owner: group });
That’s three lines. Group.create() makes a new permission group. addMember("everyone", "writer") grants public write access — the "everyone" keyword is a special shorthand that includes all users, even anonymous guests. Passing { owner: group } to Counter.create assigns the counter to that group instead of the default private account.
Now anyone who knows the CoValue ID can read and write the same counter. No API keys, no auth server — sharing the ID is all it takes.
Try it — click the + button in one panel and watch the count update in the other. Both panels are independent Jazz clients syncing via Jazz Cloud:
That’s the entire model: define your data with co.map, subscribe with useCoState, mutate directly. But we glossed over something important — how does Jazz decide who can access a CoValue, and what stops someone from escalating their own permissions?
How Jazz Permissions Work
The counter example used addMember("everyone", "writer") to make data publicly accessible. Let’s look at what’s happening under the hood.
Jazz defines a role hierarchy where each role includes the permissions of the roles below it:
When we create a Group and grant public write access, the permission structure looks like this:
You might wonder: what stops a malicious user from upgrading their own role from writer to admin? Jazz enforces permissions cryptographically — not on a server you have to trust, but in the data itself.
Every change to a CoValue is signed with the author’s private key (Ed25519). When any peer receives a transaction, it verifies the signature and checks whether that account actually had permission to make that change. A writer trying to modify Group roles? The signature is valid, but the Group’s internal rules reject it — only admins can change roles. The transaction gets discarded.
This means even the Jazz Cloud sync server can’t tamper with your data — it just relays signed transactions. If the server modified anything, the signatures wouldn’t match and peers would reject it. Jazz also encrypts data with a shared read key (XSalsa20), so the server can’t even read what it’s relaying.
With permissions covered, let’s build something more ambitious.
What We’re Building: A Real-Time Vue 3 Todo App
A todo list app that:
- Syncs in real-time across tabs and devices via Jazz Cloud
- Works offline with a toggle to simulate going on/off the network
- Supports drag-and-drop reordering via
useSortable - Shows a dynamic page title with the count of incomplete todos
- Generates a shareable URL so anyone with the link sees the same list
- Includes the Jazz Inspector for debugging CoValues in development
Try it out — both todo apps below are independent Jazz clients, each running as a Vue component embedded in this page. Add a todo in one and watch it sync to the other via Jazz Cloud:
Here’s the high-level architecture:
Let’s build it step by step.
Project Setup
You’ll need Node.js v20+ and a package manager (we’ll use pnpm, but npm/yarn work too).
Start with a fresh Vue project (select TypeScript when prompted) and install the dependencies:
npm create vue@latest vue-jazz-todo
cd vue-jazz-todo
pnpm add jazz-tools community-jazz-vue @vueuse/core @vueuse/integrations sortablejs fractional-indexing tailwindcss @tailwindcss/vite
pnpm add -D @types/sortablejs vite-plugin-vue-devtools
Here’s the project structure we’ll end up with:
Defining the Schema
Jazz uses Collaborative Values (CoValues) as its building blocks — reactive, synced data structures. If you’ve used Zod before, the schema API will feel familiar. Create src/schema.ts:
import { co, z } from "jazz-tools";
export const ToDo = co.map({ title: z.string(), completed: z.boolean(), order: z.string() });
export const ToDoList = co.list(ToDo);
Two lines. co.map defines a collaborative object with typed fields, co.list defines an ordered list. The order field stores a fractional index string for drag-and-drop — we’ll cover why in the reordering section. Jazz handles sync, persistence, and conflict resolution under the hood.
Here’s what that looks like in practice. A CoMap tracks every change from every device. When Alice sets completed: false at 8:05 AM and Bob sets completed: true at 8:03 AM, the later timestamp wins. Step through the timeline to see how the resolved state changes as transactions arrive:
Entry Point and Build Configuration
The entry point is standard Vue. Update src/main.ts:
import { createApp } from "vue";
import App from "./App.vue";
import "./assets/main.css";
createApp(App).mount("#app");
For Tailwind CSS v4, replace src/assets/main.css with a single import, no tailwind.config.js needed:
@import "tailwindcss";
We also need to tell Vue about the Jazz Inspector custom element so Vue doesn’t try to resolve it as a component. Update vite.config.ts:
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === 'jazz-inspector',
},
},
}),
vueDevTools(),
tailwindcss(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
The isCustomElement line is important — Jazz ships with a built-in inspector for browsing and debugging CoValues at runtime. It appears as a floating panel where you can click into any CoValue to see its current state, history, and sync status. Since the inspector is a Web Component (<jazz-inspector />), we need to tell Vue not to resolve it as a Vue component.
Setting Up the Jazz Provider
Instead of manually creating a context manager, we use the JazzVueProvider component from community-jazz-vue. It handles context creation, cleanup, and provides Jazz to all child components via Vue’s dependency injection.
Create src/App.vue:
<script setup lang="ts">
import { ref, computed } from "vue";
import { JazzVueProvider } from "community-jazz-vue";
import "jazz-tools/inspector/register-custom-element";
import TodoApp from "./TodoApp.vue";
const isOnline = ref(true);
const syncConfig = computed(() => ({
peer: "wss://cloud.jazz.tools/?key=vue-jazz-todo-example" as const,
when: isOnline.value ? ("always" as const) : ("never" as const),
}));
</script>
<template>
<JazzVueProvider :sync="syncConfig">
<TodoApp v-model:is-online="isOnline" />
<jazz-inspector />
</JazzVueProvider>
</template>
Three things worth understanding about this setup:
JazzVueProviderwraps the entire app and takes asyncprop. It only renders children once the Jazz context is ready, so downstream components can safely assume Jazz is available.- The sync config is reactive — the
computedderives the config fromisOnline. When the user toggles the switch,whenflips between"always"and"never", and Jazz switches between syncing and offline mode. import "jazz-tools/inspector/register-custom-element"registers the<jazz-inspector>Web Component as a side-effect import. TheisOnlinestate lives here and is passed down viav-model:is-online, keeping the sync config and toggle UI separated.
Building the Todo App
Now for the main component. Create src/TodoApp.vue and let’s build it up piece by piece.
Imports and Setup
import { ref, computed, useTemplateRef, watchEffect } from "vue";
import { co, Group } from "jazz-tools";
import { useCoState } from "community-jazz-vue";
import { useClipboard, useUrlSearchParams, useTitle, useFocus } from "@vueuse/core";
import { useSortable, removeNode, insertNodeAt } from "@vueuse/integrations/useSortable";
import { generateKeyBetween } from "fractional-indexing";
import { ToDo, ToDoList } from "./schema";
const isOnline = defineModel<boolean>("isOnline", { required: true });
const newTitle = ref("");
const { copy, copied } = useClipboard({ copiedDuring: 2000 });
We receive isOnline from the parent via defineModel and set up a ref for the input field plus VueUse’s clipboard composable for the “Copy link” button.
Routing via URL
const params = useUrlSearchParams("history");
const listId = ref<string | undefined>(params.id as string | undefined);
if (!listId.value) {
const group = Group.create();
group.addMember("everyone", "writer");
const newList = ToDoList.create([], { owner: group });
listId.value = newList.$jazz.id;
params.id = newList.$jazz.id;
}
We use VueUse’s useUrlSearchParams("history") instead of raw URLSearchParams. The "history" mode uses the History API (no page reload) and gives us a reactive object. If the URL already has an ?id= parameter, we subscribe to that existing list. Otherwise, we create a new list owned by a Group with public write access — the same pattern from the permissions section — so anyone with the link can read and write.
Subscribing to the List
const todoList = useCoState(ToDoList, listId, {
resolve: { $each: true },
});
const todos = computed(() => {
const list = todoList.value;
if (!list?.$isLoaded) return [];
return [...list].sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0));
});
useCoState is the core composable from community-jazz-vue. It takes a CoValue schema, an ID (which can be a ref), and a resolve query. It returns a Vue Ref that starts as unloaded, populates once available, and re-renders on both local and remote changes.
The resolve: { $each: true } tells Jazz to deeply load each item in the list. Without it, you’d get the list but each ToDo entry would be an unresolved reference. Items are sorted by their order field rather than their position in the CoList.
If you’re interested in how Vue composables like this are designed, I wrote about the patterns here:
Vue Composables Style Guide: Lessons from VueUse’s Codebase Vue Composables Style Guide: Lessons from VueUse's Codebase A practical guide for writing production-quality Vue 3 composables, distilled from studying VueUse's patterns for SSR safety, cleanup, and TypeScript.Dynamic Page Title
const pageTitle = useTitle("To Do");
watchEffect(() => {
const count = todos.value.filter((t) => !t.completed).length;
pageTitle.value = count > 0 ? `(${count}) To Do` : "To Do";
});
A small UX touch — the browser tab shows “(3) To Do” when there are 3 incomplete items.
CRUD Operations
const inputEl = useTemplateRef<HTMLInputElement>("inputEl");
const { focused: inputFocused } = useFocus(inputEl);
function addTodo() {
const list = todoList.value;
const title = newTitle.value.trim();
if (!title || !list?.$isLoaded) return;
const sorted = todos.value;
const lastOrder = sorted.length > 0 ? sorted[sorted.length - 1]!.order : null;
const order = generateKeyBetween(lastOrder, null);
list.$jazz.push({ title, completed: false, order });
newTitle.value = "";
inputFocused.value = true;
}
function toggleTodo(todo: co.loaded<typeof ToDo>) {
todo.$jazz.set("completed", !todo.completed);
}
function deleteTodo(todo: co.loaded<typeof ToDo>) {
const list = todoList.value;
if (!list?.$isLoaded) return;
const listIndex = [...list].findIndex((t) => t.$jazz.id === todo.$jazz.id);
if (listIndex !== -1) list.$jazz.remove(listIndex);
}
const copyLink = () => copy(window.location.href);
Every loaded CoValue exposes a $jazz accessor for mutations — $jazz.push(), $jazz.set(), $jazz.remove(). These mutations are applied locally and synced to all connected peers automatically. No “save” button, no API call.
addTodo generates a fractional index via generateKeyBetween(lastOrder, null), which creates a key that sorts after all existing items. deleteTodo finds the item by its CoValue ID instead of relying on the array index — since our todos computed is sorted by order, the display index won’t match the CoList position.
The Sync Lifecycle
Here’s what happens when you call a mutation like list.$jazz.push(...):
The mutation returns immediately — your UI updates before the sync even starts. When the data reaches other peers, their useCoState subscriptions fire and Vue re-renders on their end too.
The TodoApp.vue Template
Here’s the complete template that ties together all the script logic — the add form, the sortable list, the online toggle, and the shareable link:
<template>
<div class="min-h-screen bg-gray-950 flex items-start justify-center pt-16 px-4">
<div class="w-full max-w-md">
<!-- Header with online toggle -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-2">
<svg class="w-7 h-7 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
</svg>
<span class="text-white text-xl font-semibold tracking-tight">jazz</span>
</div>
<div class="flex items-center gap-2">
<span class="text-gray-300 text-sm">Online</span>
<button
@click="isOnline = !isOnline"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
isOnline ? 'bg-blue-600' : 'bg-gray-600',
]"
>
<span
:class="[
'inline-block h-4 w-4 rounded-full bg-white transition-transform',
isOnline ? 'translate-x-6' : 'translate-x-1',
]"
/>
</button>
</div>
</div>
<!-- Card -->
<div class="bg-gray-900 border border-gray-700 rounded-xl p-6">
<h1 class="text-3xl font-bold text-white mb-6">To Do</h1>
<div v-if="!todoList?.$isLoaded" class="text-gray-400 text-center py-8">
Loading...
</div>
<template v-else>
<form @submit.prevent="addTodo" class="mb-6 space-y-3">
<input
ref="inputEl"
v-model="newTitle"
placeholder="New task"
class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
type="submit"
class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Add
</button>
</form>
<ul ref="todoListEl" class="space-y-2">
<li
v-for="(todo, index) in todos"
:key="todo.$jazz.id"
class="group flex items-center gap-3 p-2 rounded-lg hover:bg-gray-800"
>
<span
class="drag-handle cursor-grab active:cursor-grabbing text-gray-600 group-hover:text-gray-400 transition-colors select-none"
title="Drag to reorder"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
<circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
</svg>
</span>
<input
type="checkbox"
:checked="todo.completed"
@change="toggleTodo(todo)"
class="h-4 w-4 rounded border-gray-600 bg-gray-800 text-blue-600 focus:ring-blue-500 focus:ring-offset-gray-900"
/>
<span
class="flex-1"
:class="todo.completed ? 'line-through text-gray-500' : 'text-gray-200'"
>
{{ todo.title }}
</span>
<button
@click="deleteTodo(todo)"
class="opacity-0 group-hover:opacity-100 transition-opacity text-gray-500 hover:text-red-400 p-1"
title="Delete"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</li>
</ul>
<p v-if="todos.length === 0" class="text-gray-500 text-center py-4">
No todos yet. Add one above!
</p>
<div v-if="listId" class="mt-6 flex items-center gap-2">
<p class="text-xs text-gray-500 break-all flex-1">
List ID: {{ listId }}
</p>
<button
@click="copyLink"
class="shrink-0 px-3 py-1 text-xs rounded-md border transition-colors"
:class="copied
? 'border-green-600 text-green-400'
: 'border-gray-600 text-gray-400 hover:text-gray-200 hover:border-gray-500'"
>
{{ copied ? "Copied!" : "Copy link" }}
</button>
</div>
</template>
</div>
</div>
</div>
</template>
In the template, :key="todo.$jazz.id" gives Vue a stable, globally unique key for each item. The ref="inputEl" connects to useFocus so the input regains focus after adding a todo. And @change="toggleTodo(todo)" shows how clean mutations are — no actions, no dispatching, no reducers.
Drag-and-Drop Reordering
We use useSortable from VueUse for drag-and-drop. The simplest approach would be to call splice on the CoList directly — remove the item from its old position, insert it at the new one. For a single user, that works fine.
The Problem with splice
Jazz’s CoList implements splice as a delete-plus-insert, not as an atomic move. That distinction matters as soon as two users are involved. If both users reorder the same item while offline, each one generates an independent delete-plus-insert. When they sync back up, the list CRDT sees two separate insert operations — and the item appears twice.
This is a fundamental issue with list CRDTs: there’s no “move” operation, only deletes and inserts. So we need a different strategy for ordering.
Fractional Indexing to the Rescue
Fractional indexing (by Rocicorp, based on this Observable post by David Greenspan) sidesteps the problem entirely. Instead of moving an item in the list, you store an order field on each item and update only that field when reordering. When you move an item between positions A and C, generateKeyBetween("a", "c") returns a key like "b" that sorts between them. The rest of the list stays untouched.
Because order is a field on a CoMap, updates use last-writer-wins semantics. The worst case during a concurrent edit is that one user’s reorder “wins” — but the item never duplicates.
Implementation
Here’s how it looks with useSortable:
const todoListEl = useTemplateRef<HTMLElement>("todoListEl");
useSortable(todoListEl, todos, {
handle: ".drag-handle",
animation: 150,
onUpdate: (e) => {
// Revert SortableJS DOM manipulation — let Vue re-render from data
removeNode(e.item);
insertNodeAt(e.from, e.item, e.oldIndex!);
const list = todoList.value;
if (!list?.$isLoaded || e.oldIndex == null || e.newIndex == null) return;
const sorted = todos.value;
const before = sorted[e.newIndex - 1]?.order ?? null;
const after = sorted[e.newIndex + (e.newIndex > e.oldIndex ? 1 : 0)]?.order ?? null;
const item = sorted[e.oldIndex];
if (item) item.$jazz.set("order", generateKeyBetween(before, after));
},
});
The onUpdate handler contains a subtle DOM revert pattern: SortableJS directly manipulates the DOM when the user drags an item, but our source of truth is the order field on each CoMap. So we first undo the DOM change, then update the order field. Vue’s computed re-sorts and re-renders correctly.
The reorder syncs to all peers just like any other mutation. If you’ve worked with cross-tab state syncing before, you’ll know how tricky this can be:
Building a Pinia Plugin for Cross-Tab State Syncing Building a Pinia Plugin for Cross-Tab State Syncing Learn how to create a Pinia plugin that synchronizes state across browser tabs using the BroadcastChannel API and Vue 3's Script Setup syntax.Running It
pnpm dev
Open two browser tabs. Add a todo in one, watch it appear instantly in the other. Toggle “Online” off, add more todos, toggle back on — they sync up. Copy the link, open it in an incognito window — same list.
Conclusion
The full project is four files of application code:
src/schema.ts(4 lines) — CoValue definitionssrc/App.vue(20 lines) — Provider + inspector + online togglesrc/TodoApp.vue(~80 lines script + ~125 lines template) — The complete appvite.config.ts(26 lines) — Build config with custom element support
What surprised me most coming from the Dexie.js approach is how little code you need. Jazz eliminates the entire backend-sync-API stack. You define your data, mutate it locally, and it syncs.
Two things worth highlighting: fractional indexing for drag-and-drop avoids the duplicate-item problem that CoList splice-based reordering can cause during concurrent offline edits. And Jazz Groups with addMember("everyone", "writer") make the shared URL actually accessible — without this, CoValues are private to the creator.
Check out the Jazz documentation for more, and the community-jazz-vue package (source code) for the full Vue API.
Source code: github.com/alexanderop/vue-jazz-todo-example