← All posts
·13 min read

Claude Code with Turso: Edge SQLite That Works

Claude CodeTursolibSQLSQLiteDatabase
Claude Code with Turso: edge SQLite that scales

Why Turso needs explicit CLAUDE.md constraints

Turso is SQLite at the edge. It runs your database in dozens of regions, replicates to embedded SQLite files on-device for zero-latency reads, and charges based on rows read rather than compute time. The developer experience is good: one environment variable for the URL, one for the auth token, a small client library, and SQL you already know.

The problem is that Claude Code does not know which Turso pattern to reach for. Without explicit instructions, Claude defaults to better-sqlite3 (a Node.js synchronous SQLite driver that does not speak to Turso at all), generates new Client(url) without the auth token, uses db.all() and db.run() from the SQLite3 API instead of client.execute(), and writes Drizzle config targeting SQLite's local file dialect rather than the libSQL HTTP dialect Turso requires.

These are not obscure edge cases. They are the default output of a reasonably competent Claude Code session asked to "add Turso database integration." The CLAUDE.md file is what stops them.

This guide covers the CLAUDE.md configuration, correct @libsql/client patterns, schema migration workflow, embedded replica setup for Cloudflare Workers, batch operations, and the six most common Claude mistakes. For the ORM layer on top of Turso, Claude Code with Drizzle covers the full Drizzle ORM setup including the libSQL adapter. For the Next.js application context these queries will run in, Claude Code with Next.js covers the full request lifecycle.

The Turso CLAUDE.md template

Place this at your project root. Every Claude Code session reads it before generating any code.

# Turso / libSQL rules

## Stack
- @libsql/client ^0.14.x, TypeScript 5.x strict
- Turso platform (primary database + optional embedded replicas)
- Node.js 20.x / Cloudflare Workers / Next.js 14.x edge runtime

## Environment variables
- TURSO_DATABASE_URL   (libsql:// or https:// URL from Turso dashboard)
- TURSO_AUTH_TOKEN     (auth token from Turso dashboard)
- NEVER hardcode either value in source files
- For embedded replicas: TURSO_SYNC_URL + TURSO_AUTH_TOKEN + local :memory: or file path

## Client initialisation
- ALWAYS use a singleton: import { db } from '@/lib/db'
- src/lib/db.ts content:
  import { createClient } from '@libsql/client';
  export const db = createClient({
    url: process.env.TURSO_DATABASE_URL!,
    authToken: process.env.TURSO_AUTH_TOKEN,
  });
- NEVER use better-sqlite3 for Turso connections
- NEVER omit authToken (requests without it return 401 from Turso)
- NEVER write new Client(...). The factory is createClient(), not a class

## Query pattern (MANDATORY)
- All queries: const result = await db.execute({ sql, args })
- Positional params: args as array  →  sql: 'SELECT * FROM users WHERE id = ?', args: [id]
- Named params: args as object     →  sql: 'SELECT * FROM users WHERE id = :id', args: { id }
- result.rows: Row[]  (array-like, access by column name or index)
- result.rowsAffected: number  (for INSERT/UPDATE/DELETE)
- result.lastInsertRowid: bigint | undefined  (for INSERT)

## Batch operations
- Multiple statements in one round trip: await db.batch([{ sql, args }, ...])
- Returns ResultSet[]  (one per statement)
- batch() is ATOMIC on mutations: all succeed or all fail
- Use batch() for multi-table inserts, cascading updates, transactional writes

## Hard rules
- NEVER use db.all(), db.run(), db.get() (those are better-sqlite3 methods, not libSQL)
- NEVER use the Drizzle SQLite file dialect for Turso; use the drizzle-orm/libsql adapter
- NEVER omit authToken field even if value is undefined in dev (let Turso reject, not crash)
- ALWAYS handle BigInt from lastInsertRowid before returning to JSON (JSON.stringify throws)
- NEVER return result.rows directly as JSON without serialisation (Row objects are not plain)
- ALWAYS access row values by column name: row.id, not row[0], for readability

Three rules prevent the majority of production breakage.

The createClient() factory rule matters because Claude's training data contains large amounts of better-sqlite3 and the older @libsql/client API where new Client() was used directly. Both produce TypeScript that compiles cleanly but fails at runtime when it tries to connect to Turso's HTTP endpoint.

The BigInt serialisation rule is invisible until it destroys your API response. lastInsertRowid returns a native JavaScript BigInt because SQLite row IDs can exceed the safe integer range. JSON.stringify(BigInt(1)) throws TypeError: Do not know how to serialize a BigInt. This happens silently in development where row IDs are small, then explodes in production on row 10,000,001.

The named vs positional param rule prevents SQL injection. Claude defaults to string concatenation (\SELECT * FROM users WHERE id = ${id}``) for simple queries because it is faster to write. Declaring both param styles in CLAUDE.md with concrete examples redirects Claude to parameterised queries consistently.

Install and environment setup

npm i @libsql/client

Add the credentials to your environment file:

# .env.local
TURSO_DATABASE_URL=libsql://your-database-name-orgname.turso.io
TURSO_AUTH_TOKEN=eyJhbGciOiJFZERTQS...

Get these from the Turso CLI:

turso db show your-database-name --url
turso db tokens create your-database-name

Create the singleton client:

// src/lib/db.ts
import { createClient } from '@libsql/client';

if (!process.env.TURSO_DATABASE_URL) {
  throw new Error('TURSO_DATABASE_URL is not defined');
}

export const db = createClient({
  url: process.env.TURSO_DATABASE_URL,
  authToken: process.env.TURSO_AUTH_TOKEN,
});

The startup check (if (!process.env.TURSO_DATABASE_URL)) surfaces a missing credential at boot rather than at the first query execution, which could be deep into a request handler. Add both startup checks to the singleton in CLAUDE.md so Claude generates them consistently.

Schema and migrations

Turso supports SQL migrations run via the @libsql/client directly. There is no migration runner bundled with @libsql/client, so the pattern is a script that runs db.batch() with DDL statements in order.

A practical schema for a user and sessions table:

-- migrations/0001_initial.sql
CREATE TABLE IF NOT EXISTS users (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  email     TEXT    NOT NULL UNIQUE,
  name      TEXT    NOT NULL,
  created_at TEXT   NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS sessions (
  id         TEXT    PRIMARY KEY,
  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  expires_at TEXT    NOT NULL,
  created_at TEXT    NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id);

Run this migration as a Node.js script:

// scripts/migrate.ts
import { createClient } from '@libsql/client';
import { readFileSync } from 'fs';
import { join } from 'path';

const db = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN,
});

