Next.js + PGlite で「クラウド DB 不要」の Todo アプリを作って Vercel にデプロイした
プロトタイプを作るとき、毎回 Neon や Supabase のデータベースをセットアップするのは少し面倒です。PGlite を使えば、WASM で Postgres がブラウザ内で動き、IndexedDB に永続化されるので、クラウド DB のセットアップが不要になります。
今回は Next.js 16 + PGlite で Todo アプリを作り、Vercel にデプロイしてみました。
GitHub リポジトリ: nextjs-pglite-on-vercel
PGlite とは
PGlite は ElectricSQL が開発している、WASM にコンパイルされた軽量な Postgres です。ブラウザの IndexedDB や Node.js のファイルシステムにデータを永続化できます。
従来のクラウド DB(Neon、Supabase、Turso など)とは異なり、サーバーへのネットワーク接続が不要です。すべてクライアントサイドで完結するため、プロトタイピングやオフライン対応アプリに適しています。
技術スタック
| 技術 | バージョン | 役割 |
|---|---|---|
| Next.js | 16.2.1 | フレームワーク(App Router) |
| React | 19.2.4 | UI ライブラリ |
| PGlite | 0.4.1 | WASM Postgres(ブラウザ内 DB) |
| pglite-react | 0.3.1 | React hooks(useLiveQuery など) |
アーキテクチャ
この構成の特徴は、データベースがサーバーではなくブラウザ内で動くことです。
graph LR
A[Browser] --> B[Vercel<br/>Next.js]
B -->|Server Component<br/>シェルのみ| A
A -->|PGlite WASM| C[IndexedDB<br/>todos-db]
- Server Component はアプリのシェル(レイアウトやメタデータ)のみをレンダリング
- Client Component で PGlite を初期化し、IndexedDB にデータを永続化
useLiveQueryでリアクティブにデータを取得・表示
ディレクトリ構成はシンプルです。
src/
app/
layout.tsx -- Server: ルートレイアウト、メタデータ
page.tsx -- Server: <TodoApp /> をレンダリング
globals.css -- Tailwind CSS
components/
PGliteProvider.tsx -- Client: PGlite 初期化 + live 拡張 + コンテキスト提供
TodoApp.tsx -- Client: レイアウトと PGliteProvider でラップ
TodoList.tsx -- Client: useLiveQuery でリアクティブな一覧表示
TodoItem.tsx -- Client: 完了トグル、削除
TodoForm.tsx -- Client: 新規追加
lib/
schema.ts -- SQL スキーマ定数コード解説
スキーマ定義(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()
);
`;CREATE TABLE IF NOT EXISTS で冪等なスキーマ定義をしています。PGlite の起動時に毎回実行しても、テーブルが既にあればスキップされます。通常の Postgres と同じ SQL がそのまま使えるのが 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>;
}このコンポーネントがアプリの中核です。いくつかのポイントがあります。
dataDir: "idb://todos-db" - idb:// プレフィックスで IndexedDB を永続化先に指定しています。ページをリロードしてもデータが残ります。
extensions: { live } - live 拡張を有効にすることで、useLiveQuery によるリアクティブなクエリが使えるようになります。
cancelled フラグ - React の Strict Mode では useEffect が2回実行されます。クリーンアップ関数で cancelled = true にすることで、アンマウント後の状態更新を防いでいます。
ローディング・エラー状態 - PGlite の WASM 読み込みは非同期なので、初期化完了まではローディング表示、失敗時はエラー表示を返しています。
Todo 一覧(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 が PGlite のリアクティブクエリの要です。データベースのデータが変更されると、自動的にクエリが再実行され、UI が更新されます。Server Actions + revalidatePath のようなパターンは不要で、クライアントサイドで完結するリアクティブなデータフローが実現できます。
Todo アイテム(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() で PGliteProvider のコンテキストから DB インスタンスを取得し、SQL を直接実行しています。UPDATE todos SET completed = NOT completed WHERE id = $1 のように、SQL レベルでブール値を反転させています。
useTransition でラップすることで、クエリ実行中のペンディング状態を管理し、ボタンの disabled 制御に使っています。
Todo フォーム(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>
);
}Server Actions は使わず、usePGlite() で取得した DB にパラメータ化クエリで直接 INSERT しています。PGlite はパラメータバインディング($1)をサポートしているので、SQL インジェクションの心配なくクエリを実行できます。
Neon / Supabase との比較
PGlite のアプローチと従来のクラウド DB の違いを整理します。
| 観点 | PGlite(本記事) | Neon / Supabase |
|---|---|---|
| DB の実行場所 | ブラウザ(WASM) | クラウドサーバー |
| データの永続化 | IndexedDB(ブラウザローカル) | クラウドストレージ |
| セットアップ | npm install のみ | アカウント作成 + DB プロビジョニング + 環境変数設定 |
| ネットワーク | 不要 | 必須 |
| 複数デバイス間の同期 | なし(ブラウザごとに独立) | あり |
| プロダクション利用 | プロトタイプ・個人用途向き | 本番対応 |
| 環境変数 | 不要 | DB URL + 認証トークンが必要 |
プロトタイプ段階では PGlite でサクッと動かし、プロダクションに進む段階でクラウド DB に移行する、という使い分けが現実的です。SQL は標準的な Postgres なので、移行時のクエリ書き換えは最小限で済みます。
E2E テストによる永続化の検証
このプロジェクトでは Playwright で IndexedDB の永続化を検証しています。
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");
});テストの流れは次のとおりです。
- Todo を追加
- IndexedDB に
todos-dbが作成されていることを確認 - ページをリロード
- リロード後も Todo が表示されていることを確認
ブラウザをリロードしてもデータが消えないことを自動テストで保証しています。
Vercel へのデプロイ
PGlite はクライアントサイドで動くため、Vercel へのデプロイに特別な設定は不要です。
- 環境変数の設定なし
- クラウド DB のプロビジョニングなし
- サーバーサイドで PGlite を import しないため、バンドルの問題も起きない
もしサーバーサイドのバンドルで問題が発生した場合は、next.config.ts に以下を追加します。
const nextConfig: NextConfig = {
serverExternalPackages: ["@electric-sql/pglite"],
};まとめ
PGlite を使うことで、クラウド DB のセットアップなしに Postgres 互換のデータベースをブラウザ内で動かせました。プロトタイプやデモアプリの開発では、DB のプロビジョニングや環境変数の管理から解放されるのは大きなメリットです。
useLiveQuery によるリアクティブなクエリや、IndexedDB による永続化も、特別な設定なく利用できます。将来的にプロダクション DB に移行する場合も、SQL は標準 Postgres なので移行コストは小さいでしょう。
以上、Next.js + PGlite で Todo アプリを作って Vercel にデプロイしてみた、現場からお送りしました。