Skip to content

SQLite in Vue: Complete Guide to Building Offline-First Web Apps

Published: at 

TLDR

Looking to add offline capabilities to your Vue application? While browsers offer IndexedDB, SQLite provides a more powerful solution for complex data operations. This comprehensive guide shows you how to integrate SQLite with Vue using WebAssembly for robust offline-first applications.

📚 What We’ll Build

Screenshot Sqlite Playground Try it out: Write and run SQL queries right in your browser

🚀 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:

🛠️ How It Works

We’ll use three main technologies:

  1. SQLite Wasm: SQLite converted to run in browsers
  2. Web Workers: Runs database code without freezing your app
  3. Origin Private File System: A secure place to store your database

Here’s how they work together:

How SQLite runs in your browser

📝 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 { function defineConfig(config: UserConfig): UserConfig (+5 overloads)
Type helper to make it easier to use vite.config.ts accepts a direct {@link UserConfig } object, or a function that returns it. The function receives a {@link ConfigEnv } object.
defineConfig
} from 'vite'
export default function defineConfig(config: UserConfigFnObject): UserConfigFnObject (+5 overloads)
Type helper to make it easier to use vite.config.ts accepts a direct {@link UserConfig } object, or a function that returns it. The function receives a {@link ConfigEnv } object.
defineConfig
(() => ({
UserConfig.server?: ServerOptions | undefined
Server specific options, e.g. host, port, https...
server
: {
CommonServerOptions.headers?: OutgoingHttpHeaders | undefined
Specify server response headers.
headers
: {
'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }, }, UserConfig.optimizeDeps?: DepOptimizationOptions | undefined
Dep optimization options
optimizeDeps
: {
DepOptimizationConfig.exclude?: string[] | undefined
Do not optimize these dependencies (must be resolvable import paths, cannot be globs).
exclude
: ['@sqlite.org/sqlite-wasm'],
}, }))

This configuration is crucial for SQLite WASM to work properly:

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 { class Worker
The `Worker` class represents an independent JavaScript execution thread. Most Node.js APIs are available inside of it. Notable differences inside a Worker environment are: * The `process.stdin`, `process.stdout`, and `process.stderr` streams may be redirected by the parent thread. * The `import { isMainThread } from 'node:worker_threads'` variable is set to `false`. * The `import { parentPort } from 'node:worker_threads'` message port is available. * `process.exit()` does not stop the whole program, just the single thread, and `process.abort()` is not available. * `process.chdir()` and `process` methods that set group or user ids are not available. * `process.env` is a copy of the parent thread's environment variables, unless otherwise specified. Changes to one copy are not visible in other threads, and are not visible to native add-ons (unless `worker.SHARE_ENV` is passed as the `env` option to the `Worker` constructor). On Windows, unlike the main thread, a copy of the environment variables operates in a case-sensitive manner. * `process.title` cannot be modified. * Signals are not delivered through `process.on('...')`. * Execution may stop at any point as a result of `worker.terminate()` being invoked. * IPC channels from parent processes are not accessible. * The `trace_events` module is not supported. * Native add-ons can only be loaded from multiple threads if they fulfill `certain conditions`. Creating `Worker` instances inside of other `Worker`s is possible. Like [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) and the `node:cluster module`, two-way communication can be achieved through inter-thread message passing. Internally, a `Worker` has a built-in pair of `MessagePort` s that are already associated with each other when the `Worker` is created. While the `MessagePort` object on the parent side is not directly exposed, its functionalities are exposed through `worker.postMessage()` and the `worker.on('message')` event on the `Worker` object for the parent thread. To create custom messaging channels (which is encouraged over using the default global channel because it facilitates separation of concerns), users can create a `MessageChannel` object on either thread and pass one of the`MessagePort`s on that `MessageChannel` to the other thread through a pre-existing channel, such as the global one. See `port.postMessage()` for more information on how messages are passed, and what kind of JavaScript values can be successfully transported through the thread barrier. ```js import assert from 'node:assert'; import { Worker, MessageChannel, MessagePort, isMainThread, parentPort, } from 'node:worker_threads'; if (isMainThread) { const worker = new Worker(__filename); const subChannel = new MessageChannel(); worker.postMessage({ hereIsYourPort: subChannel.port1 }, [subChannel.port1]); subChannel.port2.on('message', (value) => { console.log('received:', value); }); } else { parentPort.once('message', (value) => { assert(value.hereIsYourPort instanceof MessagePort); value.hereIsYourPort.postMessage('the worker is sending this'); value.hereIsYourPort.close(); }); } ```
@sincev10.5.0
Worker
} from 'node:worker_threads'
declare module '@sqlite.org/sqlite-wasm' { type type OnreadyFunction = () => voidOnreadyFunction = () => void type
type Sqlite3Worker1PromiserConfig = {
    onready?: OnreadyFunction;
    worker?: Worker | (() => Worker);
    generateMessageId?: (messageObject: unknown) => string;
    debug?: (...args: any[]) => void;
    onunhandled?: (event: MessageEvent) => void;
}
Sqlite3Worker1PromiserConfig
= {
onready?: OnreadyFunction | undefinedonready?: type OnreadyFunction = () => voidOnreadyFunction worker?: Worker | (() => Worker) | undefinedworker?: class Worker
The `Worker` class represents an independent JavaScript execution thread. Most Node.js APIs are available inside of it. Notable differences inside a Worker environment are: * The `process.stdin`, `process.stdout`, and `process.stderr` streams may be redirected by the parent thread. * The `import { isMainThread } from 'node:worker_threads'` variable is set to `false`. * The `import { parentPort } from 'node:worker_threads'` message port is available. * `process.exit()` does not stop the whole program, just the single thread, and `process.abort()` is not available. * `process.chdir()` and `process` methods that set group or user ids are not available. * `process.env` is a copy of the parent thread's environment variables, unless otherwise specified. Changes to one copy are not visible in other threads, and are not visible to native add-ons (unless `worker.SHARE_ENV` is passed as the `env` option to the `Worker` constructor). On Windows, unlike the main thread, a copy of the environment variables operates in a case-sensitive manner. * `process.title` cannot be modified. * Signals are not delivered through `process.on('...')`. * Execution may stop at any point as a result of `worker.terminate()` being invoked. * IPC channels from parent processes are not accessible. * The `trace_events` module is not supported. * Native add-ons can only be loaded from multiple threads if they fulfill `certain conditions`. Creating `Worker` instances inside of other `Worker`s is possible. Like [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) and the `node:cluster module`, two-way communication can be achieved through inter-thread message passing. Internally, a `Worker` has a built-in pair of `MessagePort` s that are already associated with each other when the `Worker` is created. While the `MessagePort` object on the parent side is not directly exposed, its functionalities are exposed through `worker.postMessage()` and the `worker.on('message')` event on the `Worker` object for the parent thread. To create custom messaging channels (which is encouraged over using the default global channel because it facilitates separation of concerns), users can create a `MessageChannel` object on either thread and pass one of the`MessagePort`s on that `MessageChannel` to the other thread through a pre-existing channel, such as the global one. See `port.postMessage()` for more information on how messages are passed, and what kind of JavaScript values can be successfully transported through the thread barrier. ```js import assert from 'node:assert'; import { Worker, MessageChannel, MessagePort, isMainThread, parentPort, } from 'node:worker_threads'; if (isMainThread) { const worker = new Worker(__filename); const subChannel = new MessageChannel(); worker.postMessage({ hereIsYourPort: subChannel.port1 }, [subChannel.port1]); subChannel.port2.on('message', (value) => { console.log('received:', value); }); } else { parentPort.once('message', (value) => { assert(value.hereIsYourPort instanceof MessagePort); value.hereIsYourPort.postMessage('the worker is sending this'); value.hereIsYourPort.close(); }); } ```
@sincev10.5.0
Worker
| (() => class Worker
The `Worker` class represents an independent JavaScript execution thread. Most Node.js APIs are available inside of it. Notable differences inside a Worker environment are: * The `process.stdin`, `process.stdout`, and `process.stderr` streams may be redirected by the parent thread. * The `import { isMainThread } from 'node:worker_threads'` variable is set to `false`. * The `import { parentPort } from 'node:worker_threads'` message port is available. * `process.exit()` does not stop the whole program, just the single thread, and `process.abort()` is not available. * `process.chdir()` and `process` methods that set group or user ids are not available. * `process.env` is a copy of the parent thread's environment variables, unless otherwise specified. Changes to one copy are not visible in other threads, and are not visible to native add-ons (unless `worker.SHARE_ENV` is passed as the `env` option to the `Worker` constructor). On Windows, unlike the main thread, a copy of the environment variables operates in a case-sensitive manner. * `process.title` cannot be modified. * Signals are not delivered through `process.on('...')`. * Execution may stop at any point as a result of `worker.terminate()` being invoked. * IPC channels from parent processes are not accessible. * The `trace_events` module is not supported. * Native add-ons can only be loaded from multiple threads if they fulfill `certain conditions`. Creating `Worker` instances inside of other `Worker`s is possible. Like [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) and the `node:cluster module`, two-way communication can be achieved through inter-thread message passing. Internally, a `Worker` has a built-in pair of `MessagePort` s that are already associated with each other when the `Worker` is created. While the `MessagePort` object on the parent side is not directly exposed, its functionalities are exposed through `worker.postMessage()` and the `worker.on('message')` event on the `Worker` object for the parent thread. To create custom messaging channels (which is encouraged over using the default global channel because it facilitates separation of concerns), users can create a `MessageChannel` object on either thread and pass one of the`MessagePort`s on that `MessageChannel` to the other thread through a pre-existing channel, such as the global one. See `port.postMessage()` for more information on how messages are passed, and what kind of JavaScript values can be successfully transported through the thread barrier. ```js import assert from 'node:assert'; import { Worker, MessageChannel, MessagePort, isMainThread, parentPort, } from 'node:worker_threads'; if (isMainThread) { const worker = new Worker(__filename); const subChannel = new MessageChannel(); worker.postMessage({ hereIsYourPort: subChannel.port1 }, [subChannel.port1]); subChannel.port2.on('message', (value) => { console.log('received:', value); }); } else { parentPort.once('message', (value) => { assert(value.hereIsYourPort instanceof MessagePort); value.hereIsYourPort.postMessage('the worker is sending this'); value.hereIsYourPort.close(); }); } ```
@sincev10.5.0
Worker
)
generateMessageId?: ((messageObject: unknown) => string) | undefinedgenerateMessageId?: (messageObject: unknownmessageObject: unknown) => string debug?: ((...args: any[]) => void) | undefineddebug?: (...args: any[]args: any[]) => void onunhandled?: ((event: MessageEvent) => void) | undefinedonunhandled?: (event: MessageEvent<any>event: interface MessageEvent<T = any>
A message received by a target object. [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent)
@sincev15.0.0
MessageEvent
) => void
} type type DbId = string | undefinedDbId = string | undefined type
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[][];
        };
    };
}
PromiserMethods
= {
'config-get': { args: Record<string, never>args: type Record<K extends keyof any, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<string, never>
result: {
    dbID: DbId;
    version: {
        libVersion: string;
        sourceId: string;
        libVersionNumber: number;
        downloadVersion: number;
    };
    bigIntEnabled: boolean;
    opfsEnabled: boolean;
    vfsList: string[];
}
result
: {
dbID: DbIddbID: type DbId = string | undefinedDbId
version: {
    libVersion: string;
    sourceId: string;
    libVersionNumber: number;
    downloadVersion: number;
}
version
: {
libVersion: stringlibVersion: string sourceId: stringsourceId: string libVersionNumber: numberlibVersionNumber: number downloadVersion: numberdownloadVersion: number } bigIntEnabled: booleanbigIntEnabled: boolean opfsEnabled: booleanopfsEnabled: boolean vfsList: string[]vfsList: string[] } } 'open': {
args: Partial<{
    filename?: string;
    vfs?: string;
}>
args
: type Partial<T> = { [P in keyof T]?: T[P] | undefined; }
Make all properties in T optional
Partial
<{
filename?: string | undefinedfilename?: string vfs?: string | undefinedvfs?: string }>
result: {
    dbId: DbId;
    filename: string;
    persistent: boolean;
    vfs: string;
}
result
: {
dbId: DbIddbId: type DbId = string | undefinedDbId filename: stringfilename: string persistent: booleanpersistent: boolean vfs: stringvfs: string } } 'exec': {
args: {
    sql: string;
    dbId?: DbId;
    bind?: unknown[];
    returnValue?: string;
}
args
: {
sql: stringsql: string dbId?: DbIddbId?: type DbId = string | undefinedDbId bind?: unknown[] | undefinedbind?: unknown[] returnValue?: string | undefinedreturnValue?: string }
result: {
    dbId: DbId;
    sql: string;
    bind: unknown[];
    returnValue: string;
    resultRows?: unknown[][];
}
result
: {
dbId: DbIddbId: type DbId = string | undefinedDbId sql: stringsql: string bind: unknown[]bind: unknown[] returnValue: stringreturnValue: string resultRows?: unknown[][] | undefinedresultRows?: unknown[][] } } } type
type PromiserResponseSuccess<T extends keyof PromiserMethods> = {
    type: T;
    result: PromiserMethods[T]["result"];
    messageId: string;
    dbId: DbId;
    workerReceivedTime: number;
    workerRespondTime: number;
    departureTime: number;
}
PromiserResponseSuccess
<function (type parameter) T in type PromiserResponseSuccess<T extends keyof PromiserMethods>T extends keyof
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[][];
        };
    };
}
PromiserMethods
> = {
type: T extends keyof PromiserMethodstype: function (type parameter) T in type PromiserResponseSuccess<T extends keyof PromiserMethods>T result: PromiserMethods[T]["result"]result:
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[][];
        };
    };
}
PromiserMethods
[function (type parameter) T in type PromiserResponseSuccess<T extends keyof PromiserMethods>T]['result']
messageId: stringmessageId: string dbId: DbIddbId: type DbId = string | undefinedDbId workerReceivedTime: numberworkerReceivedTime: number workerRespondTime: numberworkerRespondTime: number departureTime: numberdepartureTime: number } type
type PromiserResponseError = {
    type: "error";
    result: {
        operation: string;
        message: string;
        errorClass: string;
        input: object;
        stack: unknown[];
    };
    messageId: string;
    dbId: DbId;
}
PromiserResponseError
= {
type: "error"type: 'error'
result: {
    operation: string;
    message: string;
    errorClass: string;
    input: object;
    stack: unknown[];
}
result
: {
operation: stringoperation: string message: stringmessage: string errorClass: stringerrorClass: string input: objectinput: object stack: unknown[]stack: unknown[] } messageId: stringmessageId: string dbId: DbIddbId: type DbId = string | undefinedDbId } type type PromiserResponse<T extends keyof PromiserMethods> = PromiserResponseError | PromiserResponseSuccess<T>PromiserResponse<function (type parameter) T in type PromiserResponse<T extends keyof PromiserMethods>T extends keyof
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[][];
        };
    };
}
PromiserMethods
> =
|
type PromiserResponseSuccess<T extends keyof PromiserMethods> = {
    type: T;
    result: PromiserMethods[T]["result"];
    messageId: string;
    dbId: DbId;
    workerReceivedTime: number;
    workerRespondTime: number;
    departureTime: number;
}
PromiserResponseSuccess
<function (type parameter) T in type PromiserResponse<T extends keyof PromiserMethods>T>
|
type PromiserResponseError = {
    type: "error";
    result: {
        operation: string;
        message: string;
        errorClass: string;
        input: object;
        stack: unknown[];
    };
    messageId: string;
    dbId: DbId;
}
PromiserResponseError
type type Promiser = <T extends keyof PromiserMethods>(messageType: T, messageArguments: PromiserMethods[T]["args"]) => Promise<PromiserResponse<T>>Promiser = <function (type parameter) T in <T extends keyof PromiserMethods>(messageType: T, messageArguments: PromiserMethods[T]["args"]): Promise<PromiserResponse<T>>T extends keyof
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[][];
        };
    };
}
PromiserMethods
>(
messageType: T extends keyof PromiserMethodsmessageType: function (type parameter) T in <T extends keyof PromiserMethods>(messageType: T, messageArguments: PromiserMethods[T]["args"]): Promise<PromiserResponse<T>>T, messageArguments: PromiserMethods[T]["args"]messageArguments:
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[][];
        };
    };
}
PromiserMethods
[function (type parameter) T in <T extends keyof PromiserMethods>(messageType: T, messageArguments: PromiserMethods[T]["args"]): Promise<PromiserResponse<T>>T]['args'],
) => interface Promise<T>
Represents the completion of an asynchronous operation
Promise
<type PromiserResponse<T extends keyof PromiserMethods> = PromiserResponseError | PromiserResponseSuccess<T>PromiserResponse<function (type parameter) T in <T extends keyof PromiserMethods>(messageType: T, messageArguments: PromiserMethods[T]["args"]): Promise<PromiserResponse<T>>T>>
export function function sqlite3Worker1Promiser(config?: Sqlite3Worker1PromiserConfig | OnreadyFunction): Promisersqlite3Worker1Promiser( config: OnreadyFunction | Sqlite3Worker1PromiserConfig | undefinedconfig?:
type Sqlite3Worker1PromiserConfig = {
    onready?: OnreadyFunction;
    worker?: Worker | (() => Worker);
    generateMessageId?: (messageObject: unknown) => string;
    debug?: (...args: any[]) => void;
    onunhandled?: (event: MessageEvent) => void;
}
Sqlite3Worker1PromiserConfig
| type OnreadyFunction = () => voidOnreadyFunction,
): type Promiser = <T extends keyof PromiserMethods>(messageType: T, messageArguments: PromiserMethods[T]["args"]) => Promise<PromiserResponse<T>>Promiser }

