前回の記事 で作った Next.js + Turso + Vercel のデモアプリを、Turso が新しく整理した @tursodatabase/* パッケージファミリに移行しました。@libsql/client は公式リファレンスで legacy 扱いになっており、デモなら早めに新しい構成に乗せ替えておきたいというのが動機です。
ソースコードは shige/nextjs-turso-vercel の main ブランチにあります。今回の変更は Issue #5 と PR #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 ORM も 1.0.0-rc.1(rc タグ)で drizzle-orm/tursodatabase/database 専用アダプターを公開しています。@tursodatabase/serverless 側にも compat レイヤがあり、こちらは tursodatabase/turso#5834 で drizzle-orm 向けに修正済みです。つまり、新しい 3 パッケージと Drizzle の組み合わせは現時点で公式に動く構成になっています。
パッケージの差し替え
package.json の依存はこう変えました。バージョンは引き続き exact pin です。
| パッケージ | 旧 | 新 | 種類 |
|---|---|---|---|
| @libsql/client | 0.17.3 | 削除 | dep |
| @tursodatabase/serverless | なし | 1.1.2 | dep |
| @tursodatabase/database | なし | 0.5.3 | dep |
| @tursodatabase/sync | なし | 0.5.3 | dep |
| drizzle-orm | 0.45.2 | 1.0.0-rc.1 | dep |
| drizzle-kit | 0.31.10 | 1.0.0-rc.1 | devDep |
drizzle-orm@1.0.0-rc.1 は rc タグ側にしか乗っておらず、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-kitpnpm の minimum-release-age は新しすぎる依存をブロックする保護機能ですが、今回の rc.1 は意図して入れる新しいバージョンなので、この 2 パッケージだけ除外指定しています。
ドライバーは 3 モードに増えた
これまでの実装では、@libsql/client の createClient() に syncUrl の有無を渡すかどうかだけで「remote」と「embedded replica」を切り替えていました。新パッケージでは driver 自体が分かれているので、env から解決した結果に応じて使うパッケージを切り替える設計にしました。
| モード | 条件 | ドライバー | Drizzle アダプター | 同期 |
|---|---|---|---|---|
| remote | libsql:// または https:// | @tursodatabase/serverless(/compat) | drizzle-orm/libsql | n/a |
| sync | file: + TURSO_SYNC_URL | @tursodatabase/sync | drizzle-orm/tursodatabase/database | pull() / push() |
| local | file: のみ | @tursodatabase/database | drizzle-orm/tursodatabase/database | n/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 つあります。
- ドライバーの import を
await import(...)で動的にしている。Vercel の remote 経路では@tursodatabase/databaseも@tursodatabase/syncも呼ばないので、そもそもバンドルに含めたくないからです - remote URL に
TURSO_SYNC_URLを併設するという意味のない構成は、エラーにせず警告だけ出して remote として動く cachedDriverはPromise自体をキャッシュしている。同時に複数のリクエストが入っても、@tursodatabase/syncのconnect()が二重に走らない
syncTurso() は syncBefore() / syncAfter() に分割した
@libsql/client の client.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/libsql と drizzle-orm/tursodatabase/database を動的 import にしています。Vercel に乗るのは drizzle-orm/libsql のコードパスだけにして、tursodatabase/database は不要なバンドルに含めない方針です。AppDb を any にしているのは、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.sql と drizzle/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 では次の順番で検証しました。
pnpm typecheckとpnpm buildが通ることpnpm db:generateが空 diff で終わること(マイグレーションフォーマットだけ移行する)- local file モード(
file:local-only.dbのみ)で CRUD - local sync モード(
file:local-replica.db+TURSO_SYNC_URL)で書き込み →turso db shellでリモート側に行が出ているのを確認 - remote モード(
libsql://...直結)で CRUD - 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 の意味論にぴったり合う。「読む前に同期、書いた後に同期」という疑似コードがそのままコードになる | cachedDriver を Promise でキャッシュする工夫がいる。シングルトン的に書いたつもりが 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 の新しいパッケージ群にデモを引き上げてみた、現場からお送りしました。
参考情報
- 前回の記事: Next.js + Turso on Vercel のデモアプリを @libsql/client で作ってみた
- リポジトリ: shige/nextjs-turso-vercel
- 今回の作業: Issue #5 Migrate from @libsql/client (legacy) to @tursodatabase/* packages / PR #6 Migrate Turso driver to @tursodatabase packages
- Turso TypeScript リファレンス
- Turso ドキュメントの新パッケージ対応 PR (turso-docs#377)
@tursodatabase/serverlessの compat 修正 (turso#5834)- Drizzle ORM ドキュメント
- drizzle-orm 1.0.0-rc.1 changelog
- Next.js serverExternalPackages
- pnpm minimum-release-age 設定