Skip to content

Building Local-First Apps with Vue and Dexie.js

Published: at 

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:

  1. Works without internet connection
  2. Users stay productive when servers are down
  3. Data syncs smoothly when connectivity returns

The Architecture Behind Local-First Apps

Central Server

Client Device

Client Device

UI

Local Data

UI

Local Data

Server Data

Sync Service

Local-First Architecture with Central Server

Key decisions:

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.

Dexie Cloud

Client

Dexie Sync

Application

Dexie.js

IndexedDB

Revision Tracking

Sync Queue

Auth Service

Data Store

Replication Log

Dexie.js Local-First Implementation

Sync Strategies

  1. WebSocket Sync: Real-time updates for collaborative apps
  2. HTTP Long-Polling: Default sync mechanism, firewall-friendly
  3. 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:

  1. Create a Dexie Cloud Account:

  2. Install Required Packages:

    npm install dexie-cloud-addon
    
  3. 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.

  4. 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:

Building a Todo App

Let’s implement a practical example with a todo app:

Backend Services

Dexie.js Layer

Vue Application

App.vue

TodoList.vue
Component

useTodo.ts
Composable

database.ts
Dexie Configuration

IndexedDB

Dexie Sync Engine

Server

Server Database

Setting Up the Database

import import DexieDexie, { type import TableTable } from 'dexie'
import import dexieClouddexieCloud from 'dexie-cloud-addon'

export interface Todo {
  Todo.id?: string | undefinedid?: string
  Todo.title: stringtitle: string
  Todo.completed: booleancompleted: boolean
  Todo.createdAt: DatecreatedAt: Date
}

export class class TodoDBTodoDB extends import DexieDexie {
  TodoDB.todos: Table<Todo>todos!: import TableTable<Todo>

  constructor() {
    super('TodoDB', { addons: any[]addons: [import dexieClouddexieCloud] })
    
    this.version(1).stores({
      todos: stringtodos: '@id, title, completed, createdAt',
    })
  }

  async TodoDB.configureSync(databaseUrl: string): Promise<void>configureSync(databaseUrl: stringdatabaseUrl: string) {
    await this.cloud.configure({
      databaseUrl: stringdatabaseUrl,
      requireAuth: booleanrequireAuth: true,
      tryUseServiceWorker: booleantryUseServiceWorker: true,
    })
  }
}

export const const db: TodoDBdb = new constructor TodoDB(): TodoDBTodoDB()

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: TodoDBdb.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.
@paramonrejected The callback to execute when the Promise is rejected.@returnsA Promise for the completion of the callback.
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 ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
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: anycurrentUser = const db: TodoDBdb.cloud.currentUser export const const login: () => anylogin = () => const db: TodoDBdb.cloud.login() export const const logout: () => anylogout = () => const db: TodoDBdb.cloud.logout()

Creating the Todo Composable

