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!
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:
- 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 { function defineConfig(config: UserConfig): UserConfig (+3 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 (+3 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 optionsoptimizeDeps: {
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:
-
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 { 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 `require('node:worker_threads').isMainThread` property is set to `false`.
* The `require('node:worker_threads').parentPort` 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
const assert = require('node:assert');
const {
Worker, MessageChannel, MessagePort, isMainThread, parentPort,
} = require('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();
});
}
```Worker } from 'node:worker_threads'
declare module '@sqlite.org/sqlite-wasm' {
type type OnreadyFunction = () => void
OnreadyFunction = () => void
type type Sqlite3Worker1PromiserConfig = {
onready?: OnreadyFunction;
worker?: Worker | (() => Worker);
generateMessageId?: (messageObject: unknown) => string;
debug?: (...args: any[]) => void;
onunhandled?: (event: MessageEvent) => void;
}
Sqlite3Worker1PromiserConfig = {
onready?: OnreadyFunction | undefined
onready?: type OnreadyFunction = () => void
OnreadyFunction
worker?: Worker | (() => Worker) | undefined
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 `require('node:worker_threads').isMainThread` property is set to `false`.
* The `require('node:worker_threads').parentPort` 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
const assert = require('node:assert');
const {
Worker, MessageChannel, MessagePort, isMainThread, parentPort,
} = require('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();
});
}
```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 `require('node:worker_threads').isMainThread` property is set to `false`.
* The `require('node:worker_threads').parentPort` 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
const assert = require('node:assert');
const {
Worker, MessageChannel, MessagePort, isMainThread, parentPort,
} = require('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();
});
}
```Worker)
generateMessageId?: ((messageObject: unknown) => string) | undefined
generateMessageId?: (messageObject: unknown
messageObject: unknown) => string
debug?: ((...args: any[]) => void) | undefined
debug?: (...args: any[]
args: any[]) => void
onunhandled?: ((event: MessageEvent) => void) | undefined
onunhandled?: (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)MessageEvent) => void
}
type type DbId = string | undefined
DbId = 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 TRecord<string, never>
result: {
dbID: DbId;
version: {
libVersion: string;
sourceId: string;
libVersionNumber: number;
downloadVersion: number;
};
bigIntEnabled: boolean;
opfsEnabled: boolean;
vfsList: string[];
}
result: {
dbID: DbId
dbID: type DbId = string | undefined
DbId
version: {
libVersion: string;
sourceId: string;
libVersionNumber: number;
downloadVersion: number;
}
version: {
libVersion: string
libVersion: string
sourceId: string
sourceId: string
libVersionNumber: number
libVersionNumber: number
downloadVersion: number
downloadVersion: number
}
bigIntEnabled: boolean
bigIntEnabled: boolean
opfsEnabled: boolean
opfsEnabled: 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 optionalPartial<{
filename?: string | undefined
filename?: string
vfs?: string | undefined
vfs?: string
}>
result: {
dbId: DbId;
filename: string;
persistent: boolean;
vfs: string;
}
result: {
dbId: DbId
dbId: type DbId = string | undefined
DbId
filename: string
filename: string
persistent: boolean
persistent: boolean
vfs: string
vfs: string
}
}
'exec': {
args: {
sql: string;
dbId?: DbId;
bind?: unknown[];
returnValue?: string;
}
args: {
sql: string
sql: string
dbId?: DbId
dbId?: type DbId = string | undefined
DbId
bind?: unknown[] | undefined
bind?: unknown[]
returnValue?: string | undefined
returnValue?: string
}
result: {
dbId: DbId;
sql: string;
bind: unknown[];
returnValue: string;
resultRows?: unknown[][];
}
result: {
dbId: DbId
dbId: type DbId = string | undefined
DbId
sql: string
sql: string
bind: unknown[]
bind: unknown[]
returnValue: string
returnValue: string
resultRows?: unknown[][] | undefined
resultRows?: 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 PromiserMethods
type: 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: string
messageId: string
dbId: DbId
dbId: type DbId = string | undefined
DbId
workerReceivedTime: number
workerReceivedTime: number
workerRespondTime: number
workerRespondTime: number
departureTime: number
departureTime: 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: string
operation: string
message: string
message: string
errorClass: string
errorClass: string
input: object
input: object
stack: unknown[]
stack: unknown[]
}
messageId: string
messageId: string
dbId: DbId
dbId: type DbId = string | undefined
DbId
}
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 PromiserMethods
messageType: 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 operationPromise<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): Promiser
sqlite3Worker1Promiser(
config: OnreadyFunction | Sqlite3Worker1PromiserConfig | undefined
config?: type Sqlite3Worker1PromiserConfig = {
onready?: OnreadyFunction;
worker?: Worker | (() => Worker);
generateMessageId?: (messageObject: unknown) => string;
debug?: (...args: any[]) => void;
onunhandled?: (event: MessageEvent) => void;
}
Sqlite3Worker1PromiserConfig | type OnreadyFunction = () => void
OnreadyFunction,
): 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 DbId
DbId } from '@sqlite.org/sqlite-wasm'
import { import sqlite3Worker1Promiser
sqlite3Worker1Promiser } 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.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.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.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.ref(false)
let let promiser: any
promiser: type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any
Obtain the return type of a function typeReturnType<typeof import sqlite3Worker1Promiser
sqlite3Worker1Promiser> | null = null
let let dbId: string | null
dbId: string | null = null
async function function (local function) initialize(): Promise<boolean>
initialize() {
if (const isInitialized: Ref<boolean, boolean>
isInitialized.Ref<boolean, boolean>.value: boolean
value) return true
const isLoading: Ref<boolean, boolean>
isLoading.Ref<boolean, boolean>.value: boolean
value = true
const error: Ref<Error | null, Error | null>
error.Ref<Error | null, Error | null>.value: Error | null
value = null
try {
// Initialize the SQLite worker
let promiser: any
promiser = await new var Promise: PromiseConstructor
new <unknown>(executor: (resolve: (value: unknown) => void, reject: (reason?: any) => void) => void) => Promise<unknown>
Creates a new Promise.Promise((resolve: (value: unknown) => void
resolve) => {
const const _promiser: any
_promiser = import sqlite3Worker1Promiser
sqlite3Worker1Promiser({
onready: () => void
onready: () => resolve: (value: unknown) => void
resolve(const _promiser: any
_promiser),
})
})
if (!let promiser: any
promiser) 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: any
promiser('config-get', {})
const const openResponse: any
openResponse = await let promiser: any
promiser('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: any
openResponse.type === 'error') {
throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error(const openResponse: any
openResponse.result.message)
}
let dbId: string | null
dbId = const openResponse: any
openResponse.result.dbId as string
// Create initial tables
await let promiser: any
promiser('exec', {
dbId: string
dbId,
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: boolean
value = true
return true
}
catch (function (local var) err: unknown
err) {
const error: Ref<Error | null, Error | null>
error.Ref<Error | null, Error | null>.value: Error | null
value = function (local var) err: unknown
err instanceof var Error: ErrorConstructor
Error ? function (local var) err: Error
err : 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: Error
value
}
finally {
const isLoading: Ref<boolean, boolean>
isLoading.Ref<boolean, boolean>.value: boolean
value = false
}
}
async function function (local function) executeQuery(sql: string, params?: unknown[]): Promise<any>
executeQuery(sql: string
sql: string, params: unknown[]
params: unknown[] = []) {
if (!let dbId: string | null
dbId || !let promiser: any
promiser) {
await function (local function) initialize(): Promise<boolean>
initialize()
}
const isLoading: Ref<boolean, boolean>
isLoading.Ref<boolean, boolean>.value: boolean
value = true
const error: Ref<Error | null, Error | null>
error.Ref<Error | null, Error | null>.value: Error | null
value = null
try {
const const result: any
result = await let promiser: any
promiser!('exec', {
dbId: DbId
dbId: let dbId: string | null
dbId as import DbId
DbId,
sql: string
sql,
bind: unknown[]
bind: params: unknown[]
params,
returnValue: string
returnValue: 'resultRows',
})
if (const result: any
result.type === 'error') {
throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error(const result: any
result.result.message)
}
return const result: any
result
}
catch (function (local var) err: unknown
err) {
const error: Ref<Error | null, Error | null>
error.Ref<Error | null, Error | null>.value: Error | null
value = function (local var) err: unknown
err instanceof var Error: ErrorConstructor
Error ? function (local var) err: Error
err : 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: Error
value
}
finally {
const isLoading: Ref<boolean, boolean>
isLoading.Ref<boolean, boolean>.value: boolean
value = 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 useSQLite
useSQLite } 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.ref } from 'vue'
const { const isLoading: any
isLoading, const error: any
error, const executeQuery: any
executeQuery } = import useSQLite
useSQLite()
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.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.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.ref<string | null>(null)
// Predefined example queries for testing
const const exampleQueries: {
title: string;
query: string;
}[]
exampleQueries = [
{ title: string
title: 'Select all', query: string
query: 'SELECT * FROM test_table' },
{ title: string
title: 'Insert', query: string
query: "INSERT INTO test_table (name) VALUES ('New Test Item')" },
{ title: string
title: 'Update', query: string
query: "UPDATE test_table SET name = 'Updated Item' WHERE name LIKE 'New%'" },
{ title: string
title: 'Delete', query: string
query: "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 | null
value = null
const queryResult: Ref<any[], any[]>
queryResult.Ref<any[], any[]>.value: any[]
value = []
try {
const const result: any
result = await const executeQuery: any
executeQuery(const sqlQuery: Ref<string, string>
sqlQuery.Ref<string, string>.value: string
value)
const const isSelect: boolean
isSelect = const sqlQuery: Ref<string, string>
sqlQuery.Ref<string, string>.value: string
value.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: boolean
isSelect) {
const queryResult: Ref<any[], any[]>
queryResult.Ref<any[], any[]>.value: any[]
value = const result: any
result?.result.resultRows || []
}
else {
// After mutation, fetch updated data
const queryResult: Ref<any[], any[]>
queryResult.Ref<any[], any[]>.value: any[]
value = (await const executeQuery: any
executeQuery('SELECT * FROM test_table'))?.result.resultRows || []
}
}
catch (function (local var) err: unknown
err) {
const queryError: Ref<string | null, string | null>
queryError.Ref<string | null, string | null>.value: string | null
value = function (local var) err: unknown
err instanceof var Error: ErrorConstructor
Error ? function (local var) err: Error
err.Error.message: string
message : 'An error occurred'
}
}
</script>
<template>
<div: HTMLAttributes & ReservedProps
div HTMLAttributes.class?: any
class="max-w-7xl mx-auto px-4 py-6">
<h2: HTMLAttributes & ReservedProps
h2 HTMLAttributes.class?: any
class="text-2xl font-bold">SQLite Playground</h2: HTMLAttributes & ReservedProps
h2>
<!-- Example queries -->
<div: HTMLAttributes & ReservedProps
div HTMLAttributes.class?: any
class="mt-4">
<h3: HTMLAttributes & ReservedProps
h3 HTMLAttributes.class?: any
class="text-sm font-medium">Example Queries:</h3: HTMLAttributes & ReservedProps
h3>
<div: HTMLAttributes & ReservedProps
div HTMLAttributes.class?: any
class="flex gap-2 mt-2">
<button: ButtonHTMLAttributes & ReservedProps
button
v-for="const example: {
title: string;
query: string;
}
example in const exampleQueries: {
title: string;
query: string;
}[]
exampleQueries"
key?: PropertyKey | undefined
:key?: PropertyKey | undefined
key="const example: {
title: string;
query: string;
}
example.title: string
title"
HTMLAttributes.class?: any
class="px-3 py-1 text-sm rounded-full bg-gray-100 hover:bg-gray-200"
@onClick?: ((payload: MouseEvent) => void) | undefined
click="const sqlQuery: Ref<string, string>
sqlQuery = const example: {
title: string;
query: string;
}
example.query: string
query"
>
{{ const example: {
title: string;
query: string;
}
example.title: string
title }}
</button: ButtonHTMLAttributes & ReservedProps
button>
</div: HTMLAttributes & ReservedProps
div>
</div: HTMLAttributes & ReservedProps
div>
<!-- Query input -->
<div: HTMLAttributes & ReservedProps
div HTMLAttributes.class?: any
class="mt-6">
<textarea: TextareaHTMLAttributes & ReservedProps
textarea
TextareaHTMLAttributes.value?: string | number | readonly string[] | null | undefined
v-model="const sqlQuery: Ref<string, string>
sqlQuery"
TextareaHTMLAttributes.rows?: Numberish | undefined
rows="4"
HTMLAttributes.class?: any
class="w-full px-4 py-3 rounded-lg font-mono text-sm"
TextareaHTMLAttributes.disabled?: Booleanish | undefined
:TextareaHTMLAttributes.disabled?: Booleanish | undefined
disabled="const isLoading: any
isLoading"
/>
<button: ButtonHTMLAttributes & ReservedProps
button
ButtonHTMLAttributes.disabled?: Booleanish | undefined
:ButtonHTMLAttributes.disabled?: Booleanish | undefined
disabled="const isLoading: any
isLoading"
HTMLAttributes.class?: any
class="mt-2 px-4 py-2 rounded-lg bg-blue-600 text-white"
@onClick?: ((payload: MouseEvent) => void) | undefined
click="function runQuery(): Promise<void>
runQuery"
>
{{ const isLoading: any
isLoading ? 'Running...' : 'Run Query' }}
</button: ButtonHTMLAttributes & ReservedProps
button>
</div: HTMLAttributes & ReservedProps
div>
<!-- Error display -->
<div: HTMLAttributes & ReservedProps
div
v-if="const error: any
error || const queryError: Ref<string | null, string | null>
queryError"
HTMLAttributes.class?: any
class="mt-4 p-4 rounded-lg bg-red-50 text-red-600"
>
{{ const error: any
error?.message || const queryError: Ref<string | null, string | null>
queryError }}
</div: HTMLAttributes & ReservedProps
div>
<!-- Results table -->
<div: HTMLAttributes & ReservedProps
div 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?: any
class="mt-4">
<h3: HTMLAttributes & ReservedProps
h3 HTMLAttributes.class?: any
class="text-lg font-semibold">Results:</h3: HTMLAttributes & ReservedProps
h3>
<div: HTMLAttributes & ReservedProps
div HTMLAttributes.class?: any
class="mt-2 overflow-x-auto">
<table: TableHTMLAttributes & ReservedProps
table HTMLAttributes.class?: any
class="w-full">
<thead: HTMLAttributes & ReservedProps
thead>
<tr: HTMLAttributes & ReservedProps
tr>
<th: ThHTMLAttributes & ReservedProps
th
v-for="const column: string
column 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.keys(const queryResult: Ref<any[], any[]>
queryResult[0])"
key?: PropertyKey | undefined
:key?: PropertyKey | undefined
key="const column: string
column"
HTMLAttributes.class?: any
class="px-4 py-2 text-left"
>
{{ const column: string
column }}
</th: ThHTMLAttributes & ReservedProps
th>
</tr: HTMLAttributes & ReservedProps
tr>
</thead: HTMLAttributes & ReservedProps
thead>
<tbody: HTMLAttributes & ReservedProps
tbody>
<tr: HTMLAttributes & ReservedProps
tr
v-for="(const row: any
row, const index: number
index) in const queryResult: Ref<any[], any[]>
queryResult"
key?: PropertyKey | undefined
:key?: PropertyKey | undefined
key="const index: number
index"
>
<td: TdHTMLAttributes & ReservedProps
td
v-for="const column: string
column 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.keys(const row: any
row)"
key?: PropertyKey | undefined
:key?: PropertyKey | undefined
key="const column: string
column"
HTMLAttributes.class?: any
class="px-4 py-2"
>
{{ const row: any
row[const column: string
column] }}
</td: TdHTMLAttributes & ReservedProps
td>
</tr: HTMLAttributes & ReservedProps
tr>
</tbody: HTMLAttributes & ReservedProps
tbody>
</table: TableHTMLAttributes & ReservedProps
table>
</div: HTMLAttributes & ReservedProps
div>
</div: HTMLAttributes & ReservedProps
div>
</div: HTMLAttributes & ReservedProps
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.