Ever been frustrated when your web app stops working because the internet connection dropped? That’s where local-first applications come in! In this guide, we’ll explore how to build robust, offline-capable apps using Vue 3 and Dexie.js. If you’re new to local-first development, check out my comprehensive introduction to local-first web development first.
What Makes an App “Local-First”?
Martin Kleppmann defines local-first software as systems where “the availability of another computer should never prevent you from working.” Think Notion’s desktop app or Figma’s offline mode - they store data locally first and seamlessly sync when online.
Three key principles:
- Works without internet connection
- Users stay productive when servers are down
- Data syncs smoothly when connectivity returns
The Architecture Behind Local-First Apps
Key decisions:
- How much data to store locally (full vs. partial dataset)
- How to handle multi-user conflict resolution
Enter Dexie.js: Your Local-First Swiss Army Knife
Dexie.js provides a robust offline-first architecture where database operations run against local IndexedDB first, ensuring responsiveness without internet connection.
Sync Strategies
- WebSocket Sync: Real-time updates for collaborative apps
- HTTP Long-Polling: Default sync mechanism, firewall-friendly
- Service Worker Sync: Optional background syncing when configured
Setting Up Dexie Cloud
To enable multi-device synchronization and real-time collaboration, we’ll use Dexie Cloud. Here’s how to set it up:
-
Create a Dexie Cloud Account:
- Visit https://dexie.org/cloud/
- Sign up for a free developer account
- Create a new database from the dashboard
-
Install Required Packages:
npm install dexie-cloud-addon
-
Configure Environment Variables: Create a
.env
file in your project root:VITE_DEXIE_CLOUD_URL=https://db.dexie.cloud/db/<your-db-id>
Replace
<your-db-id>
with the database ID from your Dexie Cloud dashboard. -
Enable Authentication: Dexie Cloud provides built-in authentication. You can:
- Use email/password authentication
- Integrate with OAuth providers
- Create custom authentication flows
The free tier includes:
- Up to 50MB of data per database
- Up to 1,000 sync operations per day
- Basic authentication and access control
- Real-time sync between devices
Building a Todo App
Let’s implement a practical example with a todo app:
Setting Up the Database
import import Dexie
Dexie, { type import Table
Table } from 'dexie'
import import dexieCloud
dexieCloud from 'dexie-cloud-addon'
export interface Todo {
Todo.id?: string | undefined
id?: string
Todo.title: string
title: string
Todo.completed: boolean
completed: boolean
Todo.createdAt: Date
createdAt: Date
}
export class class TodoDB
TodoDB extends import Dexie
Dexie {
TodoDB.todos: Table<Todo>
todos!: import Table
Table<Todo>
constructor() {
super('TodoDB', { addons: any[]
addons: [import dexieCloud
dexieCloud] })
this.version(1).stores({
todos: string
todos: '@id, title, completed, createdAt',
})
}
async TodoDB.configureSync(databaseUrl: string): Promise<void>
configureSync(databaseUrl: string
databaseUrl: string) {
await this.cloud.configure({
databaseUrl: string
databaseUrl,
requireAuth: boolean
requireAuth: true,
tryUseServiceWorker: boolean
tryUseServiceWorker: true,
})
}
}
export const const db: TodoDB
db = new constructor TodoDB(): TodoDB
TodoDB()
if (!import.meta.env.VITE_DEXIE_CLOUD_URL) {
throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('VITE_DEXIE_CLOUD_URL environment variable is not defined')
}
const db: TodoDB
db.TodoDB.configureSync(databaseUrl: string): Promise<void>
configureSync(import.meta.env.VITE_DEXIE_CLOUD_URL).Promise<void>.catch<void>(onrejected?: ((reason: any) => void | PromiseLike<void>) | null | undefined): Promise<void>
Attaches a callback for only the rejection of the Promise.catch(var console: Console
The `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.error(...data: any[]): void (+1 overload)
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static)error)
export const const currentUser: any
currentUser = const db: TodoDB
db.cloud.currentUser
export const const login: () => any
login = () => const db: TodoDB
db.cloud.login()
export const const logout: () => any
logout = () => const db: TodoDB
db.cloud.logout()
Creating the Todo Composable
import { import db
db, type import Todo
Todo } from '@/db/todo'
import { import useObservable
useObservable } from '@vueuse/rxjs'
import { import liveQuery
liveQuery } from 'dexie'
import { import from
from } from 'rxjs'
import { const computed: {
<T>(getter: ComputedGetter<T>, debugOptions?: DebuggerOptions): ComputedRef<T>;
<T, S = T>(options: WritableComputedOptions<T, S>, debugOptions?: DebuggerOptions): WritableComputedRef<T, S>;
}
computed, function ref<T>(value: T): [T] extends [Ref] ? IfAny<T, Ref<T>, T> : Ref<UnwrapRef<T>, UnwrapRef<T> | T> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref } from 'vue'
export function function useTodos(): {
todos: any;
newTodoTitle: Ref<string, string>;
error: Ref<string | null, string | null>;
completedTodos: ComputedRef<any>;
pendingTodos: ComputedRef<any>;
addTodo: () => Promise<...>;
toggleTodo: (todo: Todo) => Promise<...>;
deleteTodo: (id: string) => Promise<...>;
}
useTodos() {
const const newTodoTitle: Ref<string, string>
newTodoTitle = ref<string>(value: string): Ref<string, string> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref('')
const const error: Ref<string | null, string | null>
error = ref<string | null>(value: string | null): Ref<string | null, string | null> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref<string | null>(null)
const const todos: any
todos = import useObservable
useObservable<import Todo
Todo[]>(
import from
from(import liveQuery
liveQuery(() => import db
db.todos.orderBy('createdAt').toArray())),
)
const const completedTodos: ComputedRef<any>
completedTodos = computed<any>(getter: ComputedGetter<any>, debugOptions?: DebuggerOptions): ComputedRef<any> (+1 overload)
Takes a getter function and returns a readonly reactive ref object for the
returned value from the getter. It can also take an object with get and set
functions to create a writable ref object.computed(() =>
const todos: any
todos.value?.filter(todo: any
todo => todo: any
todo.completed) ?? [],
)
const const pendingTodos: ComputedRef<any>
pendingTodos = computed<any>(getter: ComputedGetter<any>, debugOptions?: DebuggerOptions): ComputedRef<any> (+1 overload)
Takes a getter function and returns a readonly reactive ref object for the
returned value from the getter. It can also take an object with get and set
functions to create a writable ref object.computed(() =>
const todos: any
todos.value?.filter(todo: any
todo => !todo: any
todo.completed) ?? [],
)
const const addTodo: () => Promise<void>
addTodo = async () => {
try {
if (!const newTodoTitle: Ref<string, string>
newTodoTitle.Ref<string, string>.value: string
value.String.trim(): string
Removes the leading and trailing white space and line terminator characters from a string.trim())
return
await import db
db.todos.add({
title: string
title: const newTodoTitle: Ref<string, string>
newTodoTitle.Ref<string, string>.value: string
value,
completed: boolean
completed: false,
createdAt: Date
createdAt: new var Date: DateConstructor
new () => Date (+4 overloads)
Date(),
})
const newTodoTitle: Ref<string, string>
newTodoTitle.Ref<string, string>.value: string
value = ''
const error: Ref<string | null, string | null>
error.Ref<string | null, string | null>.value: string | null
value = null
}
catch (function (local var) err: unknown
err) {
const error: Ref<string | null, string | null>
error.Ref<string | null, string | null>.value: string | null
value = 'Failed to add todo'
var console: Console
The `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.error(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to `stderr` with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html)
(the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)).
```js
const code = 5;
console.error('error #%d', code);
// Prints: error #5, to stderr
console.error('error', code);
// Prints: error 5, to stderr
```
If formatting elements (e.g. `%d`) are not found in the first string then
[`util.inspect()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilinspectobject-options) is called on each argument and the
resulting string values are concatenated. See [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)
for more information.error(function (local var) err: unknown
err)
}
}
const const toggleTodo: (todo: Todo) => Promise<void>
toggleTodo = async (todo: Todo
todo: import Todo
Todo) => {
try {
await import db
db.todos.update(todo: Todo
todo.id!, {
completed: boolean
completed: !todo: Todo
todo.completed,
})
const error: Ref<string | null, string | null>
error.Ref<string | null, string | null>.value: string | null
value = null
}
catch (function (local var) err: unknown
err) {
const error: Ref<string | null, string | null>
error.Ref<string | null, string | null>.value: string | null
value = 'Failed to toggle todo'
var console: Console
The `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.error(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to `stderr` with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html)
(the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)).
```js
const code = 5;
console.error('error #%d', code);
// Prints: error #5, to stderr
console.error('error', code);
// Prints: error 5, to stderr
```
If formatting elements (e.g. `%d`) are not found in the first string then
[`util.inspect()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilinspectobject-options) is called on each argument and the
resulting string values are concatenated. See [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)
for more information.error(function (local var) err: unknown
err)
}
}
const const deleteTodo: (id: string) => Promise<void>
deleteTodo = async (id: string
id: string) => {
try {
await import db
db.todos.delete(id: string
id)
const error: Ref<string | null, string | null>
error.Ref<string | null, string | null>.value: string | null
value = null
}
catch (function (local var) err: unknown
err) {
const error: Ref<string | null, string | null>
error.Ref<string | null, string | null>.value: string | null
value = 'Failed to delete todo'
var console: Console
The `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.error(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to `stderr` with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html)
(the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)).
```js
const code = 5;
console.error('error #%d', code);
// Prints: error #5, to stderr
console.error('error', code);
// Prints: error 5, to stderr
```
If formatting elements (e.g. `%d`) are not found in the first string then
[`util.inspect()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilinspectobject-options) is called on each argument and the
resulting string values are concatenated. See [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)
for more information.error(function (local var) err: unknown
err)
}
}
return {
todos: any
todos,
newTodoTitle: Ref<string, string>
newTodoTitle,
error: Ref<string | null, string | null>
error,
completedTodos: ComputedRef<any>
completedTodos,
pendingTodos: ComputedRef<any>
pendingTodos,
addTodo: () => Promise<void>
addTodo,
toggleTodo: (todo: Todo) => Promise<void>
toggleTodo,
deleteTodo: (id: string) => Promise<void>
deleteTodo,
}
}
Authentication Guard Component
<script setup lang="ts">
import { import Button
Button } from '@/components/ui/button'
import { import Card
Card, import CardContent
CardContent, import CardDescription
CardDescription, import CardFooter
CardFooter, import CardHeader
CardHeader, import CardTitle
CardTitle } from '@/components/ui/card'
import { import currentUser
currentUser, import login
login, import logout
logout } from '@/db/todo'
import { import Icon
Icon } from '@iconify/vue'
import { import useObservable
useObservable } from '@vueuse/rxjs'
import { const computed: {
<T>(getter: ComputedGetter<T>, debugOptions?: DebuggerOptions): ComputedRef<T>;
<T, S = T>(options: WritableComputedOptions<T, S>, debugOptions?: DebuggerOptions): WritableComputedRef<T, S>;
}
computed, function ref<T>(value: T): [T] extends [Ref] ? IfAny<T, Ref<T>, T> : Ref<UnwrapRef<T>, UnwrapRef<T> | T> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref } from 'vue'
const const user: any
user = import useObservable
useObservable(import currentUser
currentUser)
const const isAuthenticated: ComputedRef<boolean>
isAuthenticated = computed<boolean>(getter: ComputedGetter<boolean>, debugOptions?: DebuggerOptions): ComputedRef<boolean> (+1 overload)
Takes a getter function and returns a readonly reactive ref object for the
returned value from the getter. It can also take an object with get and set
functions to create a writable ref object.computed(() => !!const user: any
user.value)
const const isLoading: Ref<boolean, boolean>
isLoading = ref<boolean>(value: boolean): Ref<boolean, boolean> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref(false)
async function function handleLogin(): Promise<void>
handleLogin() {
const isLoading: Ref<boolean, boolean>
isLoading.Ref<boolean, boolean>.value: boolean
value = true
try {
await import login
login()
}
finally {
const isLoading: Ref<boolean, boolean>
isLoading.Ref<boolean, boolean>.value: boolean
value = false
}
}
</script>
<template>
<div: HTMLAttributes & ReservedProps
div v-if="!const isAuthenticated: ComputedRef<boolean>
isAuthenticated" HTMLAttributes.class?: any
class="flex flex-col items-center justify-center min-h-screen p-4 bg-background">
<import Card
Card class: string
class="max-w-md w-full">
<!-- Login form content -->
</Card>
</div: HTMLAttributes & ReservedProps
div>
<template v-else>
<div: HTMLAttributes & ReservedProps
div HTMLAttributes.class?: any
class="sticky top-0 z-20 bg-card border-b">
<!-- User info and logout button -->
</div: HTMLAttributes & ReservedProps
div>
<default?(_: {}): any
slot />
</template>
</template>
Better Architecture: Repository Pattern
export interface TodoRepository {
TodoRepository.getAll(): Promise<Todo[]>
getAll(): interface Promise<T>
Represents the completion of an asynchronous operationPromise<type Todo = /*unresolved*/ any
Todo[]>
TodoRepository.add(todo: Omit<Todo, "id">): Promise<string>
add(todo: Omit<Todo, "id">
todo: type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; }
Construct a type with the properties of T except for those in type K.Omit<type Todo = /*unresolved*/ any
Todo, 'id'>): interface Promise<T>
Represents the completion of an asynchronous operationPromise<string>
TodoRepository.update(id: string, todo: Partial<Todo>): Promise<void>
update(id: string
id: string, todo: Todo
todo: type Partial<T> = { [P in keyof T]?: T[P] | undefined; }
Make all properties in T optionalPartial<type Todo = /*unresolved*/ any
Todo>): interface Promise<T>
Represents the completion of an asynchronous operationPromise<void>
TodoRepository.delete(id: string): Promise<void>
delete(id: string
id: string): interface Promise<T>
Represents the completion of an asynchronous operationPromise<void>
TodoRepository.observe(): Observable<Todo[]>
observe(): type Observable = /*unresolved*/ any
Observable<type Todo = /*unresolved*/ any
Todo[]>
}
export class class DexieTodoRepository
DexieTodoRepository implements TodoRepository {
constructor(private DexieTodoRepository.db: TodoDB
db: type TodoDB = /*unresolved*/ any
TodoDB) {}
async DexieTodoRepository.getAll(): Promise<any>
getAll() {
return this.DexieTodoRepository.db: TodoDB
db.todos.toArray()
}
DexieTodoRepository.observe(): any
observe() {
return from(liveQuery(() => this.DexieTodoRepository.db: TodoDB
db.todos.orderBy('createdAt').toArray()))
}
async DexieTodoRepository.add(todo: Omit<Todo, "id">): Promise<any>
add(todo: Omit<Todo, "id">
todo: type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; }
Construct a type with the properties of T except for those in type K.Omit<type Todo = /*unresolved*/ any
Todo, 'id'>) {
return this.DexieTodoRepository.db: TodoDB
db.todos.add(todo: Omit<Todo, "id">
todo)
}
async DexieTodoRepository.update(id: string, todo: Partial<Todo>): Promise<void>
update(id: string
id: string, todo: Todo
todo: type Partial<T> = { [P in keyof T]?: T[P] | undefined; }
Make all properties in T optionalPartial<type Todo = /*unresolved*/ any
Todo>) {
await this.DexieTodoRepository.db: TodoDB
db.todos.update(id: string
id, todo: Todo
todo)
}
async DexieTodoRepository.delete(id: string): Promise<void>
delete(id: string
id: string) {
await this.DexieTodoRepository.db: TodoDB
db.todos.delete(id: string
id)
}
}
export function function useTodos(repository: TodoRepository): {
todos: any;
newTodoTitle: any;
error: any;
addTodo: () => Promise<void>;
}
useTodos(repository: TodoRepository
repository: TodoRepository) {
const const newTodoTitle: any
newTodoTitle = ref('')
const const error: any
error = ref<string | null>(null)
const const todos: any
todos = useObservable<type Todo = /*unresolved*/ any
Todo[]>(repository: TodoRepository
repository.TodoRepository.observe(): Observable<Todo[]>
observe())
const const addTodo: () => Promise<void>
addTodo = async () => {
try {
if (!const newTodoTitle: any
newTodoTitle.value.trim()) return
await repository: TodoRepository
repository.TodoRepository.add(todo: Omit<Todo, "id">): Promise<string>
add({
title: any
title: const newTodoTitle: any
newTodoTitle.value,
completed: boolean
completed: false,
createdAt: Date
createdAt: new var Date: DateConstructor
new () => Date (+4 overloads)
Date(),
})
const newTodoTitle: any
newTodoTitle.value = ''
const error: any
error.value = null
}
catch (function (local var) err: unknown
err) {
const error: any
error.value = 'Failed to add todo'
var console: Console
The `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.error(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to `stderr` with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html)
(the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)).
```js
const code = 5;
console.error('error #%d', code);
// Prints: error #5, to stderr
console.error('error', code);
// Prints: error 5, to stderr
```
If formatting elements (e.g. `%d`) are not found in the first string then
[`util.inspect()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilinspectobject-options) is called on each argument and the
resulting string values are concatenated. See [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)
for more information.error(function (local var) err: unknown
err)
}
}
return {
todos: any
todos,
newTodoTitle: any
newTodoTitle,
error: any
error,
addTodo: () => Promise<void>
addTodo,
// ... other methods
}
}
Understanding the IndexedDB Structure
When you inspect your application in the browser’s DevTools under the “Application” tab > “IndexedDB”, you’ll see a database named “TodoDB-zy02f1…” with several object stores:
Internal Dexie Stores (Prefixed with $)
Note: These stores are only created when using Dexie Cloud for sync functionality.
- $baseRevs: Keeps track of base revisions for synchronization
- $jobs: Manages background synchronization tasks
- $logins: Stores authentication data including your last login timestamp
- $members_mutations: Tracks changes to member data for sync
- $realms_mutations: Tracks changes to realm/workspace data
- $roles_mutations: Tracks changes to role assignments
- $syncState: Maintains the current synchronization state
- $todos_mutations: Records all changes made to todos for sync and conflict resolution
Application Data Stores
- members: Contains user membership data with compound indexes:
[userId+realmId]
: For quick user-realm lookups[email+realmId]
: For email-based queriesrealmId
: For realm-specific queries
- realms: Stores available workspaces
- roles: Manages user role assignments
- todos: Your actual todo items containing:
- Title
- Completed status
- Creation timestamp
Here’s how a todo item actually looks in IndexedDB:
{
"id": "tds0PI7ogcJqpZ1JCly0qyAheHmcom",
"title": "test",
"completed": false,
"createdAt": "Tue Jan 21 2025 08:40:59 GMT+0100 (Central Europe)",
"owner": "opalic.alexander@gmail.com",
"realmId": "opalic.alexander@gmail.com"
}
Each todo gets a unique id
generated by Dexie, and when using Dexie Cloud, additional fields like owner
and realmId
are automatically added for multi-user support.
Each store in IndexedDB acts like a table in a traditional database, but is optimized for client-side storage and offline operations. The $
-prefixed stores are managed automatically by Dexie.js to handle:
- Offline Persistence: Your todos are stored locally
- Multi-User Support: User data in
members
androles
- Sync Management: All
*_mutations
stores track changes - Authentication: Login state in
$logins
Understanding Dexie’s Merge Conflict Resolution
Dexie’s conflict resolution system is sophisticated and field-aware, meaning:
- Changes to different fields of the same record can be merged automatically
- Conflicts in the same field use last-write-wins with server priority
- Deletions always take precedence over updates to prevent “zombie” records
This approach ensures smooth collaboration while maintaining data consistency across devices and users.
Conclusion
This guide demonstrated building local-first applications with Dexie.js and Vue. For simpler applications like todo lists or note-taking apps, Dexie.js provides an excellent balance of features and simplicity. For more complex needs similar to Linear, consider building a custom sync engine.
Find the complete example code on GitHub.