Skip to content

Search docs

Jump between documentation pages.

Browse docs

Use Turso (libSQL) with DaloyJS

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
workers / serverlessRemote HTTP clienturl + authToken · no embedded replicas
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

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;
  }
}

5. Use it in a route

src/server.tsts
import { z } from "zod";
import { App, secureHeaders } from "@daloyjs/core";
import { tursoPlugin } from "./db/turso.ts";

const app = new App();
app.use(secureHeaders());
tursoPlugin(app);

const TodoSchema = z.object({ id: z.number(), title: z.string(), done: z.boolean() });

app.get(
  "/todos",
  {
    operationId: "listTodos",
    responses: { 200: { description: "ok", body: z.array(TodoSchema) } },
  },
  async ({ state }) => {
    const result = await state.db.execute("select id, title, done from todos");
    const rows = result.rows.map((r) => ({
      id: Number(r.id),
      title: String(r.title),
      done: Boolean(r.done),
    }));
    return { status: 200, body: rows };
  },
);

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
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):

bash
pnpm add @prisma/client @prisma/adapter-libsql
bash
pnpm add -D prisma
prisma/schema.prismaprisma
datasource db {
  provider = "sqlite"
}

generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}
src/db/prisma.tsts
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.

See also Cloudflare D1 for a SQLite-style option that's built into Workers, or the database hosting overview.

Edit this page on GitHub

Last updated