Building a Next.js + Turso on Vercel Demo App with @libsql/client — From the Official Guide to Migrating to Drizzle ORM
I built a demo app that connects Next.js to Turso and deploys to Vercel. The premise is a tiny todo list: reads happen in Server Components, writes happen in Server Actions, and the UI is updated immediately with useOptimistic. Halfway through I also migrated the data layer from raw @libsql/client to Drizzle ORM.
The source lives at shige/nextjs-turso-vercel.
Why I built this demo
There were three motivations.
- I wanted to try Turso and libSQL from a real Vercel deployment.
- I wanted to see how embedded replicas actually feel during local development.
- I wanted to walk through the migration from raw SQL to Drizzle ORM by hand, on the same UI.
Instead of forking the official nextjs-turso-starter, I built it from scratch following the Turso Drizzle integration guide. Starting minimal makes it much easier to layer things on top later.
Tech stack
I picked the latest stable releases as of 2026-05-01 and pinned exact versions in package.json.
| Technology | Version | Role |
|---|---|---|
| Next.js | 16.2.4 | Framework (App Router with Turbopack) |
| React | 19.2.5 | UI library |
| @libsql/client | 0.17.3 | Turso connection client |
| Drizzle ORM | 0.45.2 | Type-safe query builder |
| drizzle-kit | 0.31.10 | Migration CLI |
| Tailwind CSS | 4.2.4 | Styling |
| TypeScript | 6.0.3 | Type system |
| Node.js | 24.15.0 (LTS “Krypton”) | Runtime |
| pnpm | 10.33.2 | Package manager |
Architecture
The request flow is slightly different in production (Vercel) and locally (with the 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
In production the Vercel serverless function talks to Turso directly. Locally the app reads and writes a file:local-replica.db SQLite file that periodically syncs both ways with Turso. Files do not persist between serverless invocations, so production deliberately does not use the replica.
The directory layout looks like this.
src/
app/
layout.tsx # Root layout (Server Component)
page.tsx # Top page (Server Component, fetches todos)
globals.css
components/
TodoApp.tsx # Optimistic state with useOptimistic (Client)
TodoForm.tsx # Add form (Client)
TodoList.tsx # List view (Client)
TodoItem.tsx # Single todo row (Client)
db/
schema.ts # Drizzle schema
client.ts # drizzle(getTurso(), { schema })
lib/
turso.ts # Wrapper around @libsql/client
todos.ts # "use server" actions
drizzle/
0000_loose_madrox.sql
meta/
drizzle.config.tsI split the work into two PRs
The implementation is staged into two pull requests.
- Issue #1 and PR #2: get a working todo app with raw @libsql/client, all the way to a live Vercel deploy.
- Issue #3 and PR #4: keep the same UI and behavior, swap the data layer for Drizzle ORM.
Starting with Drizzle from day one would have been faster, but doing the no-ORM step first surfaces more of what Drizzle is actually doing for you, so I deliberately took the long route.
PR #2: getting the todo app running on raw libSQL
The first PR follows the Turso Quickstart closely.
src/lib/turso.ts exposes a thin wrapper that returns an @libsql/client instance. The interesting part is the branch on TURSO_SYNC_URL, which switches between embedded-replica mode and plain hosted mode.
// 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() is exported separately so the page can render even when env vars are not configured. It came in handy when I deployed an empty project to Vercel just to check the wiring.
syncTurso() only calls sync() when TURSO_SYNC_URL is set (i.e., during local development). It is a no-op in production.
The top-level Server Component is straightforward.
// 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" ensures the page re-renders on every request. Combined with revalidatePath("/") after each Server Action, you always see the freshest todo list right after a write.
Server Actions and the write path
Writes go through Server Actions. The interesting bits are everything that touches Turso, so I will only show the addTodo excerpt.
// excerpt from 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 };
}Three DB-related things to call out.
- Each action calls
syncTurso()at the end. In replica mode this pushes the write to the hosted Turso database immediately (in production it is a no-op). - The toggle action flips the boolean at the SQL level with
not(todos.completed), so there is no read-modify-write round trip. - The return type is a discriminated
{ ok, message }shape, so an error from the Server Action can be surfaced cleanly to the client.
listTodos, toggleTodo, and deleteTodo all follow the same pattern. The full file is at src/lib/todos.ts.
The client side is a fairly stock App Router setup: useOptimistic for optimistic updates and useTransition to fire the Server Action. That part is todo-specific UI rather than Turso integration, so I am skipping it here. The components live in src/components/.
PR #4: migrating to Drizzle ORM
Until PR #2, the data layer was hand-written SQL with a schema.sql file applied through turso db shell. PR #4 replaces that with Drizzle.
The schema is now expressed in 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;Declaring completed as integer({ mode: "boolean" }) keeps the column as 0/1 in SQLite while exposing it as a real boolean to TypeScript. The Todo type is inferred via $inferSelect, so there is no separate hand-maintained type to keep in sync.
The DB client is just a Drizzle wrapper around the existing @libsql/client.
// 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 });Because Drizzle simply wraps getTurso(), the embedded-replica behavior keeps working as before. @libsql/client is still in the dependency tree at runtime, with Drizzle layered on top.
drizzle.config.ts configures 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!,
},
});There is a subtle gotcha here: TURSO_DATABASE_URL controls where drizzle-kit migrate runs. If your local .env sets TURSO_DATABASE_URL=file:local-replica.db, drizzle-kit will happily migrate your replica file instead of the hosted Turso database. The fix is to keep a separate .env.migrate (with the remote URL only) and load that file when running drizzle-kit.
The Server Actions themselves moved exactly as shown earlier: turso.execute("INSERT INTO ...", [text]) becomes db.insert(todos).values({ text }).
Reconciling with the existing table
The hosted Turso database already had a todos table from PR #2’s schema.sql. The freshly generated 0000_loose_madrox.sql is a CREATE TABLE migration, and __drizzle_migrations is empty, so running pnpm db:migrate straight away would error with “table already exists”.
There are two ways out.
- Drop the existing table and apply
0000from scratch. - Manually insert a row into
__drizzle_migrationsso0000is recorded as already applied.
Since this is a demo with disposable data, I went with option (1). The destructive reset of the demo todos table is called out explicitly in PR #4. For a real database with real data you would want option (2).
How I pinned versions
As Issue #1 lays out, the demo uses a four-layer version lock for reproducibility.
package.json: every dependency is pinned exactly.engines.nodeandengines.pnpmare also fixed..npmrc:save-exact=true,engine-strict=true,auto-install-peers=true..nvmrc: locks local Node to24.15.0.pnpm-lock.yaml: always committed; CI and Vercel install with--frozen-lockfile.
One caveat: Vercel only lets you select the Node major, so package.json declares "engines.node": "24.x", while .nvmrc keeps local development pinned to the exact 24.15.0 patch.
Deploying to Vercel
This part is mostly stock Next.js.
vercel link
vercel env add TURSO_DATABASE_URL
vercel env add TURSO_AUTH_TOKEN
vercel deploy --prodTwo things are worth flagging.
- Do not set
TURSO_SYNC_URLin Vercel production. Serverless functions do not persist files between invocations, so a replica file would not survive long enough to be useful, and you would pay a sync cost on every cold start anyway. - I do not run migrations from the build step.
pnpm db:migrateis invoked manually from my laptop. That is fine for a demo; for a real app I would run it from a pre-deploy job instead.
next.config.ts pins the Turbopack root explicitly.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {
root: process.cwd(),
},
};
export default nextConfig;This is mostly to silence Turbopack’s auto-root warning in a single-package repo.
Reflections
| What worked well | What needed care |
|---|---|
| Turso plus libSQL is a great match for Vercel. The HTTP-based protocol means you do not have to worry about connection pooling on serverless. | The embedded replica is convenient, but the behavior pivots on whether TURSO_SYNC_URL is set, so you need a clear mental model up front. |
| The Server Actions DX is excellent. No fetch, no API route, no response parsing: you just call a function. | It is easy to point drizzle-kit migrate at the file: replica by accident. A separate .env.migrate for the remote URL is the safer pattern. |
Drizzle is a thin wrapper, so the migration from @libsql/client was incremental and low-risk. | Adopting Drizzle on a database that already has tables means you have to reconcile the migration journal one way or another. |
useOptimistic makes the UI feel snappy. With a discriminated union for actions, the reducer ends up genuinely type-safe. | useFormStatus, useOptimistic, and useTransition overlap in places; figuring out which one to reach for takes a bit of practice. |
The most striking part of the whole exercise was how mechanical the @libsql/client-to-Drizzle migration ended up being. The client components never had to change: as long as the Todo type kept the same shape, everything compiled. Switching Todo from a hand-written interface to a schema-derived $inferSelect was a one-line edit. Having a single TypeScript declaration that produces both the runtime DDL and the static type is genuinely nice, and SQL primitives like not() translate cleanly into the query builder.
Summary
I built a Next.js + Turso + Vercel demo end to end, from a minimal scaffold all the way through a Drizzle ORM migration. Reading from Server Components, writing through Server Actions, and updating the UI optimistically is a really pleasant model once you get used to it. Embedded replicas do not belong in production, but they meaningfully improve the local-development experience.
The full project is at shige/nextjs-turso-vercel. pnpm install followed by pnpm dev should be enough to get it running.
That’s all from the Gemba, where we wired up Next.js, Turso, and Vercel.
References
- Repository: shige/nextjs-turso-vercel
- Initial implementation: Issue #1 Implementation plan / PR #2 Build Next.js Turso Vercel demo
- Drizzle migration: Issue #3 Migrate to Drizzle ORM / PR #4 Migrate todo data layer to Drizzle ORM
- Next.js documentation
- Turso documentation
- Turso Embedded Replicas
- Drizzle ORM documentation
- Drizzle + Turso guide
- Vercel documentation