Connect as embedded — overview
The embedded plane runs the engine inside your process via @twilldb/bun (a typed wrapper over bun:ffi and the C ABI). Queries are function calls — no socket, no round-trip. This page covers the one pattern every framework shares; then each framework has its own page that breaks the integration down step by step.
Install
Add the package; it auto-discovers the native libengine built into target/{release,debug}, or set TWILLDB_ENGINE_PATH to point at a specific build.
bun add @twilldb/bun
For the complete embedded API (every method, parameter binding, prepared statements, error handling), see Embedded (bun:ffi). This section is about wiring that API into a web framework.
The one rule: open once, share the handle
A Database handle is a long-lived resource. Open it once at module scope and reuse it for every request — never per request. Put it in a single module and import it everywhere; multiple connections to the same file:// URL in one process share one underlying database, so snapshot isolation holds across the whole app.
// db.ts — a single shared module, imported by every route in every framework.
import { open, type Database } from "@twilldb/bun";
// Opened once when this module is first imported.
export const db: Database = open(process.env.TWILLDB_URL ?? "file://./app.db");
// Create schema on boot (DDL runs in autocommit).
db.exec(`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
body TEXT NOT NULL,
ts TEXT NOT NULL
)`);
Every framework page below imports this exact db.ts. The only thing that changes is how the framework hands you the request and where you register the handle.
Embedded means one process
The embedded handle lives in this process's memory. If you run multiple instances (or serverless workers) that must share writes, either keep a single instance, switch to server mode over the wire, or point all instances at the same s3:// backend, where the durable single-writer lease coordinates them. Embedded file:// is single-process by design.
Pick your framework
Each page below is a full breakdown of one example app — the wiring, the routes, transactions, and the framework-specific gotchas.
Bun (HTTP)
The smallest integration — Bun.serve calling the shared handle directly. Start here to see the bare mechanics.
Hono
Close over the handle in route handlers; JSON body parsing and parameterized writes.
Elysia
Register the handle with decorate and validate request bodies with the type system.
Next.js
Server-runtime only — route handlers and server actions, with the globalThis singleton guard.
Node & frameworks
The same API via @twilldb/node (koffi FFI) — Next.js, Astro, Nuxt, Remix, SvelteKit, Vite. Keep it on the Node.js runtime, not Edge.
PHP & frameworks
Embed via PHP's FFI extension with twilldb/twilldb — or connect over Postgres (PDO) from Laravel / CodeIgniter / Symfony.
What every page shares
Three things are identical across frameworks, so they're explained once here and referenced from each page:
- Transactions. Wrap multi-statement writes in
db.transaction(fn)— it issuesBEGIN, runs your function, thenCOMMIT(blocking until the WAL is durable), orROLLBACK+ rethrow on error.db.transaction((tx) => { tx.query("UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]); tx.query("UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]); }); // commits durably here, or rolls back if either statement throws - Parameter binding. Always use positional
?placeholders with a params array — never string-concatenate user input. - Clean shutdown. Release the handle when the process exits:
process.on("beforeExit", () => db.close()).
Because the data is just engine rows, any of these apps can fork its database copy-on-write (Branching) or run disaggregated on object storage (Storage backends) with no code change beyond the connection string.