Turso is a hosted database built on libSQL (a fork of SQLite). The @libsql/client driver speaks HTTP and WebSocket, so the same DaloyJS app works on Node, Bun, Deno, and Cloudflare Workers.
One client, two connection modes
@libsql/clientcreateClient(...)HTTP and WebSocket
node / bun on durable diskEmbedded replicaurl: file:local.db + syncUrl · local reads, writes to primary
The same createClient() driver serves both modes. Use an embedded replica on a long-lived server with a durable disk, and the remote HTTP client on Workers and serverless functions.
1. Provision
Create a database via the Turso CLI or dashboard, then grab the URL and auth token. Set them as TURSO_DATABASE_URL and TURSO_AUTH_TOKEN. Prefer a database-scoped token over a group or organization token.
2. Install
bash
pnpm add @libsql/client
bash
npm install @libsql/client
bash
yarn add @libsql/client
bash
bun add @libsql/client
3. Create a Turso plugin
src/db/turso.tsts
import { createClient, type Client } from "@libsql/client";import type { App } from "@daloyjs/core";export const db: Client = createClient({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN,});// Call with the root app, not through app.register(): a decoration made// inside a registered plugin is visible only to that plugin's own routes.export function tursoPlugin(app: App) { app.decorate("db", db); // Runs on the Node, Bun, and Deno adapters after in-flight requests drain. app.onClose(() => { db.close(); });}
4. Augment app state
Add the declare module block to the same module that creates the client, not to a separate .d.ts file. Declaration files are exempt from type-checking when skipLibCheck is on (the scaffolded default), so a broken import inside a .d.ts fails silently and state.db degrades to any.
ts
// src/db/turso.ts (same module as the plugin above)declare module "@daloyjs/core" { interface AppState { db: Client; }}
Pass request values as arguments, never by string concatenation: state.db.execute({ sql: "select * from todos where id = ?", args: [id] }).
Embedded replicas (Node, Bun)
For very low-latency reads, use an embedded replica: a local SQLite file that syncs with the primary in the background. Writes still go to the primary. Reads are local.
src/db/turso-replica.tsts
import { createClient } from "@libsql/client";export const db = createClient({ url: "file:/var/lib/my-api/replica.db", syncUrl: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN, syncInterval: 60,});
An embedded replica needs a durable, writable filesystem that the process keeps between requests: a VM, a container with a persistent volume, or a long-lived Node/Bun server. Do not use it on Lambda, Vercel, Netlify, or other serverless functions (read-only or throwaway /tmp storage, a fresh copy per instance), or on Workers (no filesystem). Use the remote client there.
Cloudflare Workers
Use the remote HTTP client from @libsql/client/web (no embedded replicas in Workers). Store the URL and token as Worker secrets, run pnpm wrangler types, import env from cloudflare:workers, and decorate the app once before registering routes. Do not decorate inside the Worker fetch handler: decorate() throws on the second request.
src/index.tsts
import { env } from "cloudflare:workers";import { createClient, type Client } from "@libsql/client/web";import { z } from "zod";import { App, secureHeaders } from "@daloyjs/core";import { toFetchHandler } from "@daloyjs/core/cloudflare";declare module "@daloyjs/core" { interface AppState { db: Client; }}const app = new App({ production: true, behindProxy: { hops: 1 } });app.use(secureHeaders());// Decorate before the routes. The client opens no connection until a query// runs inside a request.app.decorate( "db", createClient({ url: env.TURSO_DATABASE_URL, authToken: env.TURSO_AUTH_TOKEN }),);app.get( "/todos/count", { operationId: "countTodos", responses: { 200: { description: "ok", body: z.object({ count: z.number() }) } }, }, async ({ state }) => { const result = await state.db.execute("select count(*) as count from todos"); return { status: 200, body: { count: Number(result.rows[0]?.count ?? 0) } }; },);export default toFetchHandler(app);
With Drizzle ORM
bash
pnpm add drizzle-orm
bash
npm install drizzle-orm
bash
yarn add drizzle-orm
bash
bun add drizzle-orm
src/db/drizzle.tsts
import { drizzle } from "drizzle-orm/libsql";import { createClient } from "@libsql/client";const client = createClient({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN,});export const db = drizzle({ client });
With Prisma
Prisma 7 connects through a driver adapter, which is mandatory, and generates the client into your source tree. Use the libSQL adapter (current release: Prisma 7.10):
import { PrismaLibSql } from "@prisma/adapter-libsql";import { PrismaClient } from "../generated/prisma/client.ts";const adapter = new PrismaLibSql({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN,});export const prisma = new PrismaClient({ adapter });
prisma migrate dev does not run against Turso directly. Prisma's Turso guide generates SQL with prisma migrate diff against a local SQLite file and applies it with the Turso CLI. See Prisma for the plugin and shutdown setup.