Building a Cloud-DB-Free Todo App with Next.js + PGlite and Deploying to Vercel

Tadashi Shigeoka ·  Sun, March 22, 2026

Every time you prototype something, setting up a Neon or Supabase database adds friction. With PGlite, Postgres runs as WASM in the browser and persists to IndexedDB — no cloud DB setup needed.

In this post, I built a Todo app with Next.js 16 + PGlite and deployed it to Vercel.

GitHub repository: nextjs-pglite-on-vercel

What Is PGlite?

PGlite is a lightweight Postgres compiled to WASM, developed by ElectricSQL. It can persist data to the browser’s IndexedDB or the Node.js filesystem.

Unlike cloud databases (Neon, Supabase, Turso, etc.), it requires no network connection to a server. Everything runs client-side, making it ideal for prototyping and offline-capable apps.

Tech Stack

TechnologyVersionRole
Next.js16.2.1Framework (App Router)
React19.2.4UI library
PGlite0.4.1WASM Postgres (in-browser DB)
pglite-react0.3.1React hooks (useLiveQuery, etc.)

Architecture

The key difference in this setup is that the database runs in the browser, not on a server.

graph LR
    A[Browser] --> B[Vercel<br/>Next.js]
    B -->|Server Component<br/>shell only| A
    A -->|PGlite WASM| C[IndexedDB<br/>todos-db]
  • Server Components render only the app shell (layout and metadata)
  • Client Components initialize PGlite and persist data to IndexedDB
  • useLiveQuery provides reactive data fetching and display

The directory structure is straightforward.

src/
  app/
    layout.tsx          -- Server: root layout, metadata
    page.tsx            -- Server: renders <TodoApp />
    globals.css         -- Tailwind CSS
  components/
    PGliteProvider.tsx  -- Client: PGlite init + live extension + context
    TodoApp.tsx         -- Client: layout wrapper with PGliteProvider
    TodoList.tsx        -- Client: reactive list via useLiveQuery
    TodoItem.tsx        -- Client: toggle complete, delete
    TodoForm.tsx        -- Client: add new todo
  lib/
    schema.ts           -- SQL schema constant

Code Walkthrough

Schema Definition (lib/schema.ts)

export const SCHEMA = `
  CREATE TABLE IF NOT EXISTS todos (
    id SERIAL PRIMARY KEY,
    text TEXT NOT NULL,
    completed BOOLEAN NOT NULL DEFAULT false,
    created_at TIMESTAMP NOT NULL DEFAULT NOW()
  );
`;

The schema uses CREATE TABLE IF NOT EXISTS for idempotency. It runs on every PGlite startup — if the table already exists, it’s simply skipped. Standard Postgres SQL works as-is with PGlite.

PGliteProvider (components/PGliteProvider.tsx)

"use client";
 
import { PGlite } from "@electric-sql/pglite";
import { live } from "@electric-sql/pglite/live";
import type { PGliteWithLive } from "@electric-sql/pglite/live";
import { PGliteProvider as BasePGliteProvider } from "@electric-sql/pglite-react";
import { useEffect, useState } from "react";
import { SCHEMA } from "@/lib/schema";
 
type Props = {
  children: React.ReactNode;
};
 
export function PGliteProvider({ children }: Props) {
  const [db, setDb] = useState<PGliteWithLive | null>(null);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    let cancelled = false;
 
    async function init() {
      try {
        const nextDb = await PGlite.create({
          dataDir: "idb://todos-db",
          extensions: { live },
        });
 
        await nextDb.exec(SCHEMA);
 
        if (!cancelled) {
          setDb(nextDb);
        }
      } catch (initError) {
        if (!cancelled) {
          setError(
            initError instanceof Error
              ? initError.message
              : "Failed to initialize PGlite.",
          );
        }
      }
    }
 
    init();
 
    return () => {
      cancelled = true;
    };
  }, []);
 
  if (error) {
    return (
      <div className="rounded-3xl border border-red-500/40 bg-red-950/30 p-6 text-sm text-red-100">
        <p className="font-semibold">Database initialization failed.</p>
        <p className="mt-2 text-red-100/80">{error}</p>
      </div>
    );
  }
 
  if (!db) {
    return (
      <div className="rounded-3xl border border-stone-800 bg-stone-900/70 p-6 text-sm text-stone-300">
        Loading browser database...
      </div>
    );
  }
 
  return <BasePGliteProvider db={db}>{children}</BasePGliteProvider>;
}

This component is the core of the app. A few key points:

dataDir: "idb://todos-db" — The idb:// prefix tells PGlite to persist to IndexedDB. Data survives page reloads.

extensions: { live } — Enabling the live extension unlocks reactive queries via useLiveQuery.

cancelled flag — React Strict Mode runs useEffect twice. The cleanup function sets cancelled = true to prevent state updates after unmount.

Loading and error states — Since PGlite’s WASM loading is async, the component shows a loading indicator until initialization completes and an error message on failure.

Todo List (components/TodoList.tsx)

"use client";
 
import { useLiveQuery } from "@electric-sql/pglite-react";
import { TodoItem } from "@/components/TodoItem";
 
type Todo = {
  id: number;
  text: string;
  completed: boolean;
  created_at: string;
};
 
