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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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) or curl -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-bookmarks

Select 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 login

This will open your browser for authentication. Once logged in, create your database:

turso db create bookmarks-db --group default

Automatic 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-db

Create 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-kit

Here is what each package does:

PackageRole
drizzle-ormType-safe TypeScript ORM
@libsql/clientLibSQL driver for Turso
drizzle-kitCLI 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 text and integer as base types. Dates are stored as text in ISO 8601 format.
  • Many-to-many relations: The bookmark_tags table links bookmarks and tags through a pivot table.
  • Cascade delete: Deleting a bookmark automatically removes its tag associations.
  • Default values: $defaultFn generates timestamps on the application side, not the database side.

Step 6: Run Migrations

Generate SQL migration files from your schema:

npx drizzle-kit generate

This creates a drizzle/ folder with numbered SQL files. Apply them to your Turso database:

npx drizzle-kit migrate

Verify 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.ts

Step 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 URL
  • TURSO_AUTH_TOKEN — Token generated with turso 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 --prod

Or 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/bookmarks

You 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.

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.sql

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

  1. Start the development server:

    npm run dev
  2. 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"
  3. 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 none

Migration 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 migrate

Edge 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.


Want to read more tutorials? Check out our latest tutorial on Build a Real-Time App with Supabase and Next.js 15: Complete Guide.

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