async function migrate() {
  const sql = readFileSync(join(process.cwd(), 'migrations/0001_initial.sql'), 'utf-8');

  // Split on semicolons, filter empty statements
  const statements = sql
    .split(';')
    .map(s => s.trim())
    .filter(s => s.length > 0);

  await db.batch(
    statements.map(sql => ({ sql }))
  );

  console.log(`Applied ${statements.length} statements`);
}

migrate().catch(console.error);

Run with:

npx tsx scripts/migrate.ts

For projects using Drizzle ORM, the drizzle-kit migration CLI integrates with Turso's libSQL dialect. Add the Drizzle config section to CLAUDE.md to prevent Claude from generating the SQLite file dialect:

## Drizzle ORM config (if using Drizzle)

drizzle.config.ts:
  import { defineConfig } from 'drizzle-kit';
  export default defineConfig({
    dialect: 'turso',
    schema: './src/db/schema.ts',
    out: './migrations',
    dbCredentials: {
      url: process.env.TURSO_DATABASE_URL!,
      authToken: process.env.TURSO_AUTH_TOKEN,
    },
  });

NEVER use dialect: 'sqlite' with a file: URL for Turso; use dialect: 'turso' with libsql:// URL.

Core CRUD patterns

Insert

// src/lib/users.ts
import { db } from '@/lib/db';

export interface CreateUserInput {
  email: string;
  name: string;
}

export async function createUser(input: CreateUserInput) {
  const result = await db.execute({
    sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
    args: [input.email, input.name],
  });

  // lastInsertRowid is BigInt; convert before using in JSON
  return {
    id: Number(result.lastInsertRowid),
    ...input,
  };
}

Select with filtering

export async function getUserByEmail(email: string) {
  const result = await db.execute({
    sql: 'SELECT id, email, name, created_at FROM users WHERE email = ? LIMIT 1',
    args: [email],
  });

  if (result.rows.length === 0) return null;

  const row = result.rows[0];
  return {
    id: Number(row.id),
    email: row.email as string,
    name: row.name as string,
    createdAt: row.created_at as string,
  };
}

export async function listUsers(limit = 50, offset = 0) {
  const result = await db.execute({
    sql: 'SELECT id, email, name, created_at FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?',
    args: [limit, offset],
  });

  return result.rows.map(row => ({
    id: Number(row.id),
    email: row.email as string,
    name: row.name as string,
    createdAt: row.created_at as string,
  }));
}

Update

export async function updateUserName(id: number, name: string) {
  const result = await db.execute({
    sql: 'UPDATE users SET name = ? WHERE id = ?',
    args: [name, id],
  });

  return result.rowsAffected > 0;
}

Delete

export async function deleteUser(id: number) {
  const result = await db.execute({
    sql: 'DELETE FROM users WHERE id = ?',
    args: [id],
  });

  return result.rowsAffected > 0;
}

