· typescript / turso / sqlite
How to set up Turso (libSQL) in a TypeScript project
Set up Turso (libSQL) in TypeScript from scratch: install @libsql/client, run CRUD, add Drizzle ORM, and manage local dev — with the gotchas most guides miss.
By toolchew
1,753 words · 9 min read
Turso is a hosted SQLite database that works anywhere your TypeScript runs — Node.js, Bun, Cloudflare Workers, Vercel Edge. The free tier covers 100 databases, 500 million row reads, and 5 GB of storage per month. That is enough to run a real app in production without paying anything.
This tutorial takes you from zero to a working Turso setup in TypeScript, with optional Drizzle ORM on top. Every code block here is runnable; none of it is pseudocode.
Who this is for
TypeScript developers who want a lightweight, low-cost SQLite database in the cloud. You should be comfortable with async/await and have Node.js 18+ (or Bun 1+) installed. If you need complex JSON path queries, PostGIS, or very high write throughput, stay on Postgres — see the gotchas section below.
Step 1: Create a Turso database
Via CLI (recommended)
# Install the Turso CLI (macOS)
brew install tursodatabase/tap/turso
# Authenticate
turso auth login
# Create a database
turso db create my-app-db
# Get the connection URL
turso db show my-app-db
# Create an auth token
turso db tokens create my-app-db
turso db show prints a URL in the form libsql://my-app-db-[org].turso.io. turso db tokens create prints the token. Copy both — you need them in a moment.
Via the web dashboard
Sign up at turso.tech, click New Database, choose a region, and copy the URL and token from the dashboard. The dashboard also shows row read/write counts, storage, and PITR restore points.
Step 2: Install @libsql/client
For Node.js and Bun runtimes, install @libsql/client. For Cloudflare Workers and other edge runtimes, Turso recommends @tursodatabase/serverless instead — see the edge runtime note in Step 3.
npm install @libsql/client
# or
bun add @libsql/client
Store your credentials:
# .env
TURSO_DATABASE_URL=libsql://my-app-db-[org].turso.io
TURSO_AUTH_TOKEN=your-token-here
The env var names matter. Many tutorials use DATABASE_URL or DATABASE_AUTH_TOKEN — both are wrong. Use TURSO_DATABASE_URL and TURSO_AUTH_TOKEN.
Step 3: Connect
The client supports three URL schemes. One package, one createClient call, three modes:
import { createClient } from '@libsql/client';
// 1. In-memory — for testing or scratch work
const client = createClient({ url: ':memory:' });
// 2. Local SQLite file — for development
const client = createClient({ url: 'file:local.db' });
// No authToken needed for file: connections
// 3. Remote Turso Cloud — for production
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
Edge runtimes (Cloudflare Workers, Vercel Edge Functions): Turso recommends the @tursodatabase/serverless package for edge runtimes. Install it separately and import from @tursodatabase/serverless/compat:
npm install @tursodatabase/serverless
// Cloudflare Workers and other edge runtimes
import { createClient } from '@tursodatabase/serverless/compat';
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
The /compat path exposes the same createClient interface as @libsql/client, so the rest of your code stays unchanged. It does not support file: URLs, so your local dev still uses the default @libsql/client import.
Step 4: CRUD with the raw client
The core API is client.execute(). Pass a plain SQL string, or an object with sql and args for parameterized queries. Always parameterize — never interpolate user input into SQL strings.
import { createClient } from '@libsql/client';
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
// Create a table
await client.execute(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
)
`);
// INSERT
const insert = await client.execute({
sql: 'INSERT INTO todos (text) VALUES (?)',
args: ['Buy oat milk'],
});
console.log('new id:', insert.lastInsertRowid);
// SELECT all
const { rows } = await client.execute('SELECT * FROM todos');
console.log(rows);
// SELECT one row
const { rows: [todo] } = await client.execute({
sql: 'SELECT * FROM todos WHERE id = ?',
args: [1],
});
// UPDATE
await client.execute({
sql: 'UPDATE todos SET done = 1 WHERE id = ?',
args: [1],
});
// DELETE
await client.execute({
sql: 'DELETE FROM todos WHERE id = ?',
args: [1],
});
The result shape is { rows, columns, rowsAffected, lastInsertRowid }.
Batch and transactions
Use client.batch() when you want all-or-nothing execution across multiple statements without interactive control:
await client.batch([
'INSERT INTO users (name) VALUES ("alice")',
{ sql: 'INSERT INTO orders (user_id) VALUES (?)', args: [1] },
], 'write');
Use client.transaction() when you need to commit or roll back based on runtime logic:
const txn = await client.transaction('write');
try {
await txn.execute('UPDATE accounts SET balance = balance - 100 WHERE id = 1');
await txn.execute('UPDATE accounts SET balance = balance + 100 WHERE id = 2');
await txn.commit();
} catch (err) {
await txn.rollback();
throw err;
}
Transaction modes: 'write' (full read/write), 'read' (SELECT only), 'deferred' (starts read, escalates on demand).
Step 5: Drizzle ORM (recommended)
Drizzle is the most common ORM pairing for Turso. The setup takes five minutes. If you are deciding between Drizzle and Kysely, see Drizzle vs Kysely for a side-by-side comparison of the two most-used typed query tools in TypeScript.
Install
npm install drizzle-orm @libsql/client dotenv
npm install -D drizzle-kit tsx
drizzle.config.ts
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'turso', // NOT 'sqlite' — 'turso' is required
dbCredentials: {
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
},
});
dialect: 'sqlite' is the most common mistake here. It will silently ignore the authToken field. Use 'turso'.
Schema
// src/db/schema.ts
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const usersTable = sqliteTable('users_table', {
id: int().primaryKey({ autoIncrement: true }),
name: text().notNull(),
age: int().notNull(),
email: text().notNull().unique(),
});
Database connection
// src/db/index.ts
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/libsql';
export const db = drizzle({
connection: {
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
},
});
CRUD with Drizzle
import { eq } from 'drizzle-orm';
import { db } from './db';
import { usersTable } from './db/schema';
// INSERT
await db.insert(usersTable).values({ name: 'Alice', age: 28, email: '[email protected]' });
// SELECT all
const users = await db.select().from(usersTable);
// SELECT where
const [user] = await db
.select()
.from(usersTable)
.where(eq(usersTable.email, '[email protected]'));
// UPDATE
await db
.update(usersTable)
.set({ age: 29 })
.where(eq(usersTable.email, '[email protected]'));
// DELETE
await db
.delete(usersTable)
.where(eq(usersTable.email, '[email protected]'));
Migrations
# Generate migration files
npx drizzle-kit generate
# Push schema directly (dev shortcut — no migration files generated)
npx drizzle-kit push
# Apply existing migration files
npx drizzle-kit migrate
Step 6: Local development
Three options, in order of how often you want to reach for them:
Option A — SQLite file (simplest). Add TURSO_DATABASE_URL=file:local.db to .env.local and let your createClient call pick it up. No server, no CLI, works fully offline. This is the right default for most projects.
Option B — turso dev local server. Runs a local libSQL server at http://127.0.0.1:8080. Useful when you need to test libSQL-specific behaviour (embedded replica sync, multi-database queries) that plain SQLite won’t replicate.
# Ephemeral — data is gone when the process stops
turso dev
# Persistent — data survives restarts
turso dev --db-file local.db
The ephemeral mode losing data on stop is the single most common gotcha in Turso’s support channels. Always pass --db-file unless you want a throw-away database.
Point your app at http://127.0.0.1:8080 with any non-empty string as the auth token for local use.
Option C — live remote database. Point dev directly at a non-production Turso Cloud database. Simplest prod parity; not worth the cloud egress cost for routine local work.
Gotchas vs Postgres
Data types
SQLite has fewer native types than Postgres. Here is how to map them:
| Postgres type | libSQL equivalent |
|---|---|
uuid | TEXT — use crypto.randomUUID() in app |
jsonb | TEXT — parse in app; no JSON path operators |
ARRAY | Not supported — use a join table or JSON text |
ENUM | TEXT with CHECK constraint |
BOOLEAN | INTEGER (0/1) |
TIMESTAMPTZ | INTEGER (Unix ms) or TEXT (ISO 8601) |
Write throughput
Turso is designed for read-heavy workloads. The remote primary uses a single-writer model, so high-concurrency write workloads — a busy event log, a payment ledger, a multiplayer game state store — should stay on Postgres instead.
Turso’s embedded replica mode makes local reads microsecond-fast. That read speed applies to the local replica only — remote writes still round-trip to the primary.
Missing features
- No stored procedures or server-side functions
- No
LISTEN/NOTIFY - No PostGIS or extension ecosystem
- No materialized views
- Limited
ALTER TABLE— adding columns is fine; renaming or dropping columns requires recreating the table - Full-text search via FTS5 exists but is less capable than Postgres
tsvector
What Turso adds over plain SQLite
- Networked access over HTTP/WebSocket
- Multi-database per org (100 free, unlimited on paid)
- Point-in-time restore
- Native vector search — useful for embedding workloads
- WAL mode always on (no need to
PRAGMA journal_mode=WAL)
Pricing and when to use it
As of June 2026 (turso.tech/pricing):
| Plan | Price | Databases | Storage | Rows Read/mo | Rows Written/mo |
|---|---|---|---|---|---|
| Free | $0 | 100 | 5 GB | 500M | 10M |
| Developer | $4.99/mo | Unlimited | 9 GB | 2.5B | 25M |
| Scaler | $24.92/mo | Unlimited | 24 GB | 100B | 100M |
| Pro | $416.58/mo | Unlimited | 50 GB (+$0.45/GB) | 250B | 250M |
The free tier’s 500M row reads is more than most read-heavy apps serving a few thousand daily active users will consume in a month.
Choosing between Turso and Cloudflare D1? Our Turso vs D1 comparison covers latency, pricing, and feature tradeoffs in detail.
Use Turso when:
- You’re building edge-first (Cloudflare Workers, Vercel Edge, Deno Deploy) and want low-latency reads
- You want per-tenant database isolation — 100 databases free is unusually generous
- You are on a budget and your data model is simple CRUD without complex Postgres-specific types
- You want native vector search for AI/embedding workloads
Stay on Postgres when:
- You need high concurrent write throughput (busy event logs, payment ledgers, multiplayer state)
- Your schema uses
JSONBpath queries,PostGIS, custom domains, or logical replication - Your team is deep in the Postgres ecosystem and migration cost outweighs the edge latency benefit