Embedded with Next.js
Next.js can use the embedded engine in its server runtime only — route handlers, server actions, and server components — never in the browser or on the Edge runtime. Two rules make it reliable: pin the Node.js runtime, and guard the handle against bundler module duplication with a globalThis singleton.
Two rules before any code
- Node runtime The engine is a native library loaded over the C ABI; it needs the Node.js server runtime. Set
export const runtime = "nodejs"in any route that touches the database. It cannot run on the Edge runtime or in client components. - Singleton guard In development, Next.js hot-reload and bundling can evaluate a module more than once, which would open the database repeatedly. Stash the handle on
globalThisso there is exactly one per process — the same pattern ORMs use.
Server-only, and one server process
The handle lives in the server process's memory. This works for a single long-lived Node server. On platforms that run many isolated serverless instances (each its own process) that must share writes, use server mode over the wire, or point every instance at the same s3:// backend where the durable single-writer lease coordinates them. See the overview.
Step 1 — the guarded singleton
// lib/db.ts
import { open, type Database } from "@twilldb/bun";
const g = globalThis as unknown as { __twilldb?: Database };
export const db: Database =
g.__twilldb ?? (g.__twilldb = open(process.env.TWILLDB_URL ?? "file://./app.db"));
// Run schema once, on first import.
db.exec(`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
body TEXT NOT NULL,
ts TEXT NOT NULL
)`);
Breakdown: the first import opens the database and caches it on globalThis.__twilldb; every later import (including after a hot reload) reuses that cached handle instead of opening a second one. Without this guard you'd leak handles in dev.
Step 2 — a Route Handler
App-router route handlers live at app/api/<name>/route.ts. Pin the Node runtime and use the shared handle.
// app/api/notes/route.ts
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export const runtime = "nodejs"; // required — the C ABI needs the Node runtime
export async function GET() {
const rows = db.query("SELECT * FROM notes ORDER BY id DESC LIMIT 50");
return NextResponse.json(rows);
}
export async function POST(req: Request) {
const { body } = await req.json();
if (!body) return NextResponse.json({ error: "body required" }, { status: 400 });
db.query("INSERT INTO notes (body, ts) VALUES (?, ?)", [body, new Date().toISOString()]);
return NextResponse.json({ ok: true }, { status: 201 });
}
Breakdown: the queries are identical to every other framework — only the handler signature is Next-specific. runtime = "nodejs" is the line people forget; without it Next may try the Edge runtime and the native load fails.
Step 3 — a Server Action (and reading in a Server Component)
Server actions and server components run on the server, so they can call the handle directly. Use a transaction for atomic writes and revalidatePath to refresh the UI.
// app/notes/actions.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function addNote(formData: FormData) {
const body = String(formData.get("body") ?? "");
if (!body) return;
db.query("INSERT INTO notes (body, ts) VALUES (?, ?)", [body, new Date().toISOString()]);
revalidatePath("/notes");
}
// app/notes/page.tsx (a Server Component)
import { db } from "@/lib/db";
import { addNote } from "./actions";
export const runtime = "nodejs";
export default function NotesPage() {
const notes = db.query<{ id: string; body: string }>("SELECT * FROM notes ORDER BY id DESC LIMIT 50");
return (
<main>
<form action={addNote}>
<input name="body" /><button>Add</button>
</form>
<ul>{notes.map((n) => <li key={n.id}>{n.body}</li>)}</ul>
</main>
);
}
Breakdown: the server component reads synchronously at render time; the form posts to the server action, which writes and calls revalidatePath so the next render shows the new row. All of it runs server-side — none of this code or the handle is ever sent to the browser.
Step 4 — error handling
import { EngineError } from "@twilldb/bun";
try {
db.transaction((tx) => { /* ...multi-statement write... */ });
} catch (e) {
if (e instanceof EngineError && e.retryable) {
return NextResponse.json({ error: "conflict, retry" }, { status: 409 });
}
throw e;
}
Breakdown: same contract as the other frameworks — EngineError.retryable flags a write conflict you can safely retry.
A note on Bun vs Node
@twilldb/bun loads the native library through bun:ffi, so the embedded path targets the Bun runtime. If your Next.js app runs under Bun, the examples above work as written. Running embedded under plain Node would require a NAPI binding, which is a tracked exploratory item rather than a shipped channel — see the roadmap. On a Node-only deployment today, use server mode over pgwire instead.