Next.js と Turso を Vercel にデプロイするデモアプリを作りました。Todo の CRUD を題材に、Server Components で読み、Server Actions で書き、useOptimistic で UI を即時反映するという構成です。さらに途中で生 @libsql/client から Drizzle ORM に移行しています。
ソースコードは shige/nextjs-turso-vercel に置いてあります。
なぜこのデモを作ったか
きっかけは 3 つあります。
- Turso の libSQL を Vercel から実際に触ってみたかった
- ローカル開発で embedded replica を使ったときに、開発体験がどう変わるか確かめたかった
- 同じ Todo 画面のまま、生 SQL から Drizzle ORM への移行を一度自分でやってみたかった
Turso 公式の nextjs-turso-starter を fork するのではなく、Turso 公式の Drizzle ガイドを参照しながら一から作っています。最小構成にしておくと、あとからテンプレに肉付けしていく方針が立てやすいからです。
技術スタック
2026-05-01 時点で stable な最新を選びました。バージョンは package.json に exact pin しています。
| 技術 | バージョン | 役割 |
|---|---|---|
| Next.js | 16.2.4 | フレームワーク(App Router + Turbopack) |
| React | 19.2.5 | UI ライブラリ |
| @libsql/client | 0.17.3 | Turso 接続クライアント |
| Drizzle ORM | 0.45.2 | 型安全なクエリビルダー |
| drizzle-kit | 0.31.10 | マイグレーション CLI |
| Tailwind CSS | 4.2.4 | スタイリング |
| TypeScript | 6.0.3 | 型システム |
| Node.js | 24.15.0 (LTS “Krypton”) | ランタイム |
| pnpm | 10.33.2 | パッケージマネージャ |
アーキテクチャ
リクエストの流れは本番(Vercel)とローカル(embedded replica あり)で少し異なります。
graph LR
subgraph Production
A[Browser] --> B[Vercel<br/>Next.js Server]
B --> C[Turso Cloud<br/>libSQL]
end
subgraph Local
D[Browser] --> E[Next.js Dev Server]
E --> F[file:local-replica.db]
F <-->|sync| C
end
本番では Vercel のサーバーレス関数から Turso にそのまま接続します。ローカルでは file:local-replica.db という SQLite ファイルにアクセスし、定期的に Turso と双方向で同期します。serverless では関数間でファイルが永続しないため、本番では replica を使わない構成にしています。
ディレクトリ構成は以下のとおりです。
src/
app/
layout.tsx # ルートレイアウト(Server Component)
page.tsx # トップページ(Server Component、データ取得)
globals.css
components/
TodoApp.tsx # useOptimistic で楽観的更新(Client Component)
TodoForm.tsx # 追加フォーム(Client Component)
TodoList.tsx # 一覧表示(Client Component)
TodoItem.tsx # 個別 Todo(Client Component)
db/
schema.ts # Drizzle のスキーマ定義
client.ts # drizzle(getTurso(), { schema })
lib/
turso.ts # @libsql/client のラッパ
todos.ts # "use server" のアクション群
drizzle/
0000_loose_madrox.sql
meta/
drizzle.config.ts実装は 2 つの PR に分けた
実装は 2 段階に分けて進めました。
- Issue #1 / PR #2: まずは生の @libsql/client で Turso と接続し、Vercel まで動く Todo を作る
- Issue #3 / PR #4: UI と挙動はそのまま、データ層を Drizzle ORM に置き換える
最初から Drizzle で書くこともできましたが、まずは「ORM なしで動かしたあとに ORM を後乗せする」ほうが学びが多いだろうと思って、あえて 2 段階にしました。
PR #2: 生の libSQL クライアントで Todo を動かす
最初の PR では Turso 公式の Quickstart に沿って実装しています。
src/lib/turso.ts で @libsql/client のクライアントを返すラッパを作ります。TURSO_SYNC_URL の有無で embedded replica モードと通常モードを切り替えるのがポイントです。
// src/lib/turso.ts
import { createClient, type Client } from "@libsql/client";
let client: Client | undefined;
export function hasTursoConfig() {
return Boolean(process.env.TURSO_DATABASE_URL);
}
export function getTurso() {
const databaseUrl = process.env.TURSO_DATABASE_URL;
if (!databaseUrl) {
throw new Error("TURSO_DATABASE_URL is required.");
}
if (!client) {
const syncUrl = process.env.TURSO_SYNC_URL;
client = createClient({
url: databaseUrl,
authToken: process.env.TURSO_AUTH_TOKEN,
...(syncUrl
? {
syncUrl,
syncInterval: Number(process.env.TURSO_SYNC_INTERVAL ?? 60) * 1000,
}
: {}),
});
}
return client;
}
export async function syncTurso() {
if (process.env.TURSO_SYNC_URL) {
await getTurso().sync();
}
}hasTursoConfig() を別関数で外に出しているのは、環境変数が未設定でもページが落ちないようにするためです。Vercel に空のプロジェクトをデプロイして導線だけ確認したいフェーズで助かりました。
syncTurso() は TURSO_SYNC_URL が設定されているとき(つまりローカル開発のとき)だけ sync() を呼びます。本番では何もしません。
トップページの Server Component は素直です。
// src/app/page.tsx
import { TodoApp } from "@/components/TodoApp";
import { hasTursoConfig } from "@/lib/turso";
import { listTodos, type Todo } from "@/lib/todos";
export const dynamic = "force-dynamic";
export default async function Home() {
const isConfigured = hasTursoConfig();
let todos: Todo[] = [];
let setupError: string | undefined;
if (isConfigured) {
try {
todos = await listTodos();
} catch (error) {
setupError =
error instanceof Error ? error.message : "Could not load todos.";
}
}
return (
<TodoApp
initialTodos={todos}
isConfigured={isConfigured}
setupError={setupError}
/>
);
}export const dynamic = "force-dynamic" を入れているのは、Server Actions による更新後にトップページを毎回再レンダリングしてほしいからです。revalidatePath("/") と組み合わせて、書き込み直後でも常に最新の Todo が見えるようにしています。
Server Actions と書き込みフロー
書き込みは Server Actions 経由で行います。Turso と組み合わせるうえで意識した点だけを addTodo の抜粋で示します。
// src/lib/todos.ts (抜粋)
"use server";
import { not } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db/client";
import { todos } from "@/db/schema";
import { syncTurso } from "./turso";
export async function addTodo(formData: FormData) {
const text = String(formData.get("text") ?? "").trim();
if (!text) return { ok: false, message: "Todo text is required." };
await db.insert(todos).values({ text });
await syncTurso();
revalidatePath("/");
return { ok: true };
}DB に絡む工夫は 3 点です。
- アクション末尾で
syncTurso()を呼んでいる。embedded replica モードのときに書き込みを即座に Turso 本体へ反映するためで、本番では no-op です - トグルは
not(todos.completed)で SQL レベルでブール反転している。現値を読み戻さず 1 ステップで切り替えできます - 戻り値を
{ ok, message }の判別可能な形にしている。Server Component で読んだエラーをそのままクライアントへ伝えられます
listTodos / toggleTodo / deleteTodo も同じパターンです。詳細は src/lib/todos.ts を参照してください。
クライアント側は useOptimistic で楽観的更新、useTransition で Server Action 呼び出し、という典型的な App Router の構成です。Todo 固有の UI なので本記事では割愛し、コードは src/components/ を参照してください。
PR #4: Drizzle ORM に移行する
PR #2 まではデータ層が生 SQL で、text TEXT NOT NULL のような DDL を schema.sql に書いて turso db shell で流し込む運用でした。これを Drizzle に置き換えます。
スキーマを TypeScript で書き直します。
// src/db/schema.ts
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const todos = sqliteTable("todos", {
id: integer("id").primaryKey({ autoIncrement: true }),
text: text("text").notNull(),
completed: integer("completed", { mode: "boolean" })
.notNull()
.default(false),
createdAt: text("created_at")
.notNull()
.default(sql`(datetime('now'))`),
});
export type Todo = typeof todos.$inferSelect;completed を integer({ mode: "boolean" }) で宣言すると、SQLite 側は 0/1 で持ったまま TypeScript からは boolean として扱えるようになります。Todo 型は $inferSelect でスキーマから自動推論されるので、型定義の二重管理が消えます。
DB クライアントは @libsql/client をそのまま wrap します。
// src/db/client.ts
import { drizzle } from "drizzle-orm/libsql";
import { getTurso } from "@/lib/turso";
import * as schema from "./schema";
export const db = drizzle(getTurso(), { schema });getTurso() の上に薄く Drizzle を被せているだけなので、embedded replica の挙動はそのまま残ります。@libsql/client は引き続き存在していて、Drizzle はその runtime に依存している、という関係です。
drizzle.config.ts で drizzle-kit のターゲットを設定します。
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "turso",
dbCredentials: {
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
},
});ここでハマりやすいのが TURSO_DATABASE_URL の指し先です。ローカル開発で .env に TURSO_DATABASE_URL=file:local-replica.db を書いていると、drizzle-kit migrate がローカルファイルに対して走ってしまいます。マイグレーションは常に Turso 本体に当てたいので、別ファイル(.env.migrate など)に remote の URL を入れて、こちらを読み込むようにしました。
Server Actions の中身は前章で先に最終形を載せたとおりで、turso.execute("INSERT INTO ...", [text]) 系の生 SQL が db.insert(todos).values({ text }) に置き換わります。
既存テーブルとの整合をどう取ったか
PR #2 で生 SQL の schema.sql を流し込んでいたため、Turso 本体には todos テーブルが既にあります。一方、pnpm db:generate で作られる 0000_loose_madrox.sql は「テーブルを新規作成する」マイグレーションです。__drizzle_migrations の journal も初期状態なので、このまま pnpm db:migrate を走らせると「テーブルがすでに存在する」エラーになります。
対応策は 2 つあります。
- 既存テーブルを破棄してから
0000を流す __drizzle_migrationsに手動で行を入れて「0000は適用済み」と記録する
このデモでは中身が捨てて困らないので、(1) を選びました。デモアプリの破壊的リセットは PR #4 にも明記してあります。本番のデータが入っているなら (2) のほうが安全です。
バージョン固定の方針
Issue #1 のとおり、再現性のために 4 段階のロックを敷いています。
package.json: 全パッケージを exact pin。engines.nodeとengines.pnpmも固定する.npmrc:save-exact=true、engine-strict=true、auto-install-peers=true.nvmrc: ローカル開発の Node を24.15.0に固定pnpm-lock.yaml: 必ずコミットし、CI と Vercel は--frozen-lockfileで入れる
ひとつだけ注意があります。Vercel は Node の major しか選べないので package.json の engines.node は "24.x" にしてあります。一方、ローカル開発は .nvmrc で 24.15.0 までキッチリ揃えるという二段構えです。
Vercel へのデプロイ
ここはほぼ普通の Next.js プロジェクトと同じです。
vercel link
vercel env add TURSO_DATABASE_URL
vercel env add TURSO_AUTH_TOKEN
vercel deploy --prod押さえておきたい点が 2 つあります。
- Vercel 本番では
TURSO_SYNC_URLを設定しない。serverless はインスタンス間でファイルが永続しないため、replica ファイルがあっても利点がないどころか毎回のコールドスタートで sync コストが乗ってしまいます - ビルド時にマイグレーションは流さない。
pnpm db:migrateはローカルから明示的に叩く運用です。デモなので軽い運用ですが、本番ではプリデプロイのジョブから流すほうが安全です
next.config.ts には Turbopack の root を明示してあります。
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {
root: process.cwd(),
},
};
export default nextConfig;monorepo ではないリポジトリで Turbopack の警告が出ないようにする保険です。
やってみた感想
| よかった点 | 気になった点 |
|---|---|
| Turso + libSQL は Vercel と相性が良い。HTTP ベースの接続なので serverless でもコネクションの心配がいらない | embedded replica は便利だが、TURSO_SYNC_URL の有無で動きが変わるので mental model を最初に揃える必要がある |
| Server Actions の DX が良い。fetch も API ルートも書かず、関数を呼ぶだけで書き込みができる | drizzle-kit の migrate を file: にうっかり当ててしまうと壊れる。本番用の .env.migrate を分けるのが安全 |
Drizzle は薄い wrapper なので、@libsql/client から段階的に移行しやすい | 既存テーブルを Drizzle 管理に取り込むときは journal の整合を考える必要がある |
useOptimistic のおかげで体感がきびきびする。判別可能 union 型と組み合わせると reducer が型安全になる | useFormStatus / useOptimistic / useTransition を組み合わせる場面では役割の使い分けに少し慣れがいる |
特に印象的だったのは、@libsql/client から Drizzle への移行が「ほぼ Server Action の中身の置き換え」で終わったことです。クライアント側のコンポーネントは型さえ合えば一切触らず、Todo 型も schema 駆動に切り替わっただけでした。スキーマがコードで宣言されていて、そこから型と DDL の両方が出てくるのは気持ちよく、ロジックも not() のような関数で SQL レベルの操作がそのまま書けます。
まとめ
Next.js + Turso + Vercel のデモアプリを、最小構成から Drizzle ORM 移行まで通しでやってみました。Server Components で読み、Server Actions で書き、useOptimistic で UI を即時反映する構成は、慣れるととても素直な書き味です。embedded replica は本番には持ち込まないけれど、ローカル開発の体験を底上げしてくれるよい仕組みでした。
リポジトリ全体は shige/nextjs-turso-vercel に置いてあります。pnpm install から pnpm dev まで、上の手順そのままで動かせるはずです。
以上、Next.js + Turso on Vercel のデモを作ってみた、現場からお送りしました。
参考情報
- リポジトリ: shige/nextjs-turso-vercel
- 初期実装: Issue #1 Implementation plan / PR #2 Build Next.js Turso Vercel demo
- Drizzle 移行: Issue #3 Migrate to Drizzle ORM / PR #4 Migrate todo data layer to Drizzle ORM
- Next.js ドキュメント
- Turso ドキュメント
- Turso Embedded Replicas
- Drizzle ORM ドキュメント
- Drizzle + Turso ガイド
- Vercel ドキュメント