Migrating the Next.js + Turso on Vercel Demo to @tursodatabase/* Packages — Leaving the Legacy @libsql/client Behind with Three-Mode Driver Selection
I migrated the Next.js + Turso + Vercel demo I wrote about previously to Turso’s newly reorganized @tursodatabase/* package family. @libsql/client is now explicitly tagged as legacy in the official reference, and a demo repo is exactly the right place to track the current shape of the stack.
The source lives at shige/nextjs-turso-vercel. This round of changes is captured in Issue #5 and PR #6.
Why migrate now
Three reasons.
- The Turso TypeScript reference was reorganized in turso-docs#377 (merged 2026-04-20), and
@libsql/clientwas moved into a “Legacy” section. - The new @tursodatabase/serverless is built on plain
fetchwith zero native dependencies, which is a much better fit for serverless runtimes such as Vercel Functions. - @tursodatabase/sync replaces background-interval sync with explicit
pull()andpush()calls, which lines up nicely with Server Actions semantics.
Drizzle ORM 1.0.0-rc.1 (on the rc tag) ships a dedicated drizzle-orm/tursodatabase/database adapter for the new packages. On the serverless side, the @tursodatabase/serverless/compat layer was patched specifically for drizzle-orm in tursodatabase/turso#5834. In other words, the new three-package layout combined with Drizzle is officially supported today.
Swapping the dependencies
Here is the actual package.json diff. Versions stay exact-pinned.
| Package | Before | After | Type |
|---|---|---|---|
| @libsql/client | 0.17.3 | removed | dep |
| @tursodatabase/serverless | (none) | 1.1.2 | dep |
| @tursodatabase/database | (none) | 0.5.3 | dep |
| @tursodatabase/sync | (none) | 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 only exists on the rc tag (it has not been promoted to latest). To stay consistent with the four-layer version-lock policy from Issue #1, I exact-pinned 1.0.0-rc.1 rather than tracking the rc tag.
That introduced one small .npmrc tweak.
save-exact=true
engine-strict=true
auto-install-peers=true
minimum-release-age-exclude[]=drizzle-orm
minimum-release-age-exclude[]=drizzle-kitpnpm’s minimum-release-age setting blocks dependencies that are too freshly published. The rc.1 release is intentionally fresh, so I excluded just these two packages.
The driver now has three modes
In the previous implementation, @libsql/client’s single createClient() toggled between “remote” and “embedded replica” based on whether syncUrl was passed. With the new packages the drivers are physically split, so I redesigned the resolver to pick a package based on env vars.
| Mode | Trigger | Driver | Drizzle adapter | Sync |
|---|---|---|---|---|
| remote | libsql:// or https:// URL | @tursodatabase/serverless (/compat) | drizzle-orm/libsql | n/a |
| sync | file: URL plus TURSO_SYNC_URL | @tursodatabase/sync | drizzle-orm/tursodatabase/database | pull() / push() |
| local | file: URL only | @tursodatabase/database | drizzle-orm/tursodatabase/database | n/a |
The resolver in src/lib/turso.ts evaluates env once and caches a Promise<TursoDriver>.
// excerpt from 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://");
}Three things worth calling out.
- Driver imports are dynamic (
await import(...)). The Vercel remote path never touches@tursodatabase/databaseor@tursodatabase/sync, so I do not want them in the bundle at all. - A remote URL paired with
TURSO_SYNC_URLis a logically nonsensical configuration. I chose to log a warning and proceed in remote mode rather than throw. cachedDrivercaches thePromiseitself. If two requests race in, the singleton-shaped pattern still avoids double-callingconnect()from@tursodatabase/sync.
syncTurso() became syncBefore() and syncAfter()
@libsql/client’s client.sync() ran on a background syncInterval and was usually called as a “just-in-case” nudge after writes. @tursodatabase/sync splits that responsibility into two operations.
pull()brings the latest remote state into the local file.push()flushes pending local writes to the remote database.
That maps very cleanly onto Server Action semantics (“before a read” vs. “after a write”), so I retired the single syncTurso() helper and exposed two functions instead: syncBefore() for reads and syncAfter() for writes.
// excerpt from 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();
}
}The call sites end up looking like this.
// excerpt from 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 };
}syncBefore() and syncAfter() are no-ops in remote and local modes, so each Server Action can stay mode-agnostic. TURSO_SYNC_INTERVAL is no longer needed and was removed from .env.example.
The Drizzle adapter is selected per mode
Drizzle’s entry point also has to switch. @tursodatabase/serverless/compat exposes a libSQL-compatible API, so it routes through drizzle-orm/libsql. Both @tursodatabase/database and @tursodatabase/sync go through the new drizzle-orm/tursodatabase/database adapter.
// 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;
}Both Drizzle entry points are imported dynamically too, so a Vercel build only pulls in drizzle-orm/libsql, not tursodatabase/database. AppDb is currently typed as any because the two adapters’ return types are not yet structurally identical; reconciling them under a shared BaseSQLiteDatabase is a follow-up.
The call sites changed almost trivially: import { db } from "@/db/client" became const db = await getDb(). All Drizzle query expressions (db.select().from(todos) and friends) carry over verbatim.
Adding serverExternalPackages to next.config.ts
@tursodatabase/database and @tursodatabase/sync ship native modules (prebuilt binaries). Letting Next.js attempt to bundle those into the server output breaks the build, so I declared them as external in next.config.ts.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@tursodatabase/database", "@tursodatabase/sync"],
turbopack: {
root: process.cwd(),
},
};
export default nextConfig;serverExternalPackages tells Next.js: “do not bundle these; require them at runtime.” On the Vercel serverless function in remote mode they are never imported anyway, so flipping this on costs nothing. @tursodatabase/serverless is pure JavaScript and does not need this treatment.
Migration metadata changes
drizzle-kit@1.0.0-rc.1 introduces a new on-disk format for migrations. The previous drizzle/0000_loose_madrox.sql plus drizzle/meta/* layout is replaced with a per-migration folder: drizzle/<timestamp>_loose_madrox/{migration.sql, snapshot.json}.
The SQL itself is unchanged. After the rename, I confirmed there was nothing semantically new by running pnpm db:generate and verifying it produced an empty diff.
mise exec node@24.15.0 -- pnpm db:generate
# No schema changes detectedI also added *.db-changes to .gitignore. That is the per-database change-log file that @tursodatabase/sync writes alongside the replica.
Final environment variables
Here is the new .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 is gone entirely. @tursodatabase/sync is invocation-driven, so there is no longer a knob to turn there.
Verification
PR #6 was validated in this order.
pnpm typecheckandpnpm buildpass cleanly.pnpm db:generateproduces an empty diff (only the migration metadata format moves).- Local file mode (
file:local-only.dbonly) supports CRUD. - Local sync mode (
file:local-replica.dbplusTURSO_SYNC_URL) inserts a row locally and the same row is visible in the hosted Turso DB viaturso db shell. - Remote mode (a
libsql://...URL directly) supports CRUD with no replica file created. - A Vercel preview deployment exercises the full CRUD flow on the preview URL.
A nice side-effect of the syncBefore() / syncAfter() split is that you can verify “writes were pushed” and “remote-side changes were pulled” as two separate observable steps.
Reflections
| What worked well | What needed care |
|---|---|
@tursodatabase/serverless running on plain fetch makes it very easy to deploy on Vercel Functions. No native build to worry about even on cold starts. | The two file-based packages have native dependencies, so serverExternalPackages is mandatory. I did hit a Vercel build failure once when this was missing. |
Splitting sync into pull() / push() lines up exactly with Server Actions semantics. The pseudocode reads “sync before reading, sync after writing” and the code reads the same. | The cachedDriver needs to cache a Promise, not a resolved value. A naive singleton ended up calling connect() twice under concurrent requests. |
Dynamic-importing the Drizzle adapters means the Vercel bundle never sees tursodatabase/database, which keeps the cold-start budget intact. | Using two adapters means there is still leftover work to unify their return types under a single shared shape. I am leaning on AppDb = any for now. |
The drizzle-kit@1.0.0-rc.1 migration-format move was painless because the generated SQL stayed identical. Running db:generate to confirm an empty diff is a quick sanity check. | Pulling in an rc release on the rc tag forced a small .npmrc change to bypass minimum-release-age. Worth knowing about before you reach for pnpm install. |
The most striking part of the whole exercise was that the entire data layer was swapped at the package level, but the Drizzle query expressions and the Server Action signatures barely moved. Keeping Drizzle in the middle made this legacy-to-modern migration genuinely local.
Summary
I replaced @libsql/client with the trio of @tursodatabase/serverless, @tursodatabase/database, and @tursodatabase/sync, and bumped Drizzle ORM to 1.0.0-rc.1 with its new tursodatabase adapter. The work split into: an env-driven driver resolver with three modes, an explicit pull() / push() sync model, the matching Next.js serverExternalPackages config, and the metadata move for the new drizzle-kit@rc migration format.
The full project lives at shige/nextjs-turso-vercel. Reading this post next to the previous one shows how a Turso integration that used to fit in a single @libsql/client import has been split into purpose-built packages for serverless, local, and local-first sync.
That’s all from the Gemba, where we lifted the demo onto Turso’s new package family.
References
- Previous post: Building a Next.js + Turso on Vercel Demo App with @libsql/client
- Repository: shige/nextjs-turso-vercel
- This work: Issue #5 Migrate from @libsql/client (legacy) to @tursodatabase/* packages / PR #6 Migrate Turso driver to @tursodatabase packages
- Turso TypeScript reference
- New-package docs PR (turso-docs#377)
@tursodatabase/serverlesscompat fix (turso#5834)- Drizzle ORM documentation
- drizzle-orm 1.0.0-rc.1 changelog
- Next.js serverExternalPackages
- pnpm minimum-release-age setting