Next.js + Turso on Vercel のデモアプリを @tursodatabase/* パッケージへ移行する — レガシー @libsql/client からの脱却と 3 モード切り替え

重岡 正 ·  Sat, May 2, 2026

前回の記事 で作った Next.js + Turso + Vercel のデモアプリを、Turso が新しく整理した @tursodatabase/* パッケージファミリに移行しました。@libsql/client は公式リファレンスで legacy 扱いになっており、デモなら早めに新しい構成に乗せ替えておきたいというのが動機です。

ソースコードは shige/nextjs-turso-vercel の main ブランチにあります。今回の変更は Issue #5PR #6 です。

なぜ今移行したか

理由は 3 つあります。

  • Turso TypeScript リファレンス が 2026-04-20 にマージされた turso-docs#377 で大きく書き換わり、@libsql/client が「Legacy」セクションへ移動した
  • 新しい @tursodatabase/serverless は pure fetch でネイティブ依存がゼロ。Vercel Functions のような serverless ランタイムで取り回しが楽になる
  • @tursodatabase/sync は embedded replica の同期を pull() / push() の明示呼び出しに変えていて、Server Actions と相性がいい

Drizzle ORM1.0.0-rc.1rc タグ)で drizzle-orm/tursodatabase/database 専用アダプターを公開しています。@tursodatabase/serverless 側にも compat レイヤがあり、こちらは tursodatabase/turso#5834 で drizzle-orm 向けに修正済みです。つまり、新しい 3 パッケージと Drizzle の組み合わせは現時点で公式に動く構成になっています。

パッケージの差し替え

package.json の依存はこう変えました。バージョンは引き続き exact pin です。

パッケージ種類
@libsql/client0.17.3削除dep
@tursodatabase/serverlessなし1.1.2dep
@tursodatabase/databaseなし0.5.3dep
@tursodatabase/syncなし0.5.3dep
drizzle-orm0.45.21.0.0-rc.1dep
drizzle-kit0.31.101.0.0-rc.1devDep

drizzle-orm@1.0.0-rc.1rc タグ側にしか乗っておらず、latest には来ていません。Issue #1 で決めた 4 段階のバージョンロック方針に従い、ここも rc タグを追従するのではなく 1.0.0-rc.1 を直接 pin しています。

ひとつだけ .npmrc 側の調整が必要でした。

save-exact=true
engine-strict=true
auto-install-peers=true
minimum-release-age-exclude[]=drizzle-orm
minimum-release-age-exclude[]=drizzle-kit

pnpm の minimum-release-age は新しすぎる依存をブロックする保護機能ですが、今回の rc.1 は意図して入れる新しいバージョンなので、この 2 パッケージだけ除外指定しています。

ドライバーは 3 モードに増えた

これまでの実装では、@libsql/clientcreateClient()syncUrl の有無を渡すかどうかだけで「remote」と「embedded replica」を切り替えていました。新パッケージでは driver 自体が分かれているので、env から解決した結果に応じて使うパッケージを切り替える設計にしました。

モード条件ドライバーDrizzle アダプター同期
remotelibsql:// または https://@tursodatabase/serverless/compatdrizzle-orm/libsqln/a
syncfile: + TURSO_SYNC_URL@tursodatabase/syncdrizzle-orm/tursodatabase/databasepull() / push()
localfile: のみ@tursodatabase/databasedrizzle-orm/tursodatabase/databasen/a

src/lib/turso.ts の resolver は次のような形です。env を一度だけ評価して Promise<TursoDriver> をキャッシュします。

// src/lib/turso.ts (抜粋)
type TursoMode = "remote" | "local" | "sync";
 
export type TursoDriver =
  | { mode: "remote"; driver: RemoteDriver }
  | { mode: "local"; driver: LocalDriver }
  | { mode: "sync"; driver: SyncDriver };
 
let cachedDriver: Promise<TursoDriver> | undefined;
 
export function getTursoDriver() {
  if (!cachedDriver) {
    cachedDriver = resolveTursoDriver();
  }
  return cachedDriver;
}
 
async function resolveTursoDriver(): Promise<TursoDriver> {
  const databaseUrl = process.env.TURSO_DATABASE_URL;
  if (!databaseUrl) {
    throw new Error("TURSO_DATABASE_URL is required.");
  }
 
  const syncUrl = process.env.TURSO_SYNC_URL;
 
  if (isRemoteUrl(databaseUrl)) {
    if (syncUrl) {
      console.warn(
        "TURSO_SYNC_URL is set with a remote TURSO_DATABASE_URL. Sync mode requires a file: URL, so TURSO_SYNC_URL will be ignored.",
      );
    }
    const { createClient } = await import("@tursodatabase/serverless/compat");
    return {
      mode: "remote",
      driver: createClient({
        url: databaseUrl,
        authToken: process.env.TURSO_AUTH_TOKEN,
      }),
    };
  }
 
  const path = databaseUrl.replace(/^file:/, "");
 
  if (syncUrl) {
    const { connect } = await import("@tursodatabase/sync");
    return {
      mode: "sync",
      driver: await connect({
        path,
        url: syncUrl,
        authToken: process.env.TURSO_AUTH_TOKEN,
      }),
    };
  }
 
  const { Database } = await import("@tursodatabase/database");
  return { mode: "local", driver: new Database(path) };
}
 
function isRemoteUrl(url: string) {
  return url.startsWith("libsql://") || url.startsWith("https://");
}

ポイントは 3 つあります。

  1. ドライバーの import を await import(...) で動的にしている。Vercel の remote 経路では @tursodatabase/database@tursodatabase/sync も呼ばないので、そもそもバンドルに含めたくないからです
  2. remote URL に TURSO_SYNC_URL を併設するという意味のない構成は、エラーにせず警告だけ出して remote として動く
  3. cachedDriverPromise 自体をキャッシュしている。同時に複数のリクエストが入っても、@tursodatabase/syncconnect() が二重に走らない

syncTurso() は syncBefore() / syncAfter() に分割した

@libsql/clientclient.sync() は内部で syncInterval のタイマーが回り、書き込み後にもう一度呼ぶ「念のため」というニュアンスでした。@tursodatabase/sync ではこれを 2 つに分割しています。

  • pull(): リモートの最新状態をローカルに引き取る
  • push(): ローカルの未送信ぶんをリモートへ反映する

Server Actions のセマンティクス(読む前 / 書いた後)にぴったり合うので、syncTurso() を 1 関数で持つのをやめて、syncBefore()(読み取り前)と syncAfter()(書き込み後)に分けました。

// src/lib/turso.ts (抜粋)
export async function syncBefore() {
  const resolved = await getTursoDriver();
  if (resolved.mode === "sync") {
    await resolved.driver.pull();
  }
}
 
export async function syncAfter() {
  const resolved = await getTursoDriver();
  if (resolved.mode === "sync") {
    await resolved.driver.push();
  }
}

呼び出し側はこうなります。

// src/lib/todos.ts (抜粋)
"use server";
 
import { desc, eq, not } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { getDb } from "@/db/client";
import { todos, type Todo as DbTodo } from "@/db/schema";
import { syncAfter, syncBefore } from "./turso";
 
export async function listTodos(): Promise<Todo[]> {
  await syncBefore();
  const db = await getDb();
  return db.select().from(todos).orderBy(desc(todos.createdAt), desc(todos.id));
}
 
export async function addTodo(formData: FormData): Promise<TodoActionResult> {
  const text = String(formData.get("text") ?? "").trim();
  if (!text) return { ok: false, message: "Todo text is required." };
 
  const db = await getDb();
  await db.insert(todos).values({ text });
  await syncAfter();
 
  revalidatePath("/");
  return { ok: true };
}

remote モードでも local モードでも syncBefore() / syncAfter() は no-op で抜けるので、Server Action 側の実装は 1 種類で済みます。TURSO_SYNC_INTERVAL は不要になり、.env.example からも削除しました。

Drizzle のアダプターはモードごとに切り替える

drizzle-orm 側のエントリも変える必要があります。@tursodatabase/serverless/compat は libSQL クライアント互換なので drizzle-orm/libsql を使い、@tursodatabase/database@tursodatabase/sync は新しい drizzle-orm/tursodatabase/database を使う、という割り当てです。

// src/db/client.ts
import { getTursoDriver } from "@/lib/turso";
import * as schema from "./schema";
 
type AppDb = any;
 
let cachedDb: AppDb | undefined;
 
export async function getDb() {
  if (cachedDb) return cachedDb;
 
  const resolved = await getTursoDriver();
 
  if (resolved.mode === "remote") {
    const { drizzle } = await import("drizzle-orm/libsql");
    cachedDb = drizzle({ client: resolved.driver as never, schema }) as unknown as AppDb;
  } else {
    const { drizzle } = await import("drizzle-orm/tursodatabase/database");
    cachedDb = drizzle({ client: resolved.driver as never, schema }) as unknown as AppDb;
  }
 
  return cachedDb;
}

ここでも drizzle-orm/libsqldrizzle-orm/tursodatabase/database を動的 import にしています。Vercel に乗るのは drizzle-orm/libsql のコードパスだけにして、tursodatabase/database は不要なバンドルに含めない方針です。AppDbany にしているのは、2 つのアダプターの戻り型が完全には揃っていないためで、ここを単一の BaseSQLiteDatabase で書く整理は将来の宿題にしました。

呼び出し側は import { db } from "@/db/client"const db = await getDb() に直しただけです。前回の db.select().from(todos) のような Drizzle のクエリ表現はそのまま動きます。

Next.js の serverExternalPackages を足した

@tursodatabase/database@tursodatabase/sync はネイティブモジュール(プリビルドバイナリ)を含みます。Next.js のサーバーバンドルにこれらを混ぜるとビルド時の bundling で壊れるので、next.config.ts で external にしました。

// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  serverExternalPackages: ["@tursodatabase/database", "@tursodatabase/sync"],
  turbopack: {
    root: process.cwd(),
  },
};
 
export default nextConfig;

serverExternalPackages は「これらは bundle せずランタイムで require してね」という宣言です。Vercel のサーバーレス関数の remote 経路ではそもそも import すらされないので、副作用なく外せます。@tursodatabase/serverless は pure JS なので、この指定は不要です。

マイグレーション周りの差分

drizzle-kit@1.0.0-rc.1 ではマイグレーションのファイル形式が変わっていて、drizzle/0000_loose_madrox.sqldrizzle/meta/ という構造から、drizzle/<timestamp>_loose_madrox/{migration.sql, snapshot.json} という per-folder の構造になりました。

リネームと journal のフォーマット変更に追従する必要はありますが、SQL の中身は同じです。pnpm db:generate を流したときに既存スキーマとの diff が空であることを確認してから commit しました。

mise exec node@24.15.0 -- pnpm db:generate
# No schema changes detected

加えて .gitignore*.db-changes を追加しました。@tursodatabase/sync がローカルに作る変更ログ用のファイルです。

環境変数のまとめ

最終的に .env.example はこうなりました。

# Production (Vercel): set these two only
TURSO_DATABASE_URL=libsql://your-db.turso.io
TURSO_AUTH_TOKEN=...
 
# Local file mode (no sync)
# TURSO_DATABASE_URL=file:local-replica.db
 
# Local sync mode (@tursodatabase/sync)
# TURSO_DATABASE_URL=file:local-replica.db
# TURSO_SYNC_URL=libsql://your-db.turso.io
# TURSO_AUTH_TOKEN=...

TURSO_SYNC_INTERVAL は完全に消えています。@tursodatabase/sync は invocation 駆動なので、interval を設定する場所自体がなくなりました。

検証

PR #6 では次の順番で検証しました。

  1. pnpm typecheckpnpm build が通ること
  2. pnpm db:generate が空 diff で終わること(マイグレーションフォーマットだけ移行する)
  3. local file モード(file:local-only.db のみ)で CRUD
  4. local sync モード(file:local-replica.db + TURSO_SYNC_URL)で書き込み → turso db shell でリモート側に行が出ているのを確認
  5. remote モード(libsql://... 直結)で CRUD
  6. Vercel に preview deploy して、preview URL 越しに CRUD

local sync モードの「書いて push されるところ」と「pull で他クライアントの変更が見えるところ」を別々に確かめられるのが、syncBefore() / syncAfter() 分割の利点でした。

やってみた感想

よかった点気になった点
@tursodatabase/serverless が pure fetch で動くので、Vercel Functions 上での導入がとても素直。コールドスタートでもネイティブビルドの心配がないlocal 系の 2 パッケージはネイティブ依存があるので、Next.js の serverExternalPackages 指定が必要。混ざる経路で Vercel ビルドがコケた経験あり
pull() / push() の分割が Server Actions の意味論にぴったり合う。「読む前に同期、書いた後に同期」という疑似コードがそのままコードになるcachedDriverPromise でキャッシュする工夫がいる。シングルトン的に書いたつもりが connect() の二重呼びでハマるケースがあった
Drizzle アダプターを動的 import にしたことで、remote 経路のバンドルに tursodatabase/database が混ざらないアダプターを 2 種類使い分けるぶん、戻り型を綺麗な共通型でまとめる作業が残っている。今は AppDb = any で逃げている
drizzle-kit@1.0.0-rc.1 のマイグレーションフォーマット移行も、生成された SQL の中身が変わらないので落ち着いて進められたrc タグに置かれている rc を採用するため、pnpm の minimum-release-age-exclude を明示する作業が必要だった

特に印象的だったのは、データ層がパッケージレベルで丸ごと入れ替わったにもかかわらず、Drizzle のクエリ表現と Server Actions のシグネチャが一切変わらなかった点です。ORM のレイヤを噛ませておくと、こういう legacy → modern の移行が局所的に閉じてくれる、という実感が得られました。

まとめ

@libsql/client@tursodatabase/serverless / @tursodatabase/database / @tursodatabase/sync の 3 パッケージに置き換え、Drizzle ORM も 1.0.0-rc.1 の新アダプターに上げました。env から remote / local / sync を解決するドライバー設計、pull() / push() の明示化、Next.js 側の serverExternalPackages 設定、drizzle-kit@rc のマイグレーションフォーマット移行までが今回の実作業です。

リポジトリ全体は shige/nextjs-turso-vercel に置いてあります。前回の記事と読み比べると、@libsql/client 1 つで済んでいた Turso 連携が、サーバーレス向け / ローカル向け / 同期向けの 3 パッケージに整理された変化が見えるはずです。

以上、Turso の新しいパッケージ群にデモを引き上げてみた、現場からお送りしました。

参考情報