export function TodoList() {
  const todoQuery = useLiveQuery<Todo>(
    "SELECT id, text, completed, created_at FROM todos ORDER BY created_at DESC, id DESC",
  );
  const todos = todoQuery?.rows ?? [];
 
  if (todos.length === 0) {
    return (
      <div className="rounded-3xl border border-dashed border-stone-700 p-8 text-center text-stone-400">
        No todos yet. Add one to verify live queries and IndexedDB
        persistence.
      </div>
    );
  }
 
  return (
    <ul className="space-y-3">
      {todos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

useLiveQuery is the key to PGlite’s reactive queries. When database data changes, the query automatically re-executes and updates the UI. No Server Actions + revalidatePath pattern needed — the reactive data flow is handled entirely on the client side.

Todo Item (components/TodoItem.tsx)

"use client";
 
import { useTransition } from "react";
import { usePGlite } from "@electric-sql/pglite-react";
 
type Todo = {
  id: number;
  text: string;
  completed: boolean;
};
 
type Props = {
  todo: Todo;
};
 
export function TodoItem({ todo }: Props) {
  const db = usePGlite();
  const [isPending, startTransition] = useTransition();
 
  function toggleTodo() {
    startTransition(async () => {
      await db.query("UPDATE todos SET completed = NOT completed WHERE id = $1", [
        todo.id,
      ]);
    });
  }
 
  function deleteTodo() {
    startTransition(async () => {
      await db.query("DELETE FROM todos WHERE id = $1", [todo.id]);
    });
  }
 
  return (
    <li className="flex items-center gap-3 rounded-3xl border border-stone-800 bg-stone-950/60 p-4">
      <button
        aria-label={
          todo.completed ? "Mark todo as incomplete" : "Mark todo as complete"
        }
        disabled={isPending}
        onClick={toggleTodo}
        type="button"
      >

      </button>
      <span
        className={`min-w-0 flex-1 text-base ${
          todo.completed ? "text-stone-500 line-through" : "text-stone-100"
        }`}
      >
        {todo.text}
      </span>
      <button disabled={isPending} onClick={deleteTodo} type="button">
        Delete
      </button>
    </li>
  );
}

usePGlite() retrieves the DB instance from the PGliteProvider context and executes SQL directly. UPDATE todos SET completed = NOT completed WHERE id = $1 toggles the boolean value at the SQL level.

Wrapping operations in useTransition manages the pending state during query execution and controls button disabled states.

Todo Form (components/TodoForm.tsx)

"use client";
 
import { FormEvent, useState } from "react";
import { usePGlite } from "@electric-sql/pglite-react";
 
export function TodoForm() {
  const db = usePGlite();
  const [text, setText] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);
 
  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
 
    const value = text.trim();
    if (!value) {
      return;
    }
 
    setIsSubmitting(true);
 
    try {
      await db.query("INSERT INTO todos (text) VALUES ($1)", [value]);
      setText("");
    } finally {
      setIsSubmitting(false);
    }
  }
 
  return (
    <form className="flex gap-3" onSubmit={handleSubmit}>
      <input
        aria-label="New todo"
        onChange={(event) => setText(event.target.value)}
        placeholder="Draft the next task..."
        value={text}
      />
      <button disabled={isSubmitting} type="submit">
        {isSubmitting ? "Saving..." : "Add"}
      </button>
    </form>
  );
}

No Server Actions here — the component uses usePGlite() to INSERT directly with a parameterized query. PGlite supports parameter binding ($1), so queries are safe from SQL injection.

PGlite vs. Neon / Supabase

Here’s how PGlite compares with traditional cloud databases:

AspectPGlite (this post)Neon / Supabase
DB runs inBrowser (WASM)Cloud server
Data persistenceIndexedDB (browser-local)Cloud storage
Setupnpm install onlyAccount creation + DB provisioning + env vars
NetworkNot requiredRequired
Multi-device syncNone (independent per browser)Yes
Production usePrototypes and personal toolsProduction-ready
Env varsNoneDB URL + auth token required

A practical approach: prototype with PGlite, then migrate to a cloud DB when moving to production. Since PGlite uses standard Postgres SQL, query rewrites during migration are minimal.

E2E Testing for Persistence Verification

This project uses Playwright to verify IndexedDB persistence.

import { test, expect } from "@playwright/test";
 
test("persists todos in IndexedDB across reloads", async ({ page }) => {
  const todoText = `Verify persistence ${Date.now()}`;
 
  await page.goto("/");
  await page.waitForFunction(
    () => !document.body.textContent?.includes("Loading browser database..."),
  );
 
  await page.getByPlaceholder("Draft the next task...").fill(todoText);
  await page.getByRole("button", { name: "Add" }).click();
  await expect(page.getByText(todoText)).toBeVisible();
 
  const databases = await page.evaluate(async () => {
    if (typeof indexedDB.databases !== "function") {
      return [];
    }
    return await indexedDB.databases();
  });
 
  await page.reload();
  await page.waitForFunction(
    () => !document.body.textContent?.includes("Loading browser database..."),
  );
  await expect(page.getByText(todoText)).toBeVisible();
  expect(JSON.stringify(databases)).toContain("todos-db");
});

The test flow:

  1. Add a todo
  2. Verify IndexedDB contains todos-db
  3. Reload the page
  4. Verify the todo is still visible after reload

This automated test guarantees that data persists across browser reloads.

Deploying to Vercel

Since PGlite runs client-side, deploying to Vercel requires no special configuration:

  • No environment variables
  • No cloud DB provisioning
  • No server-side PGlite imports, so no bundling issues

If you do encounter server-side bundling issues, add this to next.config.ts:

const nextConfig: NextConfig = {
  serverExternalPackages: ["@electric-sql/pglite"],
};

Summary

With PGlite, I was able to run a Postgres-compatible database entirely in the browser without any cloud DB setup. For prototypes and demo apps, being freed from DB provisioning and environment variable management is a significant advantage.

Reactive queries via useLiveQuery and IndexedDB persistence work without any special configuration. If you later need to migrate to a production database, the transition cost is low since the SQL is standard Postgres.

That’s all from the Gemba — building a Todo app with Next.js + PGlite and deploying to Vercel.

References