import { import dbdb, type import TodoTodo } from '@/db/todo'
import { import useObservableuseObservable } from '@vueuse/rxjs'
import { import liveQueryliveQuery } from 'dexie'
import { import fromfrom } 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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
<string | null>(null)
const const todos: anytodos = import useObservableuseObservable<import TodoTodo[]>( import fromfrom(import liveQueryliveQuery(() => import dbdb.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.
@example```js // Creating a readonly computed ref: const count = ref(1) const plusOne = computed(() => count.value + 1) console.log(plusOne.value) // 2 plusOne.value++ // error ``` ```js // Creating a writable computed ref: const count = ref(1) const plusOne = computed({ get: () => count.value + 1, set: (val) => { count.value = val - 1 } }) plusOne.value = 1 console.log(count.value) // 0 ```@paramgetter - Function that produces the next value.@paramdebugOptions - For debugging. See {@link https://vuejs.org/guide/extras/reactivity-in-depth.html#computed-debugging}.@see{@link https://vuejs.org/api/reactivity-core.html#computed}
computed
(() =>
const todos: anytodos.value?.filter(todo: anytodo => todo: anytodo.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.
@example```js // Creating a readonly computed ref: const count = ref(1) const plusOne = computed(() => count.value + 1) console.log(plusOne.value) // 2 plusOne.value++ // error ``` ```js // Creating a writable computed ref: const count = ref(1) const plusOne = computed({ get: () => count.value + 1, set: (val) => { count.value = val - 1 } }) plusOne.value = 1 console.log(count.value) // 0 ```@paramgetter - Function that produces the next value.@paramdebugOptions - For debugging. See {@link https://vuejs.org/guide/extras/reactivity-in-depth.html#computed-debugging}.@see{@link https://vuejs.org/api/reactivity-core.html#computed}
computed
(() =>
const todos: anytodos.value?.filter(todo: anytodo => !todo: anytodo.completed) ?? [], ) const const addTodo: () => Promise<void>addTodo = async () => { try { if (!const newTodoTitle: Ref<string, string>newTodoTitle.Ref<string, string>.value: stringvalue.String.trim(): string
Removes the leading and trailing white space and line terminator characters from a string.
trim
())
return await import dbdb.todos.add({ title: stringtitle: const newTodoTitle: Ref<string, string>newTodoTitle.Ref<string, string>.value: stringvalue, completed: booleancompleted: false, createdAt: DatecreatedAt: new
var Date: DateConstructor
new () => Date (+4 overloads)
Date
(),
}) const newTodoTitle: Ref<string, string>newTodoTitle.Ref<string, string>.value: stringvalue = '' const error: Ref<string | null, string | null>error.Ref<string | null, string | null>.value: string | nullvalue = null } catch (function (local var) err: unknownerr) { const error: Ref<string | null, string | null>error.Ref<string | null, string | null>.value: string | nullvalue = '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 ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
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.
@sincev0.1.100
error
(function (local var) err: unknownerr)
} } const const toggleTodo: (todo: Todo) => Promise<void>toggleTodo = async (todo: Todotodo: import TodoTodo) => { try { await import dbdb.todos.update(todo: Todotodo.id!, { completed: booleancompleted: !todo: Todotodo.completed, }) const error: Ref<string | null, string | null>error.Ref<string | null, string | null>.value: string | nullvalue = null } catch (function (local var) err: unknownerr) { const error: Ref<string | null, string | null>error.Ref<string | null, string | null>.value: string | nullvalue = '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 ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
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.
@sincev0.1.100
error
(function (local var) err: unknownerr)
} } const const deleteTodo: (id: string) => Promise<void>deleteTodo = async (id: stringid: string) => { try { await import dbdb.todos.delete(id: stringid) const error: Ref<string | null, string | null>error.Ref<string | null, string | null>.value: string | nullvalue = null } catch (function (local var) err: unknownerr) { const error: Ref<string | null, string | null>error.Ref<string | null, string | null>.value: string | nullvalue = '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 ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
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.
@sincev0.1.100
error
(function (local var) err: unknownerr)
} } return { todos: anytodos, 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 ButtonButton } from '@/components/ui/button'
import { import CardCard, import CardContentCardContent, import CardDescriptionCardDescription, import CardFooterCardFooter, import CardHeaderCardHeader, import CardTitleCardTitle } from '@/components/ui/card'
import { import currentUsercurrentUser, import loginlogin, import logoutlogout } from '@/db/todo'
import { import IconIcon } from '@iconify/vue'
import { import useObservableuseObservable } 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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
} from 'vue'
const const user: anyuser = import useObservableuseObservable(import currentUsercurrentUser) 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.
@example```js // Creating a readonly computed ref: const count = ref(1) const plusOne = computed(() => count.value + 1) console.log(plusOne.value) // 2 plusOne.value++ // error ``` ```js // Creating a writable computed ref: const count = ref(1) const plusOne = computed({ get: () => count.value + 1, set: (val) => { count.value = val - 1 } }) plusOne.value = 1 console.log(count.value) // 0 ```@paramgetter - Function that produces the next value.@paramdebugOptions - For debugging. See {@link https://vuejs.org/guide/extras/reactivity-in-depth.html#computed-debugging}.@see{@link https://vuejs.org/api/reactivity-core.html#computed}
computed
(() => !!const user: anyuser.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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(false)
async function function handleLogin(): Promise<void>handleLogin() { const isLoading: Ref<boolean, boolean>isLoading.Ref<boolean, boolean>.value: booleanvalue = true try { await import loginlogin() } finally { const isLoading: Ref<boolean, boolean>isLoading.Ref<boolean, boolean>.value: booleanvalue = false } } </script> <template> <div: HTMLAttributes & ReservedPropsdiv v-if="!const isAuthenticated: ComputedRef<boolean>isAuthenticated" HTMLAttributes.class?: anyclass="flex flex-col items-center justify-center min-h-screen p-4 bg-background"> <import CardCard class: stringclass="max-w-md w-full"> <!-- Login form content --> </Card> </div: HTMLAttributes & ReservedPropsdiv> <template v-else> <div: HTMLAttributes & ReservedPropsdiv HTMLAttributes.class?: anyclass="sticky top-0 z-20 bg-card border-b"> <!-- User info and logout button --> </div: HTMLAttributes & ReservedPropsdiv> <default?(_: {}): anyslot /> </template> </template>

Better Architecture: Repository Pattern

export interface TodoRepository {
  TodoRepository.getAll(): Promise<Todo[]>getAll(): interface Promise<T>
Represents the completion of an asynchronous operation
Promise
<type Todo = /*unresolved*/ anyTodo[]>
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*/ anyTodo, 'id'>): interface Promise<T>
Represents the completion of an asynchronous operation
Promise
<string>
TodoRepository.update(id: string, todo: Partial<Todo>): Promise<void>update(id: stringid: string, todo: Todotodo: type Partial<T> = { [P in keyof T]?: T[P] | undefined; }
Make all properties in T optional
Partial
<type Todo = /*unresolved*/ anyTodo>): interface Promise<T>
Represents the completion of an asynchronous operation
Promise
<void>
TodoRepository.delete(id: string): Promise<void>delete(id: stringid: string): interface Promise<T>
Represents the completion of an asynchronous operation
Promise
<void>
TodoRepository.observe(): Observable<Todo[]>observe(): type Observable = /*unresolved*/ anyObservable<type Todo = /*unresolved*/ anyTodo[]> } export class class DexieTodoRepositoryDexieTodoRepository implements TodoRepository { constructor(private DexieTodoRepository.db: TodoDBdb: type TodoDB = /*unresolved*/ anyTodoDB) {} async DexieTodoRepository.getAll(): Promise<any>getAll() { return this.DexieTodoRepository.db: TodoDBdb.todos.toArray() } DexieTodoRepository.observe(): anyobserve() { return from(liveQuery(() => this.DexieTodoRepository.db: TodoDBdb.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*/ anyTodo, 'id'>) {
return this.DexieTodoRepository.db: TodoDBdb.todos.add(todo: Omit<Todo, "id">todo) } async DexieTodoRepository.update(id: string, todo: Partial<Todo>): Promise<void>update(id: stringid: string, todo: Todotodo: type Partial<T> = { [P in keyof T]?: T[P] | undefined; }
Make all properties in T optional
Partial
<type Todo = /*unresolved*/ anyTodo>) {
await this.DexieTodoRepository.db: TodoDBdb.todos.update(id: stringid, todo: Todotodo) } async DexieTodoRepository.delete(id: string): Promise<void>delete(id: stringid: string) { await this.DexieTodoRepository.db: TodoDBdb.todos.delete(id: stringid) } } export function
function useTodos(repository: TodoRepository): {
    todos: any;
    newTodoTitle: any;
    error: any;
    addTodo: () => Promise<void>;
}
useTodos
(repository: TodoRepositoryrepository: TodoRepository) {
const const newTodoTitle: anynewTodoTitle = ref('') const const error: anyerror = ref<string | null>(null) const const todos: anytodos = useObservable<type Todo = /*unresolved*/ anyTodo[]>(repository: TodoRepositoryrepository.TodoRepository.observe(): Observable<Todo[]>observe()) const const addTodo: () => Promise<void>addTodo = async () => { try { if (!const newTodoTitle: anynewTodoTitle.value.trim()) return await repository: TodoRepositoryrepository.TodoRepository.add(todo: Omit<Todo, "id">): Promise<string>add({ title: anytitle: const newTodoTitle: anynewTodoTitle.value, completed: booleancompleted: false, createdAt: DatecreatedAt: new
var Date: DateConstructor
new () => Date (+4 overloads)
Date
(),
}) const newTodoTitle: anynewTodoTitle.value = '' const error: anyerror.value = null } catch (function (local var) err: unknownerr) { const error: anyerror.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 ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
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.
@sincev0.1.100
error
(function (local var) err: unknownerr)
} } return { todos: anytodos, newTodoTitle: anynewTodoTitle, error: anyerror, 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.

Application Data Stores

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:

  1. Offline Persistence: Your todos are stored locally
  2. Multi-User Support: User data in members and roles
  3. Sync Management: All *_mutations stores track changes
  4. Authentication: Login state in $logins

Understanding Dexie’s Merge Conflict Resolution

Yes

No

Detect Change Conflict

Different Fields?

Auto-Merge Changes

Same Field Conflict

Apply Server Version
Last-Write-Wins

Delete Operation

Always Takes Priority
Over Updates

Dexie’s conflict resolution system is sophisticated and field-aware, meaning:

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.

Related Posts