Add these four patterns to CLAUDE.md as named sections. Claude generates consistent code faster from concrete patterns than from abstract API descriptions. Showing the Number(result.lastInsertRowid) conversion once in the INSERT pattern makes Claude apply it across every generated insert.

Batch operations for transactional writes

The db.batch() method sends multiple statements in a single HTTP round trip to Turso. For mutation statements (INSERT, UPDATE, DELETE), the batch is atomic: if any statement fails, none of them commit.

// Create a user and their initial session in one atomic batch
export async function createUserWithSession(
  email: string,
  name: string,
  sessionId: string,
  expiresAt: string
) {
  const results = await db.batch([
    {
      sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
      args: [email, name],
    },
    {
      sql: `INSERT INTO sessions (id, user_id, expires_at)
            SELECT ?, id, ?
            FROM users WHERE email = ?`,
      args: [sessionId, expiresAt, email],
    },
  ]);

  const userId = Number(results[0].lastInsertRowid);
  return { userId, sessionId };
}

For read-only batches (SELECT), db.batch() is not atomic but still benefits from the single round-trip optimisation. Use it when you need results from multiple tables to render a page:

export async function getDashboardData(userId: number) {
  const [userResult, sessionResult] = await db.batch([
    {
      sql: 'SELECT id, email, name FROM users WHERE id = ?',
      args: [userId],
    },
    {
      sql: 'SELECT id, expires_at FROM sessions WHERE user_id = ? ORDER BY created_at DESC LIMIT 5',
      args: [userId],
    },
  ]);

  return {
    user: userResult.rows[0] ?? null,
    sessions: sessionResult.rows,
  };
}

Add batch patterns to CLAUDE.md:

## Batch operations

- db.batch([{ sql, args }, ...]) sends all statements in one HTTP round trip
- Returns ResultSet[]  (one per statement), destructure by index
- Mutations are atomic: all succeed or all fail
- Read batches are not atomic but save round trips
- NEVER loop db.execute() for related mutations; use db.batch() for correctness + performance
- Max statements per batch: no hard limit, but keep under 100 for latency predictability

Embedded replicas for edge runtimes

Turso's embedded replica mode stores a local SQLite copy of your remote database. Reads hit the local file (zero network latency). Writes go to the remote primary and then sync back. This is the right setup for Cloudflare Workers, Vercel Edge Functions, and any environment where you need sub-millisecond read latency.

// src/lib/db-edge.ts (for Cloudflare Workers or Vercel Edge)
import { createClient } from '@libsql/client';

export const db = createClient({
  url: 'file:local.db',                           // local replica file
  syncUrl: process.env.TURSO_SYNC_URL!,           // remote primary URL
  authToken: process.env.TURSO_AUTH_TOKEN,
  syncInterval: 60,                               // sync every 60 seconds
});

// Manually trigger a sync before reads on critical paths
export async function syncAndQuery<T>(
  queryFn: () => Promise<T>
): Promise<T> {
  await db.sync();
  return queryFn();
}

Add the embedded replica config to CLAUDE.md with a separate section:

## Embedded replicas (edge runtime config)

- For Cloudflare Workers / Vercel Edge: use file: URL + syncUrl
- NEVER use libsql:// URL directly in edge runtimes (TCP not available in all environments)
- syncInterval: 60 is a sensible default; lower for fresher reads, higher to reduce Turso egress
- Call await db.sync() before reads on critical paths that cannot tolerate stale data
- local.db file path is relative to the Worker bundle root (check platform-specific constraints)
- TURSO_SYNC_URL is the same libsql:// URL as TURSO_DATABASE_URL on the primary

Using Turso with Next.js API routes

Turso works in Next.js server components, API routes, and server actions. The client uses HTTP under the hood, so it works in both Node.js and edge runtimes.

// src/app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { listUsers, createUser } from '@/lib/users';

export async function GET(req: NextRequest) {
  const url = new URL(req.url);
  const limit = Number(url.searchParams.get('limit') ?? '50');
  const offset = Number(url.searchParams.get('offset') ?? '0');

  const users = await listUsers(limit, offset);
  return NextResponse.json({ users });
}

export async function POST(req: NextRequest) {
  const body = await req.json();

  if (!body.email || !body.name) {
    return NextResponse.json({ error: 'email and name required' }, { status: 400 });
  }

  try {
    const user = await createUser({ email: body.email, name: body.name });
    return NextResponse.json({ user }, { status: 201 });
  } catch (err: unknown) {
    // UNIQUE constraint on email
    if (err instanceof Error && err.message.includes('UNIQUE constraint failed')) {
      return NextResponse.json({ error: 'Email already registered' }, { status: 409 });
    }
    throw err;
  }
}

