Next.js + Turso on Vercel のデモアプリを @libsql/client で作ってみた — 公式ガイドから Drizzle ORM 移行まで

重岡 正 ·  Fri, May 1, 2026

Next.jsTursoVercel にデプロイするデモアプリを作りました。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.js16.2.4フレームワーク(App Router + Turbopack
React19.2.5UI ライブラリ
@libsql/client0.17.3Turso 接続クライアント
Drizzle ORM0.45.2型安全なクエリビルダー
drizzle-kit0.31.10マイグレーション CLI
Tailwind CSS4.2.4スタイリング
TypeScript6.0.3型システム
Node.js24.15.0 (LTS “Krypton”)ランタイム
pnpm10.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 段階に分けて進めました。

最初から 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 点です。

  1. アクション末尾で syncTurso() を呼んでいる。embedded replica モードのときに書き込みを即座に Turso 本体へ反映するためで、本番では no-op です
  2. トグルは not(todos.completed) で SQL レベルでブール反転している。現値を読み戻さず 1 ステップで切り替えできます
  3. 戻り値を { 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;

completedinteger({ 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.tsdrizzle-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 の指し先です。ローカル開発で .envTURSO_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 つあります。

  1. 既存テーブルを破棄してから 0000 を流す
  2. __drizzle_migrations に手動で行を入れて「0000 は適用済み」と記録する

このデモでは中身が捨てて困らないので、(1) を選びました。デモアプリの破壊的リセットは PR #4 にも明記してあります。本番のデータが入っているなら (2) のほうが安全です。

バージョン固定の方針

Issue #1 のとおり、再現性のために 4 段階のロックを敷いています。

  • package.json: 全パッケージを exact pin。engines.nodeengines.pnpm も固定する
  • .npmrc: save-exact=trueengine-strict=trueauto-install-peers=true
  • .nvmrc: ローカル開発の Node を 24.15.0 に固定
  • pnpm-lock.yaml: 必ずコミットし、CI と Vercel は --frozen-lockfile で入れる

ひとつだけ注意があります。Vercel は Node の major しか選べないので package.jsonengines.node"24.x" にしてあります。一方、ローカル開発は .nvmrc24.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 のデモを作ってみた、現場からお送りしました。

参考情報