Step 4: Create the SQLite Composable

The core of our implementation is the useSQLite composable. This will handle all database operations:

import type { import DbIdDbId } from '@sqlite.org/sqlite-wasm'
import { import sqlite3Worker1Promisersqlite3Worker1Promiser } from '@sqlite.org/sqlite-wasm'
import { 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 databaseConfig: {
    readonly filename: "file:mydb.sqlite3?vfs=opfs";
    readonly tables: {
        readonly test: {
            readonly name: "test_table";
            readonly schema: "\n        CREATE TABLE IF NOT EXISTS test_table (\n          id INTEGER PRIMARY KEY AUTOINCREMENT,\n          name TEXT NOT NULL,\n          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n        );\n      ";
        };
    };
}
databaseConfig
= {
filename: "file:mydb.sqlite3?vfs=opfs"filename: 'file:mydb.sqlite3?vfs=opfs',
tables: {
    readonly test: {
        readonly name: "test_table";
        readonly schema: "\n        CREATE TABLE IF NOT EXISTS test_table (\n          id INTEGER PRIMARY KEY AUTOINCREMENT,\n          name TEXT NOT NULL,\n          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n        );\n      ";
    };
}
tables
: {
test: {
    readonly name: "test_table";
    readonly schema: "\n        CREATE TABLE IF NOT EXISTS test_table (\n          id INTEGER PRIMARY KEY AUTOINCREMENT,\n          name TEXT NOT NULL,\n          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n        );\n      ";
}
test
: {
name: "test_table"name: 'test_table', schema: "\n CREATE TABLE IF NOT EXISTS test_table (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n );\n "schema: ` CREATE TABLE IF NOT EXISTS test_table ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); `, }, }, } as
type const = {
    readonly filename: "file:mydb.sqlite3?vfs=opfs";
    readonly tables: {
        readonly test: {
            readonly name: "test_table";
            readonly schema: "\n        CREATE TABLE IF NOT EXISTS test_table (\n          id INTEGER PRIMARY KEY AUTOINCREMENT,\n          name TEXT NOT NULL,\n          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n        );\n      ";
        };
    };
}
const
export function
function useSQLite(): {
    isLoading: Ref<boolean, boolean>;
    error: Ref<Error | null, Error | null>;
    isInitialized: Ref<boolean, boolean>;
    executeQuery: (sql: string, params?: unknown[]) => Promise<...>;
}
useSQLite
() {
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)
const const error: Ref<Error | null, Error | null>error = ref<Error | null>(value: Error | null): Ref<Error | null, Error | 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
<Error | null>(null)
const const isInitialized: Ref<boolean, boolean>isInitialized = 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)
let let promiser: anypromiser: type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any
Obtain the return type of a function type
ReturnType
<typeof import sqlite3Worker1Promisersqlite3Worker1Promiser> | null = null
let let dbId: string | nulldbId: string | null = null async function function (local function) initialize(): Promise<boolean>initialize() { if (const isInitialized: Ref<boolean, boolean>isInitialized.Ref<boolean, boolean>.value: booleanvalue) return true const isLoading: Ref<boolean, boolean>isLoading.Ref<boolean, boolean>.value: booleanvalue = true const error: Ref<Error | null, Error | null>error.Ref<Error | null, Error | null>.value: Error | nullvalue = null try { // Initialize the SQLite worker let promiser: anypromiser = await new
var Promise: PromiseConstructor
new <unknown>(executor: (resolve: (value: unknown) => void, reject: (reason?: any) => void) => void) => Promise<unknown>
Creates a new Promise.
@paramexecutor A callback used to initialize the promise. This callback is passed two arguments: a resolve callback used to resolve the promise with a value or the result of another promise, and a reject callback used to reject the promise with a provided reason or error.
Promise
((resolve: (value: unknown) => voidresolve) => {
const const _promiser: any_promiser = import sqlite3Worker1Promisersqlite3Worker1Promiser({ onready: () => voidonready: () => resolve: (value: unknown) => voidresolve(const _promiser: any_promiser), }) }) if (!let promiser: anypromiser) throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
('Failed to initialize promiser')
// Get configuration and open database await let promiser: anypromiser('config-get', {}) const const openResponse: anyopenResponse = await let promiser: anypromiser('open', { filename: "file:mydb.sqlite3?vfs=opfs"filename:
const databaseConfig: {
    readonly filename: "file:mydb.sqlite3?vfs=opfs";
    readonly tables: {
        readonly test: {
            readonly name: "test_table";
            readonly schema: "\n        CREATE TABLE IF NOT EXISTS test_table (\n          id INTEGER PRIMARY KEY AUTOINCREMENT,\n          name TEXT NOT NULL,\n          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n        );\n      ";
        };
    };
}
databaseConfig
.filename: "file:mydb.sqlite3?vfs=opfs"filename,
}) if (const openResponse: anyopenResponse.type === 'error') { throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
(const openResponse: anyopenResponse.result.message)
} let dbId: string | nulldbId = const openResponse: anyopenResponse.result.dbId as string // Create initial tables await let promiser: anypromiser('exec', { dbId: stringdbId, sql: "\n CREATE TABLE IF NOT EXISTS test_table (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n );\n "sql:
const databaseConfig: {
    readonly filename: "file:mydb.sqlite3?vfs=opfs";
    readonly tables: {
        readonly test: {
            readonly name: "test_table";
            readonly schema: "\n        CREATE TABLE IF NOT EXISTS test_table (\n          id INTEGER PRIMARY KEY AUTOINCREMENT,\n          name TEXT NOT NULL,\n          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n        );\n      ";
        };
    };
}
databaseConfig
.
tables: {
    readonly test: {
        readonly name: "test_table";
        readonly schema: "\n        CREATE TABLE IF NOT EXISTS test_table (\n          id INTEGER PRIMARY KEY AUTOINCREMENT,\n          name TEXT NOT NULL,\n          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n        );\n      ";
    };
}
tables
.
test: {
    readonly name: "test_table";
    readonly schema: "\n        CREATE TABLE IF NOT EXISTS test_table (\n          id INTEGER PRIMARY KEY AUTOINCREMENT,\n          name TEXT NOT NULL,\n          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n        );\n      ";
}
test
.schema: "\n CREATE TABLE IF NOT EXISTS test_table (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n );\n "schema,
}) const isInitialized: Ref<boolean, boolean>isInitialized.Ref<boolean, boolean>.value: booleanvalue = true return true } catch (function (local var) err: unknownerr) { const error: Ref<Error | null, Error | null>error.Ref<Error | null, Error | null>.value: Error | nullvalue = function (local var) err: unknownerr instanceof var Error: ErrorConstructorError ? function (local var) err: Errorerr : new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
('Unknown error')
throw const error: Ref<Error | null, Error | null>error.Ref<Error | null, Error | null>.value: Errorvalue } finally { const isLoading: Ref<boolean, boolean>isLoading.Ref<boolean, boolean>.value: booleanvalue = false } } async function function (local function) executeQuery(sql: string, params?: unknown[]): Promise<any>executeQuery(sql: stringsql: string, params: unknown[]params: unknown[] = []) { if (!let dbId: string | nulldbId || !let promiser: anypromiser) { await function (local function) initialize(): Promise<boolean>initialize() } const isLoading: Ref<boolean, boolean>isLoading.Ref<boolean, boolean>.value: booleanvalue = true const error: Ref<Error | null, Error | null>error.Ref<Error | null, Error | null>.value: Error | nullvalue = null try { const const result: anyresult = await let promiser: anypromiser!('exec', { dbId: DbIddbId: let dbId: string | nulldbId as import DbIdDbId, sql: stringsql, bind: unknown[]bind: params: unknown[]params, returnValue: stringreturnValue: 'resultRows', }) if (const result: anyresult.type === 'error') { throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
(const result: anyresult.result.message)
} return const result: anyresult } catch (function (local var) err: unknownerr) { const error: Ref<Error | null, Error | null>error.Ref<Error | null, Error | null>.value: Error | nullvalue = function (local var) err: unknownerr instanceof var Error: ErrorConstructorError ? function (local var) err: Errorerr : new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
('Query execution failed')
throw const error: Ref<Error | null, Error | null>error.Ref<Error | null, Error | null>.value: Errorvalue } finally { const isLoading: Ref<boolean, boolean>isLoading.Ref<boolean, boolean>.value: booleanvalue = false } } return { isLoading: Ref<boolean, boolean>isLoading, error: Ref<Error | null, Error | null>error, isInitialized: Ref<boolean, boolean>isInitialized, executeQuery: (sql: string, params?: unknown[]) => Promise<any>executeQuery, } }

