Guide

Client instance

PGlite in the browser, in a Web Worker shared between tabs.

The client side runs PGlite in the browser: in a Web Worker, so queries never block the page, and shared between the tabs of your app. It is off by default.

nuxt.config.ts
export default defineNuxtConfig({
  pglite: {
    client: { enabled: true },
  },
})

The config file

app/pglite.config.ts holds what cannot be plain JSON: extensions, init, per-environment settings.

app/pglite.config.ts
import { live } from '@electric-sql/pglite/live'
import { vector } from '@electric-sql/pglite-pgvector'

export default definePGliteClientConfig({
  dataDir: 'idb://my-app',
  extensions: { vector }, // loaded in the worker, next to the database
  clientExtensions: { live }, // loaded on the main thread, typed on the instance
  init: async (pg) => {
    await pg.exec('CREATE EXTENSION IF NOT EXISTS vector')
    await pg.exec('CREATE TABLE IF NOT EXISTS todos (id serial PRIMARY KEY, title text)')
  },
})

definePGliteClientConfig is auto-imported. It accepts PGlite's worker options plus:

  • extensions: Postgres extensions (vector, the contrib ones, …), loaded in the worker where the database runs.
  • clientExtensions: extensions that add a JavaScript namespace to the instance (live, electricSync), loaded on the main thread and typed on what usePGlite() returns. This is how PGlite itself splits them.
  • init: runs once in each tab that creates the instance, before it is handed out. Write it to be repeatable (IF NOT EXISTS, ON CONFLICT DO NOTHING), since every tab runs it.
  • dispose: runs before the instance is closed through pglite.close().
  • devtoolsActions: client actions for Nuxt DevTools.
  • $development, $production, $test: per-environment overrides, as on the server.

The file is imported by both the main thread and the worker. Keep it to package and relative imports: the worker bundle has no auto-imports, which is why definePGliteClientConfig is provided there as a global.

Querying from components

usePGlite() resolves to the instance, starting the worker on first call (or when the app loads, with eager). PGlite runs in the browser only, so call it from client-only code: a .client.vue component, a component behind <ClientOnly>, onMounted, or an event handler.

app/components/AddTodo.vue
<script setup lang="ts">
const title = ref('')

async function add() {
  const pg = await usePGlite()
  await pg.query('INSERT INTO todos (title) VALUES ($1)', [title.value])
  title.value = ''
}
</script>

<template>
  <form @submit.prevent="add">
    <input v-model="title" />
    <button>Add</button>
  </form>
</template>
app/pages/todos.vue
<template>
  <ClientOnly>
    <TodoList />
    <template #fallback>Loading…</template>
  </ClientOnly>
</template>

During server-side rendering usePGlite() throws, so a call in setup of a component that also renders on the server fails the page.

Try it

The SQL box below runs against this site's own client instance, with a planets table seeded by init. Statements you run are stored in your browser (IndexedDB).

The component behind it does little more than this:

app/components/SqlRunner.client.vue
<script setup lang="ts">
const sql = ref('SELECT * FROM planets ORDER BY moons DESC')
const results = shallowRef<{ fields: string[]; rows: unknown[][] }[]>([])
const error = ref<string>()

async function run() {
  error.value = undefined
  try {
    const pg = await usePGlite()
    const output = await pg.exec(sql.value, { rowMode: 'array' })
    results.value = output.map((result) => ({
      fields: result.fields.map((field) => field.name),
      rows: result.rows as unknown[][],
    }))
  } catch (cause) {
    error.value = cause instanceof Error ? cause.message : String(cause)
  }
}
</script>

exec runs several statements and returns one result each; query runs one statement with parameters.

Disabling the client side

With enabled: false (the default), nothing of PGlite reaches the client bundle. #pglite/client still resolves, to a stub whose functions fail with a pointer.