Join the Newsletter!

Exclusive content & updates. No spam.

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 Dexie, { type Table } from 'dexie'
import dexieCloud from 'dexie-cloud-addon'

export interface Todo {
  id?: string
  title: string
  completed: boolean
  createdAt: Date
}

export class TodoDB extends Dexie {
  todos!: Table<Todo>

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

  async configureSync(databaseUrl: string) {
    await this.cloud.configure({
      databaseUrl,
      requireAuth: true,
      tryUseServiceWorker: true,
    })
  }
}

export const db = new TodoDB()

if (!import.meta.env.VITE_DEXIE_CLOUD_URL) {
  throw new Error('VITE_DEXIE_CLOUD_URL environment variable is not defined')
}

db.configureSync(import.meta.env.VITE_DEXIE_CLOUD_URL).catch(console.error)

export const currentUser = db.cloud.currentUser
export const login = () => db.cloud.login()
export const logout = () => db.cloud.logout()

Creating the Todo Composable

import { db, type Todo } from '@/db/todo'
import { useObservable } from '@vueuse/rxjs'
import { liveQuery } from 'dexie'
import { from } from 'rxjs'
import { computed, ref } from 'vue'

export function useTodos() {
  const newTodoTitle = ref('')
  const error = ref<string | null>(null)

  const todos = useObservable<Todo[]>(
    from(liveQuery(() => db.todos.orderBy('createdAt').toArray())),
  )

  const completedTodos = computed(() =>
    todos.value?.filter(todo => todo.completed) ?? [],
  )

  const pendingTodos = computed(() =>
    todos.value?.filter(todo => !todo.completed) ?? [],
  )

  const addTodo = async () => {
    try {
      if (!newTodoTitle.value.trim())
        return

      await db.todos.add({
        title: newTodoTitle.value,
        completed: false,
        createdAt: new Date(),
      })

      newTodoTitle.value = ''
      error.value = null
    }
    catch (err) {
      error.value = 'Failed to add todo'
      console.error(err)
    }
  }

  const toggleTodo = async (todo: Todo) => {
    try {
      await db.todos.update(todo.id!, {
        completed: !todo.completed,
      })
      error.value = null
    }
    catch (err) {
      error.value = 'Failed to toggle todo'
      console.error(err)
    }
  }

  const deleteTodo = async (id: string) => {
    try {
      await db.todos.delete(id)
      error.value = null
    }
    catch (err) {
      error.value = 'Failed to delete todo'
      console.error(err)
    }
  }

  return {
    todos,
    newTodoTitle,
    error,
    completedTodos,
    pendingTodos,
    addTodo,
    toggleTodo,
    deleteTodo,
  }
}

Authentication Guard Component

<script setup lang="ts">

import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { currentUser, login, logout } from '@/db/todo'
import { Icon } from '@iconify/vue'
import { useObservable } from '@vueuse/rxjs'
import { computed, ref } from 'vue'

const user = useObservable(currentUser)
const isAuthenticated = computed(() => !!user.value)
const isLoading = ref(false)

async function handleLogin() {
  isLoading.value = true
  try {
    await login()
  }
  finally {
    isLoading.value = false
  }
}
</script>

<template>
  <div v-if="!isAuthenticated" class="flex flex-col items-center justify-center min-h-screen p-4 bg-background">
    <Card class="max-w-md w-full">
      <!-- Login form content -->
    </Card>
  </div>
  <template v-else>
    <div class="sticky top-0 z-20 bg-card border-b">
      <!-- User info and logout button -->
    </div>
    <slot />
  </template>
</template>

Better Architecture: Repository Pattern


export interface TodoRepository {
  getAll(): Promise<Todo[]>
  add(todo: Omit<Todo, 'id'>): Promise<string>
  update(id: string, todo: Partial<Todo>): Promise<void>
  delete(id: string): Promise<void>
  observe(): Observable<Todo[]>
}

export class DexieTodoRepository implements TodoRepository {
  constructor(private db: TodoDB) {}

  async getAll() {
    return this.db.todos.toArray()
  }

  observe() {
    return from(liveQuery(() => this.db.todos.orderBy('createdAt').toArray()))
  }

  async add(todo: Omit<Todo, 'id'>) {
    return this.db.todos.add(todo)
  }

  async update(id: string, todo: Partial<Todo>) {
    await this.db.todos.update(id, todo)
  }

  async delete(id: string) {
    await this.db.todos.delete(id)
  }
}

export function useTodos(repository: TodoRepository) {
  const newTodoTitle = ref('')
  const error = ref<string | null>(null)
  const todos = useObservable<Todo[]>(repository.observe())

  const addTodo = async () => {
    try {
      if (!newTodoTitle.value.trim()) return
      await repository.add({
        title: newTodoTitle.value,
        completed: false,
        createdAt: new Date(),
      })
      newTodoTitle.value = ''
      error.value = null
    }
    catch (err) {
      error.value = 'Failed to add todo'
      console.error(err)
    }
  }

  return {
    todos,
    newTodoTitle,
    error,
    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.

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

Most Related Posts