Step 5: Create a SQLite Playground Component

Now let’s create a component to test our SQLite implementation:


<script setup lang="ts">
import { import useSQLiteuseSQLite } from '@/composables/useSQLite'
import { 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 isLoading: anyisLoading, const error: anyerror, const executeQuery: anyexecuteQuery } = import useSQLiteuseSQLite() const const sqlQuery: Ref<string, string>sqlQuery = 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
('SELECT * FROM test_table')
const const queryResult: Ref<any[], any[]>queryResult = ref<any[]>(value: any[]): Ref<any[], any[]> (+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
<any[]>([])
const const queryError: Ref<string | null, string | null>queryError = 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)
// Predefined example queries for testing const
const exampleQueries: {
    title: string;
    query: string;
}[]
exampleQueries
= [
{ title: stringtitle: 'Select all', query: stringquery: 'SELECT * FROM test_table' }, { title: stringtitle: 'Insert', query: stringquery: "INSERT INTO test_table (name) VALUES ('New Test Item')" }, { title: stringtitle: 'Update', query: stringquery: "UPDATE test_table SET name = 'Updated Item' WHERE name LIKE 'New%'" }, { title: stringtitle: 'Delete', query: stringquery: "DELETE FROM test_table WHERE name = 'Updated Item'" }, ] async function function runQuery(): Promise<void>runQuery() { const queryError: Ref<string | null, string | null>queryError.Ref<string | null, string | null>.value: string | nullvalue = null const queryResult: Ref<any[], any[]>queryResult.Ref<any[], any[]>.value: any[]value = [] try { const const result: anyresult = await const executeQuery: anyexecuteQuery(const sqlQuery: Ref<string, string>sqlQuery.Ref<string, string>.value: stringvalue) const const isSelect: booleanisSelect = const sqlQuery: Ref<string, string>sqlQuery.Ref<string, string>.value: stringvalue.String.trim(): string
Removes the leading and trailing white space and line terminator characters from a string.
trim
().String.toLowerCase(): string
Converts all the alphabetic characters in a string to lowercase.
toLowerCase
().String.startsWith(searchString: string, position?: number): boolean
Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding elements of this object (converted to a String) starting at position. Otherwise returns false.
startsWith
('select')
if (const isSelect: booleanisSelect) { const queryResult: Ref<any[], any[]>queryResult.Ref<any[], any[]>.value: any[]value = const result: anyresult?.result.resultRows || [] } else { // After mutation, fetch updated data const queryResult: Ref<any[], any[]>queryResult.Ref<any[], any[]>.value: any[]value = (await const executeQuery: anyexecuteQuery('SELECT * FROM test_table'))?.result.resultRows || [] } } catch (function (local var) err: unknownerr) { const queryError: Ref<string | null, string | null>queryError.Ref<string | null, string | null>.value: string | nullvalue = function (local var) err: unknownerr instanceof var Error: ErrorConstructorError ? function (local var) err: Errorerr.Error.message: stringmessage : 'An error occurred' } } </script> <template> <div: HTMLAttributes & ReservedPropsdiv HTMLAttributes.class?: anyclass="max-w-7xl mx-auto px-4 py-6"> <h2: HTMLAttributes & ReservedPropsh2 HTMLAttributes.class?: anyclass="text-2xl font-bold">SQLite Playground</h2: HTMLAttributes & ReservedPropsh2> <!-- Example queries --> <div: HTMLAttributes & ReservedPropsdiv HTMLAttributes.class?: anyclass="mt-4"> <h3: HTMLAttributes & ReservedPropsh3 HTMLAttributes.class?: anyclass="text-sm font-medium">Example Queries:</h3: HTMLAttributes & ReservedPropsh3> <div: HTMLAttributes & ReservedPropsdiv HTMLAttributes.class?: anyclass="flex gap-2 mt-2"> <button: ButtonHTMLAttributes & ReservedPropsbutton v-for="
const example: {
    title: string;
    query: string;
}
example
in
const exampleQueries: {
    title: string;
    query: string;
}[]
exampleQueries
"
key?: PropertyKey | undefined:key?: PropertyKey | undefinedkey="
const example: {
    title: string;
    query: string;
}
example
.title: stringtitle"
HTMLAttributes.class?: anyclass="px-3 py-1 text-sm rounded-full bg-gray-100 hover:bg-gray-200" @onClick?: ((payload: MouseEvent) => void) | undefinedclick="const sqlQuery: Ref<string, string>sqlQuery =
const example: {
    title: string;
    query: string;
}
example
.query: stringquery"
> {{
const example: {
    title: string;
    query: string;
}
example
.title: stringtitle }}
</button: ButtonHTMLAttributes & ReservedPropsbutton> </div: HTMLAttributes & ReservedPropsdiv> </div: HTMLAttributes & ReservedPropsdiv> <!-- Query input --> <div: HTMLAttributes & ReservedPropsdiv HTMLAttributes.class?: anyclass="mt-6"> <textarea: TextareaHTMLAttributes & ReservedPropstextarea TextareaHTMLAttributes.value?: string | number | readonly string[] | null | undefinedv-model="const sqlQuery: Ref<string, string>sqlQuery" TextareaHTMLAttributes.rows?: Numberish | undefinedrows="4" HTMLAttributes.class?: anyclass="w-full px-4 py-3 rounded-lg font-mono text-sm" TextareaHTMLAttributes.disabled?: Booleanish | undefined:TextareaHTMLAttributes.disabled?: Booleanish | undefineddisabled="const isLoading: anyisLoading" /> <button: ButtonHTMLAttributes & ReservedPropsbutton ButtonHTMLAttributes.disabled?: Booleanish | undefined:ButtonHTMLAttributes.disabled?: Booleanish | undefineddisabled="const isLoading: anyisLoading" HTMLAttributes.class?: anyclass="mt-2 px-4 py-2 rounded-lg bg-blue-600 text-white" @onClick?: ((payload: MouseEvent) => void) | undefinedclick="function runQuery(): Promise<void>runQuery" > {{ const isLoading: anyisLoading ? 'Running...' : 'Run Query' }} </button: ButtonHTMLAttributes & ReservedPropsbutton> </div: HTMLAttributes & ReservedPropsdiv> <!-- Error display --> <div: HTMLAttributes & ReservedPropsdiv v-if="const error: anyerror || const queryError: Ref<string | null, string | null>queryError" HTMLAttributes.class?: anyclass="mt-4 p-4 rounded-lg bg-red-50 text-red-600" > {{ const error: anyerror?.message || const queryError: Ref<string | null, string | null>queryError }} </div: HTMLAttributes & ReservedPropsdiv> <!-- Results table --> <div: HTMLAttributes & ReservedPropsdiv v-if="const queryResult: Ref<any[], any[]>queryResult.Array<any>.length: number
Gets or sets the length of the array. This is a number one higher than the highest index in the array.
length
" HTMLAttributes.class?: anyclass="mt-4">
<h3: HTMLAttributes & ReservedPropsh3 HTMLAttributes.class?: anyclass="text-lg font-semibold">Results:</h3: HTMLAttributes & ReservedPropsh3> <div: HTMLAttributes & ReservedPropsdiv HTMLAttributes.class?: anyclass="mt-2 overflow-x-auto"> <table: TableHTMLAttributes & ReservedPropstable HTMLAttributes.class?: anyclass="w-full"> <thead: HTMLAttributes & ReservedPropsthead> <tr: HTMLAttributes & ReservedPropstr> <th: ThHTMLAttributes & ReservedPropsth v-for="const column: stringcolumn in var Object: ObjectConstructor
Provides functionality common to all JavaScript objects.
Object
.ObjectConstructor.keys(o: {}): string[] (+1 overload)
Returns the names of the enumerable string properties and methods of an object.
@paramo Object that contains the properties and methods. This can be an object that you created or an existing Document Object Model (DOM) object.
keys
(const queryResult: Ref<any[], any[]>queryResult[0])"
key?: PropertyKey | undefined:key?: PropertyKey | undefinedkey="const column: stringcolumn" HTMLAttributes.class?: anyclass="px-4 py-2 text-left" > {{ const column: stringcolumn }} </th: ThHTMLAttributes & ReservedPropsth> </tr: HTMLAttributes & ReservedPropstr> </thead: HTMLAttributes & ReservedPropsthead> <tbody: HTMLAttributes & ReservedPropstbody> <tr: HTMLAttributes & ReservedPropstr v-for="(const row: anyrow, const index: numberindex) in const queryResult: Ref<any[], any[]>queryResult" key?: PropertyKey | undefined:key?: PropertyKey | undefinedkey="const index: numberindex" > <td: TdHTMLAttributes & ReservedPropstd v-for="const column: stringcolumn in var Object: ObjectConstructor
Provides functionality common to all JavaScript objects.
Object
.ObjectConstructor.keys(o: {}): string[] (+1 overload)
Returns the names of the enumerable string properties and methods of an object.
@paramo Object that contains the properties and methods. This can be an object that you created or an existing Document Object Model (DOM) object.
keys
(const row: anyrow)"
key?: PropertyKey | undefined:key?: PropertyKey | undefinedkey="const column: stringcolumn" HTMLAttributes.class?: anyclass="px-4 py-2" > {{ const row: anyrow[const column: stringcolumn] }} </td: TdHTMLAttributes & ReservedPropstd> </tr: HTMLAttributes & ReservedPropstr> </tbody: HTMLAttributes & ReservedPropstbody> </table: TableHTMLAttributes & ReservedPropstable> </div: HTMLAttributes & ReservedPropsdiv> </div: HTMLAttributes & ReservedPropsdiv> </div: HTMLAttributes & ReservedPropsdiv> </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

Multi-Tab Architecture

Notion solved the challenge of handling multiple browser tabs with an innovative approach:

  1. Each tab has its own Web Worker for SQLite operations
  2. A SharedWorker manages which tab is “active”
  3. Only one tab can write to SQLite at a time
  4. Queries from all tabs are routed through the active tab’s Worker

Key Learnings from Notion

  1. Async Loading: They load the WASM SQLite library asynchronously to avoid blocking initial page load
  2. Race Conditions: They implemented a “racing” system between SQLite and API requests to handle slower devices
  3. OPFS Handling: They discovered that Origin Private File System (OPFS) doesn’t handle concurrency well out of the box
  4. 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.

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

Related Posts