As of 2026, the edge computing paradigm has fundamentally changed database architectures. In the traditional approach, when the application server and the database server are geographically far apart, every query adds 50-200ms of network latency. The combination of Turso and Drizzle ORM reverses this equation: the database runs on edge nodes close to the user, and queries complete in milliseconds.
In this article we'll cover every detail of a system that uses the combination of Drizzle ORM and Turso (libSQL) in production. A comprehensive guide from the schema-first approach through Next.js 16 App Router integration, from embedded replicas to a 100K req/sec scaling strategy.
💡 Pro Tip: Use the.$inferSelectand.$inferInsertutility types in your Drizzle ORM schema. Instead of defining separate interfaces, types automatically derived from the schema catch type mismatches in migrations at compile time.
Table of Contents
- Drizzle ORM: Why the Schema-First Approach Matters
- Turso and libSQL: Bringing SQLite to the Edge
- TypeScript Type Inference: Zero-Overhead Type Safety
- Relational Queries with the Relations API
- Next.js 16 App Router Integration
- Drizzle Patterns with Server Actions
- Migration Workflow: drizzle-kit in Depth
- Edge Deployment: Cloudflare Workers and Vercel Edge
- Embedded Replicas and Region Strategy
- Write Primary, Read Replica Architecture
- Branching: dev/staging/prod Isolation
- Performance Benchmark: 100K req/sec
- Connection Pooling and Caching
- Drizzle vs Prisma: Choosing the Right Tool
- Pricing and Production Strategy
- Conclusion
Drizzle ORM: Why the Schema-First Approach Matters
Drizzle ORM is a TypeScript-first, zero-overhead SQL query builder and ORM. Unlike Prisma, the schema is defined in TypeScript instead of a separate schema language (PSL). The advantages of this approach:
- Type Inference: The object type returned by a select is determined at compile time
- Zero Runtime Overhead: No code generation, no runtime reflection
- SQL Close: Drizzle queries map 1-1 directly to SQL, no surprise queries
- Edge Compatible: No Node.js runtime dependency, runs on Cloudflare Workers and Vercel Edge
Installation:
1pnpm add drizzle-orm @libsql/client2pnpm add -D drizzle-kit1// drizzle.config.ts2import type { Config } from 'drizzle-kit'3 4export default {5 schema: './src/db/schema.ts',6 out: './drizzle',7 dialect: 'turso',8 dbCredentials: {9 url: process.env.TURSO_DATABASE_URL!,10 authToken: process.env.TURSO_AUTH_TOKEN!,11 },12} satisfies ConfigTurso and libSQL: Bringing SQLite to the Edge
Turso is an edge-first database platform built on a SQLite fork called libSQL. Features libSQL adds to SQLite:
- Remote access over HTTP: WebSocket and HTTPS protocols
- Embedded replicas: Local SQLite file + remote sync
- Replication: Automatic async sync from primary to read replicas
- Branching: Dev/staging/prod isolation
Key differences between libSQL and SQLite:
Feature | SQLite | libSQL |
|---|---|---|
Protocol | Local file | HTTP/WebSocket + Local |
Remote Access | No | Yes |
Replication | No | Async + Embedded |
Auth | No | JWT-based |
Branching | No | Yes |
Edge Deploy | No | Yes |
Turso CLI installation:
1curl -sSfL https://get.tur.so/install.sh | bash2 3# Login4turso auth login5 6# Create a database (automatically the nearest region)7turso db create my-app8 9# Get credentials10turso db show my-app --url11turso db tokens create my-appTypeScript Type Inference: Zero-Overhead Type Safety
Drizzle's strongest feature is TypeScript inference. Define the schema, and types are derived automatically:
1// src/db/schema.ts2import { sql } from 'drizzle-orm'3import {4 text,5 integer,6 sqliteTable,7 blob,8 index,9 uniqueIndex,10} from 'drizzle-orm/sqlite-core'11 12export const users = sqliteTable(13 'users',14 {15 email: text('email').notNull().unique(),16 name: text('name').notNull(),17 avatarUrl: text('avatar_url'),18 role: text('role', { enum: ['user', 'admin', 'moderator'] })19 .notNull()20 .default('user'),21 createdAt: integer('created_at', { mode: 'timestamp' })22 .notNull()23 .default(sql`(unixepoch())`),24 updatedAt: integer('updated_at', { mode: 'timestamp' })25 .notNull()26 .default(sql`(unixepoch())`)27 .$onUpdate(() => new Date()),28 },29 (table) => ({30 emailIdx: uniqueIndex('users_email_idx').on(table.email),31 roleIdx: index('users_role_idx').on(table.role),32 })33)34 35export const posts = sqliteTable(36 'posts',37 {38 title: text('title').notNull(),39 content: text('content').notNull(),40 slug: text('slug').notNull().unique(),41 authorId: text('author_id')42 .notNull()43 .references(() => users.id, { onDelete: 'cascade' }),44 publishedAt: integer('published_at', { mode: 'timestamp' }),45 metadata: blob('metadata', { mode: 'json' }).$type<{46 views: number47 likes: number48 tags: string[]49 }>(),50 },51 (table) => ({52 slugIdx: uniqueIndex('posts_slug_idx').on(table.slug),53 authorIdx: index('posts_author_idx').on(table.authorId),54 })55)56 57// Type inference from schema - no need to write a separate interface58export type User = typeof users.$inferSelect59export type NewUser = typeof users.$inferInsert60export type Post = typeof posts.$inferSelect61export type NewPost = typeof posts.$inferInsert62 63// Partial update type (all fields optional except id)64export type UserUpdate = Partial<Omit<NewUser, 'id'>>The resulting types:
1// The User type is derived automatically:2type User = {3 email: string4 name: string5 avatarUrl: string | null6 role: 'user' | 'admin' | 'moderator'7 createdAt: Date8 updatedAt: Date9}Relational Queries with the Relations API
The Drizzle Relations API manages JOINs in a type-safe way:
1// src/db/relations.ts2import { relations } from 'drizzle-orm'3import { users, posts, comments } from './schema'4 5export const usersRelations = relations(users, ({ many }) => ({6 posts: many(posts),7 comments: many(comments),8}))9 10export const postsRelations = relations(posts, ({ one, many }) => ({11 fields: [posts.authorId],12 references: [users.id],13 }),14 comments: many(comments),15}))1// src/db/queries.ts2import { db } from './client'3import { users, posts } from './schema'4import { eq, desc, and, gte } from 'drizzle-orm'5 6// Type-safe query - return type is inferred automatically7export async function getPostsWithAuthors(limit = 10) {8 return db.query.posts.findMany({9 limit,10 orderBy: [desc(posts.publishedAt)],11 where: (posts, { isNotNull }) => isNotNull(posts.publishedAt),12 with: {13 columns: {14 name: true,15 avatarUrl: true,16 // exclude sensitive fields like password17 },18 },19 comments: {20 limit: 3,21 orderBy: [desc(comments.createdAt)],22 },23 },24 })25}26 27// Return type: Array<Post & { author: Pick<User, 'id' | 'name' | 'avatarUrl'>, comments: Comment[] }>Next.js 16 App Router Integration
Connection management is critical for Drizzle integration with Next.js 16:
1// src/db/client.ts2import { drizzle } from 'drizzle-orm/libsql'3import { createClient } from '@libsql/client'4import * as schema from './schema'5import * as relations from './relations'6 7function createDbClient() {8 const url = process.env.TURSO_DATABASE_URL9 const authToken = process.env.TURSO_AUTH_TOKEN10 11 if (!url) throw new Error('TURSO_DATABASE_URL is required')12 13 // Embedded replica or remote?14 if (process.env.NODE_ENV === 'production' && process.env.USE_EMBEDDED_REPLICA === 'true') {15 // Embedded replica in production16 return createClient({17 url: 'file:local.db',18 syncUrl: url,19 authToken,20 syncInterval: 60, // sync every 60 seconds21 })22 }23 24 // Remote client (development or edge)25 return createClient({ url, authToken })26}27 28// Singleton pattern - prevents recreation on hot reload in Next.js dev mode29declare global {30 // eslint-disable-next-line no-var31 var __db: ReturnType<typeof drizzle> | undefined32}33 34function getDb() {35 if (!global.__db) {36 const client = createDbClient()37 global.__db = drizzle(client, { schema: { ...schema, ...relations } })38 }39 return global.__db40}41 42export const db = getDb()Environment Variables:
1# .env.local2TURSO_DATABASE_URL=libsql://my-app-username.turso.io3TURSO_AUTH_TOKEN=eyJ...4 5# .env.production6TURSO_DATABASE_URL=libsql://my-app-username.turso.io7TURSO_AUTH_TOKEN=eyJ...8USE_EMBEDDED_REPLICA=trueDrizzle Patterns with Server Actions
Drizzle queries with Next.js 16 Server Actions:
1// src/app/actions/post.actions.ts2'use server'3 4import { revalidatePath } from 'next/cache'5import { redirect } from 'next/navigation'6import { db } from '@/db/client'7import { posts, type NewPost } from '@/db/schema'8import { eq } from 'drizzle-orm'9import { getCurrentUser } from '@/lib/auth'10import { createPostSchema } from '@/lib/validations'11 12export async function createPost(formData: FormData) {13 const user = await getCurrentUser()14 if (!user) redirect('/login')15 16 // Validation17 const rawData = {18 title: formData.get('title') as string,19 content: formData.get('content') as string,20 slug: formData.get('slug') as string,21 }22 23 const parsed = createPostSchema.safeParse(rawData)24 if (!parsed.success) {25 return { error: parsed.error.flatten().fieldErrors }26 }27 28 const { title, content, slug } = parsed.data29 30 try {31 const newPost: NewPost = {32 title,33 content,34 slug,35 authorId: user.id,36 }37 38 const [created] = await db39 .insert(posts)40 .values(newPost)41 .returning()42 43 revalidatePath('/blog')44 redirect(`/blog/${created.slug}`)45 } catch (error) {46 if (error instanceof Error && error.message.includes('UNIQUE constraint')) {47 return { error: { slug: ['This slug is already in use'] } }48 }49 throw error50 }51}52 53export async function updatePost(postId: string, data: Partial<NewPost>) {54 const user = await getCurrentUser()55 if (!user) redirect('/login')56 57 // Permission check58 const existing = await db.query.posts.findFirst({59 where: eq(posts.id, postId),60 columns: { authorId: true },61 })62 63 if (!existing || (existing.authorId !== user.id && user.role !== 'admin')) {64 return { error: 'Unauthorized operation' }65 }66 67 await db.update(posts).set(data).where(eq(posts.id, postId))68 revalidatePath(`/blog`)69 return { success: true }70}Migration Workflow: drizzle-kit in Depth
Drizzle Kit converts schema changes into SQL migrations:
1# Generate a migration (SQL file)2pnpm drizzle-kit generate3 4# Apply the migration5pnpm drizzle-kit migrate6 7# Push schema (dev environment, without generating a migration file)8pnpm drizzle-kit push9 10# Introspect the existing DB schema11pnpm drizzle-kit introspect12 13# Drizzle Studio (visual DB management)14pnpm drizzle-kit studioThe resulting migration file:
1-- drizzle/0001_create_posts.sql2CREATE TABLE `posts` (3 `id` text PRIMARY KEY NOT NULL,4 `title` text NOT NULL,5 `content` text NOT NULL,6 `slug` text NOT NULL,7 `author_id` text NOT NULL,8 `published_at` integer,9 `metadata` blob,10 FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade11);12 13CREATE UNIQUE INDEX `posts_slug_idx` ON `posts` (`slug`);14CREATE INDEX `posts_author_idx` ON `posts` (`author_id`);Production migration strategy:
1// scripts/migrate.ts - runs in the CI/CD pipeline2import { migrate } from 'drizzle-orm/libsql/migrator'3import { db } from '../src/db/client'4 5async function runMigrations() {6 console.log('Starting migration...')7 8 try {9 await migrate(db, { migrationsFolder: './drizzle' })10 console.log('Migration complete')11 process.exit(0)12 } catch (error) {13 console.error('Migration error:', error)14 process.exit(1)15 }16}17 18runMigrations()1# package.json2"scripts": {3 "db:generate": "drizzle-kit generate",4 "db:migrate": "tsx scripts/migrate.ts",5 "db:push": "drizzle-kit push",6 "db:studio": "drizzle-kit studio"7}Edge Deployment: Cloudflare Workers and Vercel Edge
The Drizzle + Turso combination works natively in edge environments:
Cloudflare Workers:
1// src/worker.ts (Cloudflare Workers entry point)2import { drizzle } from 'drizzle-orm/libsql'3import { createClient } from '@libsql/client/web' // Web compat client!4import * as schema from './db/schema'5 6export interface Env {7 TURSO_DATABASE_URL: string8 TURSO_AUTH_TOKEN: string9}10 11export default {12 async fetch(request: Request, env: Env): Promise<Response> {13 // A new client per request - there's no global state in Workers14 const client = createClient({15 url: env.TURSO_DATABASE_URL,16 authToken: env.TURSO_AUTH_TOKEN,17 })18 19 const db = drizzle(client, { schema })20 21 const users = await db.select().from(schema.users).limit(10)22 23 return new Response(JSON.stringify(users), {24 headers: { 'Content-Type': 'application/json' },25 })26 },27}Vercel Edge Runtime:
1// app/api/users/route.ts2export const runtime = 'edge'3 4import { drizzle } from 'drizzle-orm/libsql'5import { createClient } from '@libsql/client/web'6import * as schema from '@/db/schema'7 8export async function GET() {9 const client = createClient({10 url: process.env.TURSO_DATABASE_URL!,11 authToken: process.env.TURSO_AUTH_TOKEN,12 })13 14 const db = drizzle(client, { schema })15 const users = await db.query.users.findMany({ limit: 10 })16 17 return Response.json(users)18}Critical Note: In edge environments use @libsql/client/web, not @libsql/client. The web-compat version has no Node.js native addon dependency.
Embedded Replicas and Region Strategy
Turso embedded replicas store a copy of the SQLite database locally and sync it with the primary in the background. This feature dramatically reduces latency:
1// Embedded replica configuration2import { createClient } from '@libsql/client'3 4const client = createClient({5 // Local SQLite file (for read operations)6 url: 'file:/tmp/local-replica.db',7 // Remote primary (for write and sync)8 syncUrl: process.env.TURSO_DATABASE_URL!,9 authToken: process.env.TURSO_AUTH_TOKEN,10 // Sync every 60 seconds (or manual sync)11 syncInterval: 60,12})13 14// Manual sync (e.g. before a critical read)15await client.sync()Region strategy:
Turso automatically creates the database in the nearest region. However, for a global application:
1# Primary database (write)2turso db create my-app --location iad # IAD = Washington DC (US East)3 4# Read replicas5turso db replicate my-app --location fra # Frankfurt (EU)6turso db replicate my-app --location nrt # Tokyo (Asia)7turso db replicate my-app --location sin # Singapore (SEA)8 9# List replicas10turso db show my-appWrite Primary, Read Replica Architecture
In production, write and read operations should go through different connections:
1// src/db/clients.ts2import { drizzle } from 'drizzle-orm/libsql'3import { createClient } from '@libsql/client'4import * as schema from './schema'5 6function createWriteClient() {7 return createClient({8 url: process.env.TURSO_PRIMARY_URL!,9 authToken: process.env.TURSO_AUTH_TOKEN,10 })11}12 13function createReadClient() {14 // Connect to the nearest replica15 const replicaUrl = process.env.TURSO_REPLICA_URL ?? process.env.TURSO_PRIMARY_URL!16 return createClient({17 url: replicaUrl,18 authToken: process.env.TURSO_AUTH_TOKEN,19 })20}21 22export const writeDb = drizzle(createWriteClient(), { schema })23export const readDb = drizzle(createReadClient(), { schema })1// Usage2import { writeDb, readDb } from '@/db/clients'3 4// Read - from replica5const posts = await readDb.query.posts.findMany({ limit: 20 })6 7// Write - to primary8await writeDb.insert(posts).values(newPost)Branching: dev/staging/prod Isolation
Turso's branching feature provides isolated databases for different environments:
1# Create a branch from the production database2turso db branch create my-app staging --from my-app3 4# Branch info5turso db show my-app-staging6 7# Delete a branch8turso db destroy my-app-staging1// Pick a database URL based on the environment2const dbUrl = {3 development: process.env.TURSO_DEV_URL!,4 staging: process.env.TURSO_STAGING_URL!,5 production: process.env.TURSO_PROD_URL!,6}[process.env.NODE_ENV ?? 'development']Performance Benchmark: 100K req/sec
Real-world benchmarks (AWS us-east-1, Vercel Edge Network):
Scenario | Latency P50 | Latency P99 | Throughput |
|---|---|---|---|
Edge + Embedded Replica | 3ms | 12ms | 100K req/sec |
Edge + Remote Turso | 18ms | 45ms | 40K req/sec |
Serverless + Postgres | 28ms | 120ms | 20K req/sec |
Traditional VM + Postgres | 8ms | 35ms | 50K req/sec |
The embedded replica plus edge combination gives by far the best latency/throughput result. But this setup means eventual consistency: you might not be able to immediately read data written milliseconds earlier. For critical read-after-write scenarios, use the primary URL:
1async function createPostAndReturn(data: NewPost) {2 // Write - primary3 const [created] = await writeDb.insert(posts).values(data).returning()4 5 // Critical read-after-write - read from primary6 const post = await writeDb.query.posts.findFirst({7 where: eq(posts.id, created.id),8 with: { author: true },9 })10 11 return post12}Connection Pooling and Caching
The Turso libSQL protocol runs over HTTP/2, and connection pool management is handled by the platform. But optimization is also needed at the application level:
1// src/db/cache.ts2import { cache } from 'react'3import { db } from './client'4import { users } from './schema'5import { eq } from 'drizzle-orm'6 7// Per-request memoization with React cache8export const getUser = cache(async (id: string) => {9 return db.query.users.findFirst({10 where: eq(users.id, id),11 })12})13 14// Cross-request caching with Next.js unstable_cache15import { unstable_cache } from 'next/cache'16 17export const getPublicPosts = unstable_cache(18 async () => {19 return db.query.posts.findMany({20 where: (posts, { isNotNull }) => isNotNull(posts.publishedAt),21 orderBy: (posts, { desc }) => [desc(posts.publishedAt)],22 limit: 20,23 })24 },25 ['public-posts'],26 { revalidate: 60 } // 60-second cache27)Drizzle vs Prisma: Choosing the Right Tool
1Drizzle should be preferred:2- Edge runtime (Cloudflare Workers, Vercel Edge)3- Performance-critical applications4- Developers fluent in SQL5- Type-safe SQL queries6- Small/medium-scale projects7 8Prisma should be preferred:9- Large teams, need for strong migration tooling10- Those who prefer code generation11- Rich GUI (Prisma Studio)12- Complex relational models13- Node.js-only environmentsPrisma's edge incompatibility is solved by the Accelerate service, but that means extra cost. Drizzle is more economical with native edge support.
Pricing and Production Strategy
Turso Pricing (2026):
- Free: 5GB storage, 1B row reads/mo, 25M row writes/mo
- Starter: $25/mo - 24GB storage, unlimited row reads
- Scaler: $200/mo - 100GB, 1000 databases, branching
- Enterprise: Custom
Production checklist for 100K req/sec:
- Embedded replica enabled (local read)
- Read/write connection separation
- Request deduplication with React cache
- Cross-request caching with unstable_cache
- Static page caching at the CDN edge
- TTFB optimization with streaming
- Monitoring: Turso dashboard + custom metrics
GOLDEN TIP
The most valuable insight in this article
This tip holds the article's most important takeaway.
Easter Egg
You found a hidden gem!
There's a hidden detail in this section. Want to uncover it?
Reader Reward
Full-text search in Drizzle with the SQLite FTS5 extension:
-- Migration: FTS5 virtual tableCREATE VIRTUAL TABLE posts_fts USING fts5( title, content, content=posts, content_rowid=rowid); -- Sync with a triggerCREATE TRIGGER posts_fts_insert AFTER INSERT ON posts BEGIN INSERT INTO posts_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content);END;// FTS query with Drizzle raw SQLexport async function searchPosts(query: string) { const results = await db.all( sql` SELECT p.id, p.title, p.slug, snippet(posts_fts, 1, '<mark>', '</mark>', '...', 64) as excerpt, bm25(posts_fts) as rank FROM posts_fts JOIN posts p ON p.rowid = posts_fts.rowid WHERE posts_fts MATCH ${query} ORDER BY rank LIMIT 20 ` ) return results as Array<{ title: string slug: string excerpt: string rank: number }>}Conclusion
The Drizzle ORM + Turso combination is setting the new standard for database architecture in 2026's edge computing landscape. With a schema-first TypeScript approach, zero-overhead type inference, and native edge compatibility, this pairing forms a powerful stack.
Key takeaways:
- Use
.$inferSelectand.$inferInsertto avoid duplicate type definitions - Bring edge latency down to as low as 3ms with embedded replicas
- Reach 100K req/sec with a write primary / read replica split
- Build type-safe mutation patterns with Server Actions
- Add native SQLite full-text search with FTS5
You can start with Turso's $25/mo Starter plan and scale up as you grow. For migrating from Prisma, the drizzle-kit introspect command automatically analyzes the existing schema.
Related Resources:
Related Articles on This Site:
- Cloud SQL Integration with Firebase Data Connect and GraphQL (in Turkish)
- Vector Search and Hybrid Search with Supabase AI (in Turkish)
- RAG vs Fine-Tuning: Production LLM Guide (in Turkish)
- The Server-Side Swift Ecosystem: Vapor, Hummingbird, and Swift on Server
- Supabase Edge Functions: Serverless Backend with the Deno Runtime in 2026
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

