Next Talk: How to Build Local-First Apps with Vue

March 12, 2026 — Vue.js Amsterdam

Conference
Skip to content

Building a Real-Time Todo App with Jazz and Vue 3

Published: at 

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. local-firstarchitecturevue +1

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. vuedexieindexeddb +1

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:

  1. Counter.create({ count: 0 }) creates a new CoValue instance and persists it locally.
  2. useCoState(Counter, counterId) subscribes to that CoValue. It returns a reactive Ref that starts unloaded, populates once available, and re-renders on every change — just like Vue’s ref, but across the network.
  3. $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:

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:

Browser B (Tab 2)

Jazz Cloud

Browser A (Tab 1)

WebSocket

WebSocket

Vue App

Local IndexedDB

Sync Server

Persistent Store

Vue App

Local IndexedDB

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:

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. vuetypescript

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(...):

list.$jazz.push(...)

Local IndexedDB

Vue re-renders via shallowRef

Jazz Cloud Sync Server

Other Browsers

useCoState() triggers update

Vue re-renders

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. vuepinia

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:

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

Press Esc or click outside to close

Stay Updated!

Subscribe to my newsletter for more TypeScript, Vue, and web dev insights directly in your inbox.

  • Background information about the articles
  • Weekly Summary of all the interesting blog posts that I read
  • Small tips and trick
Subscribe Now