We just shipped @bunny.net/database-client, a small SQL client for Bunny Database. It uses fetch with no external dependencies, and is built for Bunny Edge Scripting.
The same code runs in Deno, Bun, Node, or any TypeScript function or API where you want to use Bunny Database without extra runtime dependencies.
import * as BunnySDK from "npm:@bunny.net/edgescript-sdk@0.12.1"; import { connect } from "npm:@bunny.net/database-client"; const db = connect();
With no arguments, connect() reads BUNNY_DATABASE_URL and BUNNY_DATABASE_AUTH_TOKEN from the environment, which is what bunny db link and Edge Scripting already set.
There are working examples for Bun, Node, Hono, Edge Scripting, Next.js, Astro, and SvelteKit if you want to read code before you read the rest of this post.
The library is experimental, a work-in-progress, and open to contribution.
Queries
Statements are prepared, bound, and then executed with whichever method fits the shape you want back:
const rows = await db.prepare("SELECT id, name FROM users").all(); // [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }] const user = await db.prepare("SELECT * FROM users WHERE id = ?").bind(1).first(); // { id: 1, name: "Alice" } or null const name = await db.prepare("SELECT name FROM users WHERE id = ?").bind(1).first("name"); // "Alice" or null
run() adds write metadata:
const result = await db .prepare("INSERT INTO users (name) VALUES (?) RETURNING id") .bind("Carol") .run(); // { rows: [{ id: 3 }], columns: ["id"], rowsAffected: 1, lastInsertRowid: 3 }
Statements are immutable, so you can prepare once and bind as often as you like:
const byId = db.prepare("SELECT id, name FROM users WHERE id = ?"); const alice = await byId.bind(1).first(); const bob = await byId.bind(2).first();
SQLite's named parameters work too. Pass a single object instead of positional values, with or without the sigil:
const team = await db .prepare("SELECT id, name FROM users WHERE team = :team AND active = :active") .bind({ team: "platform", active: true }) .all();
Rows are typed as Record<string, SqlValue> by default, so you can pass your own shape to
skip the cast:
interface User { id: number; name: string; } const users = await db.prepare("SELECT id, name FROM users").all<User>();
That type is an assertion for now. Nothing checks the rows against it at runtime, so it’s only ever as accurate as your SQL.
Transactions in one round trip
batch() runs every statement in a single transaction and a single HTTP request. All of them commit or none do:
const [inserted, count] = await db.batch([ db.prepare("INSERT INTO users (name) VALUES (?)").bind("Dan"), db.prepare("SELECT COUNT(*) AS c FROM users"), ]); inserted.rowsAffected; // 1 count.rows[0].c; // 4
You get one result per statement you passed, in order. If any statement fails, the transaction rolls back and batch() throws that statement's error, so there is no half-applied state to reason about.
For schema setup, there is db.exec(), which runs a multi-statement script:
await db.exec(` CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL); CREATE INDEX IF NOT EXISTS users_name ON users (name); `);
It takes no parameters and returns no rows. For a schema you expect to keep changing, reach for bunny db migrations.
bunny db migrations create add_users_table # migrations/0001_add_users_table.sql bunny db migrations apply bunny db migrations list
bunny db migrations apply --dry-run lists what would run without touching the database. The migration commands are still marked experimental and could change based on your feedback.
Errors carry the SQLite code
Errors are DatabaseError with the SQLite code attached, so you can catch a SQLITE_CONSTRAINT without string-matching a message.
import { DatabaseError } from "@bunny.net/database-client"; try { await db.prepare("INSERT INTO users (email) VALUES (?)").bind(email).run(); } catch (error) { // Prefix match, so this catches SQLITE_CONSTRAINT and its extended forms alike. if (error instanceof DatabaseError && error.code?.startsWith("SQLITE_CONSTRAINT")) { return Response.json({ error: "That email is taken." }, { status: 409 }); } throw error; }
Failures that happen before any SQL runs are wrapped the same way, with codes NETWORK, TIMEOUT, and ABORTED, so one instanceof DatabaseError covers the whole surface and no TypeError slips past it.
Keep it on the server
A database token authorizes the connection, and anything holding it can run whatever SQL the token allows. This client belongs in server-side code only. Never ship it, or a token, to a browser.
The pattern we typically see from users is an Edge Script sitting in front of Bunny Database. The token stays on the server, and the browser only sees the responses you choose to return:
import * as BunnySDK from "npm:@bunny.net/edgescript-sdk@0.12.1"; import { connect, DatabaseError } from "npm:@bunny.net/database-client"; const db = connect({ timeout: 2000 }); BunnySDK.net.http.serve(async (request: Request): Promise<Response> => { const { pathname } = new URL(request.url); if (pathname !== "/notes") return new Response("Not found", { status: 404 }); try { if (request.method === "POST") { const payload = await request.json().catch(() => null); const title = typeof payload?.title === "string" ? payload.title.trim() : ""; if (!title) { return Response.json({ error: "A title is required." }, { status: 400 }); } const note = await db .prepare("INSERT INTO notes (title) VALUES (?) RETURNING id, title") .bind(title) .first(); return Response.json(note, { status: 201 }); } // Parameterized, and scoped to the columns and rows the caller may see. const notes = await db .prepare("SELECT id, title FROM notes WHERE published = 1 ORDER BY id LIMIT 50") .all(); return Response.json(notes); } catch (error) { // The server's message and SQLite code stay in your logs, not in the response. console.error(error instanceof DatabaseError ? `${error.code}: ${error.message}` : error); return Response.json({ error: "Something went wrong." }, { status: 500 }); } });
The browser calls your endpoint, and your endpoint decides what SQL runs. Narrow the token while you are at it using the bunny.net CLI:
bunny db tokens create --read-only --expiry 30d
A narrowed token still exposes every row of every table to whatever holds it, because it authorizes the connection and not the individual query. It limits the damage without removing it, which is why the token stays on your side of the wire.
It works with your framework too
@bunny.net/database-client works with the frameworks you already use. Each one keeps its environment variables somewhere slightly different, so that's the only part of the setup that changes:
// Next.js, where .env is already on process.env const db = () => connect(); // Astro, where it is on import.meta.env const db = () => connect({ url: import.meta.env.BUNNY_DATABASE_URL, authToken: import.meta.env.BUNNY_DATABASE_AUTH_TOKEN, }); // SvelteKit, where $env/dynamic/private keeps it out of the client bundle const db = () => connect({ url: env.BUNNY_DATABASE_URL, authToken: env.BUNNY_DATABASE_AUTH_TOKEN, });
Once db() is wired up, the query code looks the same wherever you call it from:
export const GET: RequestHandler = async () => Response.json( await db().prepare("SELECT id, title FROM notes ORDER BY id").all(), ); export const POST: RequestHandler = async ({ request }) => { const { title } = (await request.json()) as { title?: string }; if (!title?.trim()) { return Response.json({ error: "A title is required." }, { status: 400 }); } return Response.json( await db() .prepare("INSERT INTO notes (title) VALUES (?) RETURNING id, title") .bind(title.trim()) .first(), { status: 201 }, ); };
React Server Components can call db() directly too:
export default async function Page() { const notes = await db() .prepare("SELECT id, title FROM notes ORDER BY id") .all<{ id: number; title: string }>(); async function create(formData: FormData) { "use server"; const title = String(formData.get("title") ?? "").trim(); if (!title) return; await db().prepare("INSERT INTO notes (title) VALUES (?)").bind(title).run(); revalidatePath("/"); } return ( <main> <form action={create}> <input name="title" placeholder="Note title" /> <button type="submit">Add</button> </form> <ul> {notes.map((note) => ( <li key={note.id}>{note.title}</li> ))} </ul> </main> ); }
Try it
This is an early release, and designed to be small. Create a Bunny Database using the Dashboard or the bunny.net CLI:
npm install -g @bunny.net/cli bunny login bunny db create
Then pick an example to get you started:
| Example | Runs on |
|---|---|
bun/ |
Bun |
node/ |
Node 24+ |
hono/ |
Bun or Deno |
edge-script/ |
Bunny Edge Scripting |
next/ |
Next.js |
astro/ |
Astro |
sveltekit/ |
SvelteKit |
When you're ready to deploy, push an Edge Script with the CLI, or deploy a framework app to Magic Containers.
If you've already got a project you want to add Bunny Database to, install the client with your package manager of choice:
npm install @bunny.net/database-client # pnpm add @bunny.net/database-client # bun add @bunny.net/database-client
If something you need is missing, tell us on Discord, or open a pull request on GitHub. The CLI is open to contributions from the community.
Comments require cookies. to view and post.

