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 globalThis so 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.

Next

Twill DB documentation · Licensed under BUSL-1.1. · Author