Skip to content

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.

Do the runtime work from the Next.js guide first:

  • pnpm add @brrrd/adapter; set adapterPath: require.resolve("@brrrd/adapter") in next.config.
  • Remove the OpenNext / @opennextjs/cloudflare setup (the init call, wrangler deploy 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 --webpackdist/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.

Before — D1 on Cloudflare
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 });
};
After — libSQL on Louhi
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 });
};
Install / remove deps
pnpm add @tursodatabase/serverless @libsql/client drizzle-orm
pnpm remove @opennextjs/cloudflare # and any wrangler-only D1 deps

Your Drizzle schema is portable as-is — only the client changes.

Create the database
comwit databases create --project <projectId> --name app-db

Copy the one-time database_token and note the database_url — these become DATABASE_AUTH_TOKEN and DATABASE_URL.

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

Push the schema
DATABASE_URL=... DATABASE_AUTH_TOKEN=... pnpm drizzle-kit push --force

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

Export from D1
wrangler d1 export <DB_NAME> --remote --output d1-dump.sql

libSQL 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 INDEX statements 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 executeMultiple that begins with PRAGMA 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.ts (sketch)
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"));
}
Configure 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.com
  • 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”
SymptomCauseFix
503 … look-around not supportedMiddleware matcher uses (?!…)Match-all matcher + filter in the function
OG/icon route 500snext/og pulls native sharpexport const runtime = "edge" + images.unoptimized
502 on data importOne oversized executeMultiple (~1.5 MB+)Chunk to ~100 statements
Import rejectedD1-only statements (defer_foreign_keys, sqlite_sequence, constraint index DROP)Strip them from the dump
FK errors mid-importFK enforcement may be on; state isn’t guaranteed across callsOne executeMultiple starting PRAGMA foreign_keys=OFF;

Once it’s live, automate redeploys from GitHub Actions.