Building a Next.js + Turso on Vercel Demo App with @libsql/client — From the Official Guide to Migrating to Drizzle ORM

Tadashi Shigeoka ·  Fri, May 1, 2026

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.

TechnologyVersionRole
Next.js16.2.4Framework (App Router with Turbopack)
React19.2.5UI library
@libsql/client0.17.3Turso connection client
Drizzle ORM0.45.2Type-safe query builder
drizzle-kit0.31.10Migration CLI
Tailwind CSS4.2.4Styling
TypeScript6.0.3Type system
Node.js24.15.0 (LTS “Krypton”)Runtime
pnpm10.33.2Package 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.ts

I split the work into two PRs

The implementation is staged into two pull requests.

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.

  1. 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).
  2. The toggle action flips the boolean at the SQL level with not(todos.completed), so there is no read-modify-write round trip.
  3. 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.

  1. Drop the existing table and apply 0000 from scratch.
  2. Manually insert a row into __drizzle_migrations so 0000 is 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.node and engines.pnpm are also fixed.
  • .npmrc: save-exact=true, engine-strict=true, auto-install-peers=true.
  • .nvmrc: locks local Node to 24.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 --prod

Two things are worth flagging.

  • Do not set TURSO_SYNC_URL in 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:migrate is 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 wellWhat 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