From Cloudflare Workers + D1
This is the full path for a Next.js app running on Cloudflare Workers/Pages (typically via OpenNext) with a D1 database. You will move the runtime to brrrd, the database to Louhi (libSQL), and your data from D1 to Louhi. It is written as a runbook a coding agent can execute.
In practice the Cloudflare coupling is small and usually lives in one file (your database client). The steps below are the validated path used to migrate real apps onto the platform.
1. Switch the runtime to brrrd
Section titled “1. Switch the runtime to brrrd”Do the runtime work from the Next.js guide first:
pnpm add @brrrd/adapter; setadapterPath: require.resolve("@brrrd/adapter")innext.config.- Remove the OpenNext /
@opennextjs/cloudflaresetup (the init call,wranglerdeploy wiring,open-next.config.ts). - Upgrade to Next ^16.2 / React 19 if you are not there yet.
- Apply the runtime checklist
(OG routes →
runtime = "edge", middleware matcher with no look-around, etc.). - Build with
next build --webpack→dist/brrrd.
2. Swap the database client (D1 → libSQL)
Section titled “2. Swap the database client (D1 → libSQL)”Find your D1 client — usually src/server/db/index.ts — and replace the
Cloudflare/D1 wiring with a libSQL client. Keep the same exported names so
the rest of your app doesn’t change.
import { drizzle } from "drizzle-orm/d1";import { getCloudflareContext } from "@opennextjs/cloudflare";
export const getDb = async () => { const { env } = await getCloudflareContext({ async: true }); return drizzle(env.DB, { schema });};import "server-only";import { type Client as LibsqlClient } from "@libsql/client/web";import { createClient } from "@tursodatabase/serverless/compat";import { drizzle } from "drizzle-orm/libsql/web";import * as schema from "./schema";
export const getDb = async () => { const client = createClient({ url: process.env.DATABASE_URL!, authToken: process.env.DATABASE_AUTH_TOKEN!, }); // Create a fresh client per request: the serverless compat client serializes // work internally, so one long-lived instance can stall unrelated requests. return drizzle({ client: client as unknown as LibsqlClient, schema });};pnpm add @tursodatabase/serverless @libsql/client drizzle-ormpnpm remove @opennextjs/cloudflare # and any wrangler-only D1 depsYour Drizzle schema is portable as-is — only the client changes.
3. Create the Louhi database
Section titled “3. Create the Louhi database”comwit databases create --project <projectId> --name app-dbCopy the one-time database_token and note the database_url — these become
DATABASE_AUTH_TOKEN and DATABASE_URL.
4. Create the schema on Louhi
Section titled “4. Create the schema on Louhi”For a fresh database (no data to move), push your final schema directly — this is more reliable than replaying a D1 migration history, because a few D1 migration statements don’t translate to libSQL (see the caveats below):
DATABASE_URL=... DATABASE_AUTH_TOKEN=... pnpm drizzle-kit push --force5. Move your data (only if you have data to keep)
Section titled “5. Move your data (only if you have data to keep)”Export from D1, sanitize for libSQL, then import in chunks.
wrangler d1 export <DB_NAME> --remote --output d1-dump.sqllibSQL is stricter than D1, so strip these from the dump before importing — they will otherwise be rejected:
PRAGMA defer_foreign_keys = ...;DELETE FROM sqlite_sequence;DROP INDEXstatements for UNIQUE / primary-key constraint-backed indexes (libSQL rejects dropping a constraint’s backing index).
Then import. Two Louhi behaviors shape how:
- Foreign-key enforcement is connection state that may be on, and that state
isn’t guaranteed to carry across separate HTTP calls. So wrap the whole load in
a single
executeMultiplethat begins withPRAGMA foreign_keys=OFF;— it is a safe no-op if FKs were already off, and keeps the disable in the same connection as the inserts. - A single very large
executeMultiple(~1.5 MB+) can fail at the edge (observed 502). Chunk the statements (≈100 per batch).
import { createClient } from "@tursodatabase/serverless/compat";
const client = createClient({ url: process.env.DATABASE_URL!, authToken: process.env.DATABASE_AUTH_TOKEN!,});
// quote-aware split of the sanitized dump into statements, then:for (const batch of chunk(statements, 100)) { await client.executeMultiple("PRAGMA foreign_keys=OFF;\n" + batch.join("\n"));}6. Set environment and deploy
Section titled “6. Set environment and deploy”comwit apps create --project <projectId> --name web
# Set DATABASE_URL and DATABASE_AUTH_TOKEN (plain values; no secret store yet):# PUT /v1/projects/{projectId}/apps/{appId}/environment/DATABASE_URL { "value": "...", "secret": false }# PUT /v1/projects/{projectId}/apps/{appId}/environment/DATABASE_AUTH_TOKEN { "value": "...", "secret": false }
comwit deploy \ --project <projectId> \ --app <appId> \ --package ./dist/brrrd \ --host app.example.com7. Verify
Section titled “7. Verify”- App renders on its hostname (test pages in a browser — isolate-only runtime errors pass the build).
- Database reads/writes work against Louhi.
- If you moved data, spot-check row counts and a few records.
Common Cloudflare-specific gotchas, recapped
Section titled “Common Cloudflare-specific gotchas, recapped”| Symptom | Cause | Fix |
|---|---|---|
503 … look-around not supported | Middleware matcher uses (?!…) | Match-all matcher + filter in the function |
| OG/icon route 500s | next/og pulls native sharp | export const runtime = "edge" + images.unoptimized |
502 on data import | One oversized executeMultiple (~1.5 MB+) | Chunk to ~100 statements |
| Import rejected | D1-only statements (defer_foreign_keys, sqlite_sequence, constraint index DROP) | Strip them from the dump |
| FK errors mid-import | FK enforcement may be on; state isn’t guaranteed across calls | One executeMultiple starting PRAGMA foreign_keys=OFF; |
Once it’s live, automate redeploys from GitHub Actions.