Want to build web apps that work offline? While browsers have built-in storage options like IndexedDB, they're not great for complex data operations. This guide shows you how to use SQLite with Vue 3 to build powerful offline-first apps.
๐ What We'll Build
- A Vue 3 app with SQLite that works offline
- A simple query playground to test SQLite
- Everything runs in the browser - no server needed!
๐ Want the code? Get the complete example at github.com/alexanderop/sqlite-vue-example
๐๏ธ Why SQLite?
Browser storage like IndexedDB is okay, but SQLite is better because:
- It's a real SQL database in your browser
- Your data stays safe even when offline
- You can use normal SQL queries
- It handles complex data relationships well
๐ ๏ธ How It Works
We'll use three main technologies:
- SQLite Wasm: SQLite converted to run in browsers
- Web Workers: Runs database code without freezing your app
- Origin Private File System: A secure place to store your database
Here's how they work together:
๐ Implementation Guide
Let's build this step by step, starting with the core SQLite functionality and then creating a playground to test it.
Step 1: Install Dependencies
First, install the required SQLite WASM package:
npm install @sqlite.org/sqlite-wasm
Step 2: Configure Vite
Create or update your vite.config.ts
file to support WebAssembly and cross-origin isolation:
import { defineConfig } from 'vite'
export default defineConfig(() => ({
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
optimizeDeps: {
exclude: ['@sqlite.org/sqlite-wasm'],
},
}))
This configuration is crucial for SQLite WASM to work properly:
-
Cross-Origin Headers:
-
Cross-Origin-Opener-Policy
andCross-Origin-Embedder-Policy
headers enable "cross-origin isolation" - This is required for using SharedArrayBuffer, which SQLite WASM needs for optimal performance
- Without these headers, the WebAssembly implementation might fail or perform poorly
-
-
Dependency Optimization:
-
optimizeDeps.exclude
tells Vite not to pre-bundle the SQLite WASM package - This is necessary because the WASM files need to be loaded dynamically at runtime
- Pre-bundling would break the WASM initialization process
-
Step 3: Add TypeScript Types
Since @sqlite.org/sqlite-wasm
doesn't include TypeScript types for Sqlite3Worker1PromiserConfig, we need to create our own. Create a new file types/sqlite-wasm.d.ts
:
Define this as a d.ts file so that TypeScript knows about it.
import type { Worker } from 'node:worker_threads'
declare module '@sqlite.org/sqlite-wasm' {
type OnreadyFunction = () => void
type Sqlite3Worker1PromiserConfig = {
onready?: OnreadyFunction
worker?: Worker | (() => Worker)
generateMessageId?: (messageObject: unknown) => string
debug?: (...args: any[]) => void
onunhandled?: (event: MessageEvent) => void
}
type DbId = string | undefined
type PromiserMethods = {
'config-get': {
args: Record<string, never>
result: {
dbID: DbId
version: {
libVersion: string
sourceId: string
libVersionNumber: number
downloadVersion: number
}
bigIntEnabled: boolean
opfsEnabled: boolean
vfsList: string[]
}
}
'open': {
args: Partial<{
filename?: string
vfs?: string
}>
result: {
dbId: DbId
filename: string
persistent: boolean
vfs: string
}
}
'exec': {
args: {
sql: string
dbId?: DbId
bind?: unknown[]
returnValue?: string
}
result: {
dbId: DbId
sql: string
bind: unknown[]
returnValue: string
resultRows?: unknown[][]
}
}
}
type PromiserResponseSuccess<T extends keyof PromiserMethods> = {
type: T
result: PromiserMethods[T]['result']
messageId: string
dbId: DbId
workerReceivedTime: number
workerRespondTime: number
departureTime: number
}
type PromiserResponseError = {
type: 'error'
result: {
operation: string
message: string
errorClass: string
input: object
stack: unknown[]
}
messageId: string
dbId: DbId
}
type PromiserResponse<T extends keyof PromiserMethods> =
| PromiserResponseSuccess<T>
| PromiserResponseError
type Promiser = <T extends keyof PromiserMethods>(
messageType: T,
messageArguments: PromiserMethods[T]['args'],
) => Promise<PromiserResponse<T>>
export function sqlite3Worker1Promiser(
config?: Sqlite3Worker1PromiserConfig | OnreadyFunction,
): Promiser
}
Step 4: Create the SQLite Composable
The core of our implementation is the useSQLite
composable. This will handle all database operations:
//@noErrors
import type { DbId } from '@sqlite.org/sqlite-wasm'
import { sqlite3Worker1Promiser } from '@sqlite.org/sqlite-wasm'
import { ref } from 'vue'
const databaseConfig = {
filename: 'file:mydb.sqlite3?vfs=opfs',
tables: {
test: {
name: 'test_table',
schema: `
CREATE TABLE IF NOT EXISTS test_table (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`,
},
},
} as const
export function useSQLite() {
const isLoading = ref(false)
const error = ref<Error | null>(null)
const isInitialized = ref(false)
let promiser: ReturnType<typeof sqlite3Worker1Promiser> | null = null
let dbId: string | null = null
async function initialize() {
if (isInitialized.value) return true
isLoading.value = true
error.value = null
try {
// Initialize the SQLite worker
promiser = await new Promise((resolve) => {
const _promiser = sqlite3Worker1Promiser({
onready: () => resolve(_promiser),
})
})
if (!promiser) throw new Error('Failed to initialize promiser')
// Get configuration and open database
await promiser('config-get', {})
const openResponse = await promiser('open', {
filename: databaseConfig.filename,
})
if (openResponse.type === 'error') {
throw new Error(openResponse.result.message)
}
dbId = openResponse.result.dbId as string
// Create initial tables
await promiser('exec', {
dbId,
sql: databaseConfig.tables.test.schema,
})
isInitialized.value = true
return true
}
catch (err) {
error.value = err instanceof Error ? err : new Error('Unknown error')
throw error.value
}
finally {
isLoading.value = false
}
}
async function executeQuery(sql: string, params: unknown[] = []) {
if (!dbId || !promiser) {
await initialize()
}
isLoading.value = true
error.value = null
try {
const result = await promiser!('exec', {
dbId: dbId as DbId,
sql,
bind: params,
returnValue: 'resultRows',
})
if (result.type === 'error') {
throw new Error(result.result.message)
}
return result
}
catch (err) {
error.value = err instanceof Error ? err : new Error('Query execution failed')
throw error.value
}
finally {
isLoading.value = false
}
}
return {
isLoading,
error,
isInitialized,
executeQuery,
}
}
Step 5: Create a SQLite Playground Component
Now let's create a component to test our SQLite implementation:
<script setup lang="ts">
import { useSQLite } from '@/composables/useSQLite'
import { ref } from 'vue'
const { isLoading, error, executeQuery } = useSQLite()
const sqlQuery = ref('SELECT * FROM test_table')
const queryResult = ref<any[]>([])
const queryError = ref<string | null>(null)
// Predefined example queries for testing
const exampleQueries = [
{ title: 'Select all', query: 'SELECT * FROM test_table' },
{ title: 'Insert', query: "INSERT INTO test_table (name) VALUES ('New Test Item')" },
{ title: 'Update', query: "UPDATE test_table SET name = 'Updated Item' WHERE name LIKE 'New%'" },
{ title: 'Delete', query: "DELETE FROM test_table WHERE name = 'Updated Item'" },
]
async function runQuery() {
queryError.value = null
queryResult.value = []
try {
const result = await executeQuery(sqlQuery.value)
const isSelect = sqlQuery.value.trim().toLowerCase().startsWith('select')
if (isSelect) {
queryResult.value = result?.result.resultRows || []
}
else {
// After mutation, fetch updated data
queryResult.value = (await executeQuery('SELECT * FROM test_table'))?.result.resultRows || []
}
}
catch (err) {
queryError.value = err instanceof Error ? err.message : 'An error occurred'
}
}
</script>
<template>
<div class="max-w-7xl mx-auto px-4 py-6">
<h2 class="text-2xl font-bold">SQLite Playground</h2>
<!-- Example queries -->
<div class="mt-4">
<h3 class="text-sm font-medium">Example Queries:</h3>
<div class="flex gap-2 mt-2">
<button
v-for="example in exampleQueries"
:key="example.title"
class="px-3 py-1 text-sm rounded-full bg-gray-100 hover:bg-gray-200"
@click="sqlQuery = example.query"
>
{{ example.title }}
</button>
</div>
</div>
<!-- Query input -->
<div class="mt-6">
<textarea
v-model="sqlQuery"
rows="4"
class="w-full px-4 py-3 rounded-lg font-mono text-sm"
:disabled="isLoading"
/>
<button
:disabled="isLoading"
class="mt-2 px-4 py-2 rounded-lg bg-blue-600 text-white"
@click="runQuery"
>
{{ isLoading ? 'Running...' : 'Run Query' }}
</button>
</div>
<!-- Error display -->
<div
v-if="error || queryError"
class="mt-4 p-4 rounded-lg bg-red-50 text-red-600"
>
{{ error?.message || queryError }}
</div>
<!-- Results table -->
<div v-if="queryResult.length" class="mt-4">
<h3 class="text-lg font-semibold">Results:</h3>
<div class="mt-2 overflow-x-auto">
<table class="w-full">
<thead>
<tr>
<th
v-for="column in Object.keys(queryResult[0])"
:key="column"
class="px-4 py-2 text-left"
>
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in queryResult"
:key="index"
>
<td
v-for="column in Object.keys(row)"
:key="column"
class="px-4 py-2"
>
{{ row[column] }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
๐ฏ Real-World Example: Notion's SQLite Implementation
Notion recently shared how they implemented SQLite in their web application, providing some valuable insights:
Performance Improvements
- 20% faster page navigation across all modern browsers
- Even greater improvements for users with slower connections:
Multi-Tab Architecture
Notion solved the challenge of handling multiple browser tabs with an innovative approach:
- Each tab has its own Web Worker for SQLite operations
- A SharedWorker manages which tab is "active"
- Only one tab can write to SQLite at a time
- Queries from all tabs are routed through the active tab's Worker
Key Learnings from Notion
- Async Loading: They load the WASM SQLite library asynchronously to avoid blocking initial page load
- Race Conditions: They implemented a "racing" system between SQLite and API requests to handle slower devices
- OPFS Handling: They discovered that Origin Private File System (OPFS) doesn't handle concurrency well out of the box
- Cross-Origin Isolation: They opted for OPFS SyncAccessHandle Pool VFS to avoid cross-origin isolation requirements
This real-world implementation demonstrates both the potential and challenges of using SQLite in production web applications. Notion's success shows that with careful architecture choices, SQLite can significantly improve web application performance.
๐ฏ Conclusion
You now have a solid foundation for building offline-capable Vue applications using SQLite. This approach offers significant advantages over traditional browser storage solutions, especially for complex data requirements.
Top comments (0)