Turso and Drizzle ORM with Next.js: Edge-Ready Databases in 2026

What you will learn: How to connect Turso (a distributed SQLite database) to Next.js 15 via Drizzle ORM, create type-safe schemas, run migrations, and deploy on Vercel Edge Functions for sub-10ms latency worldwide.
Introduction
Traditional databases like PostgreSQL and MySQL run from a single datacenter. When a user in Tunis queries a server in Paris, network latency adds 30 to 80 ms to every request. Multiply that by 5 queries per page and you get half a second of unavoidable delay.
Turso solves this with LibSQL, an open-source fork of SQLite designed for edge distribution. Your database is automatically replicated across dozens of regions. Combined with Drizzle ORM — a lightweight, type-safe TypeScript ORM — and Next.js 15 Edge Functions, you get a complete stack where every query is served from the datacenter closest to your user.
In this tutorial, we will build a bookmark management app with full CRUD and edge-ready deployment.
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed (
node --version) - A free Turso account at turso.tech
- Turso CLI installed:
brew install tursodatabase/tap/turso(macOS) orcurl -sSfL https://get.tur.so/install.sh | bash(Linux) - Basic knowledge of Next.js App Router and TypeScript
- A code editor (VS Code recommended)
What You Will Build
A bookmark application with the following features:
- Create, read, update, and delete bookmarks
- Tag-based categorization
- Full-text search
- API Routes running on Edge Runtime
- Sub-10ms latency thanks to Turso replication
Step 1: Create the Next.js Project
Initialize a new Next.js 15 project with TypeScript:
npx create-next-app@latest turso-bookmarks --typescript --tailwind --eslint --app --src-dir
cd turso-bookmarksSelect the default options when prompted. You should have a standard project structure with the src/app/ directory.
Step 2: Configure Turso
CLI Authentication
Log in to Turso from your terminal:
turso auth loginThis will open your browser for authentication. Once logged in, create your database:
turso db create bookmarks-db --group defaultAutomatic Replication: Turso automatically replicates your database across regions in your group. The default group uses your nearest region. Add replicas with turso db replicate bookmarks-db [region].
Retrieve Credentials
Get your connection URL and token:
turso db show bookmarks-db --url
turso db tokens create bookmarks-dbCreate a .env.local file at the project root:
TURSO_DATABASE_URL=libsql://bookmarks-db-[your-org].turso.io
TURSO_AUTH_TOKEN=eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...Security: Never commit your Turso tokens to Git. Add .env.local to your .gitignore (Next.js does this by default).
Step 3: Install Dependencies
Install Drizzle ORM with the LibSQL driver:
npm install drizzle-orm @libsql/client
npm install -D drizzle-kitHere is what each package does:
| Package | Role |
|---|---|
drizzle-orm | Type-safe TypeScript ORM |
@libsql/client | LibSQL driver for Turso |
drizzle-kit | CLI for migrations and introspection |
Step 4: Configure Drizzle
Database Client Setup
Create the connection file src/lib/db.ts:
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import * as schema from "./schema";
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN,
});
export const db = drizzle(client, { schema });Drizzle Kit Configuration
Create drizzle.config.ts at the project root:
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/lib/schema.ts",
out: "./drizzle",
dialect: "turso",
dbCredentials: {
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN,
},
});Turso Dialect: Since Drizzle Kit v0.22+, the turso dialect is natively supported. It handles LibSQL specifics like integer types for booleans and text for dates.
Step 5: Define the Schema
Create src/lib/schema.ts with the tables for our bookmark app:
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm";
// Bookmarks table
export const bookmarks = sqliteTable("bookmarks", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
url: text("url").notNull(),
description: text("description"),
favicon: text("favicon"),
createdAt: text("created_at")
.notNull()
.$defaultFn(() => new Date().toISOString()),
updatedAt: text("updated_at")
.notNull()
.$defaultFn(() => new Date().toISOString()),
});
// Tags table
export const tags = sqliteTable("tags", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(),
color: text("color").notNull().default("#6366f1"),
});
// Pivot table bookmarks <-> tags
export const bookmarkTags = sqliteTable("bookmark_tags", {
bookmarkId: integer("bookmark_id")
.notNull()
.references(() => bookmarks.id, { onDelete: "cascade" }),
tagId: integer("tag_id")
.notNull()
.references(() => tags.id, { onDelete: "cascade" }),
});
// Relations
export const bookmarksRelations = relations(bookmarks, ({ many }) => ({
bookmarkTags: many(bookmarkTags),
}));
export const tagsRelations = relations(tags, ({ many }) => ({
bookmarkTags: many(bookmarkTags),
}));
export const bookmarkTagsRelations = relations(bookmarkTags, ({ one }) => ({
bookmark: one(bookmarks, {
fields: [bookmarkTags.bookmarkId],
references: [bookmarks.id],
}),
tag: one(tags, {
fields: [bookmarkTags.tagId],
references: [tags.id],
}),
}));Key Schema Points
- SQLite types: LibSQL uses
textandintegeras base types. Dates are stored astextin ISO 8601 format. - Many-to-many relations: The
bookmark_tagstable links bookmarks and tags through a pivot table. - Cascade delete: Deleting a bookmark automatically removes its tag associations.
- Default values:
$defaultFngenerates timestamps on the application side, not the database side.
Step 6: Run Migrations
Generate SQL migration files from your schema:
npx drizzle-kit generateThis creates a drizzle/ folder with numbered SQL files. Apply them to your Turso database:
npx drizzle-kit migrateVerify the tables were created:
turso db shell bookmarks-db ".tables"You should see: bookmarks, tags, bookmark_tags, and the internal Drizzle migration tables.
Production Migrations: Always run drizzle-kit generate locally and commit the SQL files. Never run drizzle-kit push directly in production — use drizzle-kit migrate which applies generated files deterministically.
Step 7: Create Edge API Routes
The real advantage of Turso shows with Edge Functions. Let us create API routes that run on edge runtime.
GET/POST Route for Bookmarks
Create src/app/api/bookmarks/route.ts:
import { db } from "@/lib/db";
import { bookmarks, bookmarkTags, tags } from "@/lib/schema";
import { eq, like, desc } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge";
// GET /api/bookmarks?search=next&tag=dev
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const search = searchParams.get("search");
const tag = searchParams.get("tag");
let query = db.query.bookmarks.findMany({
with: {
bookmarkTags: {
with: {
tag: true,
},
},
},
orderBy: [desc(bookmarks.createdAt)],
});
const results = await query;
// In-memory filtering for search
let filtered = results;
if (search) {
const term = search.toLowerCase();
filtered = filtered.filter(
(b) =>
b.title.toLowerCase().includes(term) ||
b.description?.toLowerCase().includes(term)
);
}
if (tag) {
filtered = filtered.filter((b) =>
b.bookmarkTags.some((bt) => bt.tag.name === tag)
);
}
return NextResponse.json(filtered);
}
// POST /api/bookmarks
export async function POST(request: NextRequest) {
const body = await request.json();
const { title, url, description, tagIds } = body;
if (!title || !url) {
return NextResponse.json(
{ error: "Title and URL are required" },
{ status: 400 }
);
}
const [bookmark] = await db
.insert(bookmarks)
.values({ title, url, description })
.returning();
// Associate tags
if (tagIds && tagIds.length > 0) {
await db.insert(bookmarkTags).values(
tagIds.map((tagId: number) => ({
bookmarkId: bookmark.id,
tagId,
}))
);
}
return NextResponse.json(bookmark, { status: 201 });
}PUT/DELETE Route for a Specific Bookmark
Create src/app/api/bookmarks/[id]/route.ts:
import { db } from "@/lib/db";
import { bookmarks, bookmarkTags } from "@/lib/schema";
import { eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge";
// PUT /api/bookmarks/:id
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const { title, url, description, tagIds } = body;
const [updated] = await db
.update(bookmarks)
.set({
title,
url,
description,
updatedAt: new Date().toISOString(),
})
.where(eq(bookmarks.id, parseInt(id)))
.returning();
if (!updated) {
return NextResponse.json(
{ error: "Bookmark not found" },
{ status: 404 }
);
}
// Update tags
if (tagIds) {
await db
.delete(bookmarkTags)
.where(eq(bookmarkTags.bookmarkId, updated.id));
if (tagIds.length > 0) {
await db.insert(bookmarkTags).values(
tagIds.map((tagId: number) => ({
bookmarkId: updated.id,
tagId,
}))
);
}
}
return NextResponse.json(updated);
}
// DELETE /api/bookmarks/:id
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const [deleted] = await db
.delete(bookmarks)
.where(eq(bookmarks.id, parseInt(id)))
.returning();
if (!deleted) {
return NextResponse.json(
{ error: "Bookmark not found" },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
}Tags Route
Create src/app/api/tags/route.ts:
import { db } from "@/lib/db";
import { tags } from "@/lib/schema";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge";
export async function GET() {
const allTags = await db.select().from(tags);
return NextResponse.json(allTags);
}
export async function POST(request: NextRequest) {
const { name, color } = await request.json();
if (!name) {
return NextResponse.json(
{ error: "Tag name is required" },
{ status: 400 }
);
}
const [tag] = await db
.insert(tags)
.values({ name, color: color || "#6366f1" })
.returning();
return NextResponse.json(tag, { status: 201 });
}Step 8: Create the Main Server Component
Create the main page in src/app/page.tsx that uses Server Components for initial rendering:
import { db } from "@/lib/db";
import { bookmarks, tags } from "@/lib/schema";
import { desc } from "drizzle-orm";
import { BookmarkList } from "@/components/bookmark-list";
export const runtime = "edge";
export default async function HomePage() {
const allBookmarks = await db.query.bookmarks.findMany({
with: {
bookmarkTags: {
with: {
tag: true,
},
},
},
orderBy: [desc(bookmarks.createdAt)],
});
const allTags = await db.select().from(tags);
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="mb-8 text-3xl font-bold">My Bookmarks</h1>
<BookmarkList
initialBookmarks={allBookmarks}
tags={allTags}
/>
</main>
);
}Server Component + Edge: By adding export const runtime = "edge" to the page, Next.js runs the Server Component in an Edge Runtime. The Drizzle query to Turso executes from the nearest edge datacenter — resulting in an ultra-fast Time to First Byte.
Step 9: Create the Client Component
Create src/components/bookmark-list.tsx for the interactive part:
"use client";
import { useState, useTransition } from "react";
type Tag = {
id: number;
name: string;
color: string;
};
type BookmarkTag = {
bookmarkId: number;
tagId: number;
tag: Tag;
};
type Bookmark = {
id: number;
title: string;
url: string;
description: string | null;
favicon: string | null;
createdAt: string;
updatedAt: string;
bookmarkTags: BookmarkTag[];
};
export function BookmarkList({
initialBookmarks,
tags,
}: {
initialBookmarks: Bookmark[];
tags: Tag[];
}) {
const [bookmarksList, setBookmarks] = useState(initialBookmarks);
const [search, setSearch] = useState("");
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const filteredBookmarks = bookmarksList.filter((b) => {
const matchesSearch =
!search ||
b.title.toLowerCase().includes(search.toLowerCase()) ||
b.description?.toLowerCase().includes(search.toLowerCase());
const matchesTag =
!selectedTag ||
b.bookmarkTags.some((bt) => bt.tag.name === selectedTag);
return matchesSearch && matchesTag;
});
async function handleDelete(id: number) {
startTransition(async () => {
const res = await fetch(`/api/bookmarks/${id}`, {
method: "DELETE",
});
if (res.ok) {
setBookmarks((prev) => prev.filter((b) => b.id !== id));
}
});
}
return (
<div>
{/* Search bar */}
<div className="mb-6 flex gap-4">
<input
type="text"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 rounded-lg border px-4 py-2"
/>
<select
value={selectedTag || ""}
onChange={(e) => setSelectedTag(e.target.value || null)}
className="rounded-lg border px-4 py-2"
>
<option value="">All tags</option>
{tags.map((tag) => (
<option key={tag.id} value={tag.name}>
{tag.name}
</option>
))}
</select>
</div>
{/* Bookmarks list */}
<div className="space-y-4">
{filteredBookmarks.map((bookmark) => (
<div
key={bookmark.id}
className="rounded-lg border p-4 transition-shadow hover:shadow-md"
>
<div className="flex items-start justify-between">
<div>
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="text-lg font-semibold text-blue-600 hover:underline"
>
{bookmark.title}
</a>
{bookmark.description && (
<p className="mt-1 text-gray-600">
{bookmark.description}
</p>
)}
<div className="mt-2 flex gap-2">
{bookmark.bookmarkTags.map((bt) => (
<span
key={bt.tagId}
className="rounded-full px-2 py-1 text-xs text-white"
style={{
backgroundColor: bt.tag.color,
}}
>
{bt.tag.name}
</span>
))}
</div>
</div>
<button
onClick={() => handleDelete(bookmark.id)}
disabled={isPending}
className="text-red-500 hover:text-red-700"
>
Delete
</button>
</div>
</div>
))}
</div>
{filteredBookmarks.length === 0 && (
<p className="text-center text-gray-500">
No bookmarks found.
</p>
)}
</div>
);
}Step 10: Add a Seed Script
To test our application, create a seed script at src/lib/seed.ts:
import { db } from "./db";
import { bookmarks, tags, bookmarkTags } from "./schema";
async function seed() {
console.log("Seeding database...");
// Create tags
const [devTag] = await db
.insert(tags)
.values({ name: "dev", color: "#6366f1" })
.returning();
const [designTag] = await db
.insert(tags)
.values({ name: "design", color: "#ec4899" })
.returning();
const [toolsTag] = await db
.insert(tags)
.values({ name: "tools", color: "#14b8a6" })
.returning();
// Create bookmarks
const [bm1] = await db
.insert(bookmarks)
.values({
title: "Next.js Documentation",
url: "https://nextjs.org/docs",
description: "Official Next.js documentation",
})
.returning();
const [bm2] = await db
.insert(bookmarks)
.values({
title: "Turso Documentation",
url: "https://docs.turso.tech",
description: "Complete guide to Turso and LibSQL",
})
.returning();
const [bm3] = await db
.insert(bookmarks)
.values({
title: "Drizzle ORM",
url: "https://orm.drizzle.team",
description: "Headless TypeScript ORM for SQL",
})
.returning();
// Associate tags with bookmarks
await db.insert(bookmarkTags).values([
{ bookmarkId: bm1.id, tagId: devTag.id },
{ bookmarkId: bm2.id, tagId: devTag.id },
{ bookmarkId: bm2.id, tagId: toolsTag.id },
{ bookmarkId: bm3.id, tagId: devTag.id },
{ bookmarkId: bm3.id, tagId: toolsTag.id },
]);
console.log("Seed completed!");
}
seed().catch(console.error);Run the seed:
npx tsx src/lib/seed.tsStep 11: Optimize Edge Performance
Persistent Connections
The LibSQL client automatically manages an HTTP connection pool. For Edge Functions (which are stateless), each invocation creates a new connection. Turso optimizes this with multiplexed HTTP/2 connections.
Caching with Next.js
Use the Next.js cache for data that changes infrequently:
import { unstable_cache } from "next/cache";
export const getCachedTags = unstable_cache(
async () => {
return db.select().from(tags);
},
["all-tags"],
{
revalidate: 3600, // 1 hour
tags: ["tags"],
}
);Embedded Replicas (Optional)
For even lower latencies in local development, Turso supports embedded replicas — a local SQLite copy that syncs automatically:
import { createClient } from "@libsql/client";
const client = createClient({
url: "file:local-replica.db",
syncUrl: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN,
syncInterval: 60, // Sync every 60 seconds
});Embedded Replicas and Edge: Embedded replicas do not work in Edge Runtime (no file system access). Use them only for local development or traditional Node.js servers.
Step 12: Deploy to Vercel
Configure Environment Variables
In your Vercel dashboard, add the following variables:
TURSO_DATABASE_URL— Your Turso database URLTURSO_AUTH_TOKEN— Token generated withturso db tokens create
Configure Edge Runtime
Make sure your API routes and pages use export const runtime = "edge". Vercel will automatically deploy these functions to its global edge network.
Deploy
npx vercel deploy --prodOr connect your Git repository for automatic deployments on every push.
Verify Performance
After deployment, test latency from different regions using curl:
curl -o /dev/null -s -w "Time: %{time_total}s\n" \
https://your-app.vercel.app/api/bookmarksYou should see response times under 50 ms from most regions, with much of that being TLS handshake. The Turso query itself typically takes between 1 and 8 ms.
Step 13: Add Full-Text Search
Turso supports full-text search via FTS5, a SQLite extension. Let us add it to our application.
Create the FTS Table
Create a manual migration drizzle/fts-setup.sql:
CREATE VIRTUAL TABLE IF NOT EXISTS bookmarks_fts USING fts5(
title,
description,
content='bookmarks',
content_rowid='id'
);
-- Triggers to keep FTS in sync
CREATE TRIGGER IF NOT EXISTS bookmarks_ai AFTER INSERT ON bookmarks BEGIN
INSERT INTO bookmarks_fts(rowid, title, description)
VALUES (new.id, new.title, new.description);
END;
CREATE TRIGGER IF NOT EXISTS bookmarks_ad AFTER DELETE ON bookmarks BEGIN
INSERT INTO bookmarks_fts(bookmarks_fts, rowid, title, description)
VALUES('delete', old.id, old.title, old.description);
END;
CREATE TRIGGER IF NOT EXISTS bookmarks_au AFTER UPDATE ON bookmarks BEGIN
INSERT INTO bookmarks_fts(bookmarks_fts, rowid, title, description)
VALUES('delete', old.id, old.title, old.description);
INSERT INTO bookmarks_fts(rowid, title, description)
VALUES (new.id, new.title, new.description);
END;Apply this migration manually via the Turso shell:
turso db shell bookmarks-db < drizzle/fts-setup.sqlSearch Query
Add a search route at src/app/api/search/route.ts:
import { db } from "@/lib/db";
import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge";
export async function GET(request: NextRequest) {
const query = new URL(request.url).searchParams.get("q");
if (!query) {
return NextResponse.json([]);
}
const results = await db.all(
sql`SELECT b.*, bm.rank
FROM bookmarks_fts bm
JOIN bookmarks b ON b.id = bm.rowid
WHERE bookmarks_fts MATCH ${query}
ORDER BY bm.rank
LIMIT 20`
);
return NextResponse.json(results);
}Testing and Verification
To verify everything works:
-
Start the development server:
npm run dev -
Test the API routes:
# List bookmarks curl http://localhost:3000/api/bookmarks # Create a bookmark curl -X POST http://localhost:3000/api/bookmarks \ -H "Content-Type: application/json" \ -d '{"title":"Test","url":"https://example.com"}' # Search curl "http://localhost:3000/api/search?q=next" -
Check data in Turso:
turso db shell bookmarks-db "SELECT * FROM bookmarks"
Troubleshooting Common Issues
Error "TURSO_DATABASE_URL is not defined"
Make sure your .env.local file is at the project root and that you restarted the dev server after creating it.
Error "Token expired"
Turso tokens expire. Regenerate one with:
turso db tokens create bookmarks-db --expiration noneMigration error "table already exists"
If you run migrations multiple times, drop the tables and re-run:
turso db shell bookmarks-db "DROP TABLE IF EXISTS bookmark_tags; DROP TABLE IF EXISTS bookmarks; DROP TABLE IF EXISTS tags;"
npx drizzle-kit migrateEdge Runtime "Module not found"
Some Node.js packages are not compatible with Edge Runtime. Verify that you are only using @libsql/client (which supports HTTP) and not better-sqlite3 or any other native driver.
Next Steps
You now have a complete application with an edge-ready database. Here are some ideas to go further:
- Add authentication with Better Auth or Auth.js
- Implement a favorites system with optimistic updates
- Add Server Actions for server-side mutations without API routes
- Set up monitoring with OpenTelemetry
- Build a browser extension that saves bookmarks directly
Conclusion
In this tutorial, we built an edge-ready bookmark application by combining three powerful technologies:
- Turso (LibSQL) for a distributed SQLite database with automatic replication
- Drizzle ORM for type-safe TypeScript queries and declarative migrations
- Next.js 15 Edge Runtime to execute code as close as possible to users
This stack is ideal for applications that require ultra-fast response times worldwide. The database travels with your code — no more choosing between performance and simplicity.
The complete code from this tutorial serves as a starting point for your own projects. The Turso + Drizzle + Next.js Edge combination represents one of the most performant and ergonomic architectures for modern web development in 2026.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.

Build a Semantic Search Engine with Next.js 15, OpenAI, and Pinecone
Learn how to build a production-ready semantic search engine using Next.js 15, OpenAI Embeddings, and Pinecone vector database. This comprehensive tutorial covers setup, indexing, querying, and deployment.