Add the error handling pattern for constraint violations to CLAUDE.md:

## Error handling

- db.execute() and db.batch() throw on error (promise rejection)
- Wrap in try/catch; check err.message for constraint names
- UNIQUE constraint: message contains 'UNIQUE constraint failed: table.column'
- Foreign key constraint: message contains 'FOREIGN KEY constraint failed'
- NOT NULL constraint: message contains 'NOT NULL constraint failed'
- Map constraint errors to HTTP 409 Conflict, not 500 Internal Server Error

Turso CLI for development

The Turso CLI is the fastest way to inspect data and run ad-hoc queries during development:

# Install
curl -sSfL https://get.tur.so/install.sh | bash

# Create a database
turso db create my-app

# Open interactive shell
turso db shell my-app

# Run a SQL file
turso db shell my-app < migrations/0001_initial.sql

# Get connection details for .env.local
turso db show my-app --url
turso db tokens create my-app

Add the CLI commands to CLAUDE.md so Claude can suggest them when you ask about running migrations or inspecting data:

## Turso CLI (for development)

- turso db shell <name>              open interactive SQL shell
- turso db shell <name> < file.sql   run a SQL file
- turso db show <name> --url         get database URL
- turso db tokens create <name>      generate auth token
- turso db locations                 show available edge locations
- turso db list                      list all databases in your account

Permission hooks for database scripts

A Turso project accumulates maintenance scripts: migration runners, seed scripts, data export scripts, cleanup scripts. Some are safe to run at any time. Others truncate tables or delete user data. Permission hooks distinguish them.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(npx tsx scripts/migrate.ts*)",
      "Bash(npx tsx scripts/seed-dev.ts*)",
      "Bash(npx tsx scripts/count-rows.ts*)",
      "Bash(turso db shell*)"
    ],
    "deny": [
      "Bash(npx tsx scripts/drop-tables.ts*)",
      "Bash(npx tsx scripts/delete-users.ts*)",
      "Bash(npx tsx scripts/purge-sessions.ts*)"
    ]
  }
}

Read-only scripts and migration runners are safe to auto-execute. Scripts that delete rows or drop tables require explicit confirmation. The deny list surfaces those as prompts instead of letting Claude run them automatically during a "clean up the database" task.

Common Claude Code mistakes with Turso

Six patterns Claude generates without CLAUDE.md constraints, with the correct replacement for each.

1. Using better-sqlite3 driver

Claude generates: import Database from 'better-sqlite3'; const db = new Database('local.db');

Correct: import { createClient } from '@libsql/client'; export const db = createClient({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN });

2. Missing auth token

Claude generates: createClient({ url: process.env.TURSO_DATABASE_URL! }) with no authToken.

Correct: always include authToken: process.env.TURSO_AUTH_TOKEN. Turso returns 401 on any authenticated database without it.

3. Using db.all() and db.run()

Claude generates: db.all('SELECT * FROM users', callback) or db.run('INSERT ...') from the sqlite3 Node.js API.

Correct: await db.execute({ sql: '...', args: [...] }).

4. String concatenation for query params

Claude generates: db.execute(`SELECT * FROM users WHERE id = ${userId}`)

Correct: db.execute({ sql: 'SELECT * FROM users WHERE id = ?', args: [userId] }).

5. Returning BigInt in JSON responses

Claude generates: return { id: result.lastInsertRowid } where lastInsertRowid is a BigInt that breaks JSON.stringify.

Correct: return { id: Number(result.lastInsertRowid) }.

6. Wrong Drizzle dialect

Claude generates: dialect: 'sqlite' with a local file URL in drizzle.config.ts.

Correct: dialect: 'turso' with dbCredentials: { url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN }.

Add all six pairs to CLAUDE.md as a common mistakes section. Claude benefits from the before/after contrast because it can match the pattern it would otherwise generate to the correct form.

Building edge-ready queries

The Turso CLAUDE.md in this guide produces database integrations where the @libsql/client factory is used consistently, auth tokens are never omitted, all queries use parameterised inputs, BigInt row IDs are converted before serialisation, batch operations replace mutation loops, and Drizzle config targets the libSQL dialect.

The underlying principle is the same for any infrastructure integration with Claude Code: the framework's defaults are designed for the common case, and Turso is not the common case Claude was trained on most heavily. The CLAUDE.md template fills that gap, making Turso the default target instead of local SQLite files.

For the ORM layer, Claude Code with Drizzle shows the full schema definition and query builder setup on top of these primitives. For a full-stack app using Turso as the database layer, Claude Code with Next.js covers how these query functions integrate into the request lifecycle.

Get Claudify. The Turso CLAUDE.md template, migration scripts, and edge replica configuration are included, ready to drop into any project.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame