Skip to content
Outer
Esc
↑↓navigate↵open⌘Jpreview
On this page

Store data in D1

D1 on Outer — the binding, migration order, query API, console grid, and backup summary.

The template ships with D1 on by default. Copy pages/index.server.ts: a loader that reads D1 and one action (an intent field tells forms apart) that writes it. The person-facing guide is Save app data in a database.

Use the declared binding

void.config.ts ships the database; Outer provisions it on first deploy. Edits need bun run build. Bindings are read from the built config:

d1_databases: [{ binding: "DB", database_name: "app-db" }],

One D1 database and one R2 bucket per app: a second d1_databases entry fails the deploy at validation (“Outer apps can have one D1 database and one R2 bucket for now; merge the extra ones”). The built dist/wrangler.json carries the binding to celld, which creates the database on first use. Celld keys the database by name, so a typo opens a silent empty database. Double-check the name.

Ship schema as migrations

  1. Iterate locally with void db push (applies the schema with no migration files).
  2. Run void db generate to write db/migrations/NNNN_name.sql.
  3. Commit the files; Outer applies them on deploy after the moderation gate, before new code serves traffic.
  4. void db migrate applies pending migrations to the local database only, never production.

Migration order depends on deploy state:

Deploy Order
App with a live version Migrations run before the upload; the old code keeps serving, and a migrate failure uploads nothing
First deploy Upload first, then migrate; the proxy returns 503 (building page) until the first deploy succeeds, so no race is possible

Files apply in numeric-prefix order under the app’s scoped storage credentials. Already-applied files are skipped, never re-applied. A file edited after it was applied fails the deploy. Write a new migration instead. A failing migration fails the deploy and names the file; the previous version keeps serving (first deploys stay not-live, “Your app isn’t live yet”), with celld’s error in the log tail. The step runs only when the built config declares d1_databases and the repo holds migration files; no files means the step is skipped.

Expand first, contract later: the migration runs while the old code still serves, so prefer additive changes (new tables, nullable columns). Dropping a column the live code still reads serves errors until the new version takes over.

Seed INSERTs count as page text: no strong profanity in seeds. The pre-live gate reads SQL string literals and a profane seed blocks the deploy.

Query with prepare and bind

prepare() takes exactly one statement. bind(...) fills ? placeholders. all() returns { results }, first() returns one row, run() writes:

await db.prepare("INSERT INTO item (text) VALUES (?)").bind(text).run();
await db.prepare("DELETE FROM item WHERE id = ?").bind(id).run();

Apps with no migration files skip the migrate step, so creating tables where you use them keeps working for tiny apps (CREATE TABLE IF NOT EXISTS, as the starter does).

Drizzle is optional: define tables in db/schema.ts with void/schema-d1, import them through the @schema alias, and query through db from void/db:

import { items } from "@schema";
import { db, eq } from "void/db";

const all = await db.select().from(items);
await db.insert(items).values({ label: "Milk" });

Browse data in Console

Open the app in Console and go to Data → D1. The editor opens the app’s single d1_databases binding directly, then its tables with row counts. Each table opens a server-paged grid: click a header to sort, use search and the filter popover to narrow rows, and flip to Definition for columns, indexes, foreign keys, and the CREATE statement. Click a row to update or delete it; Insert adds one. Every field is raw text with a NULL toggle, so NULL and "" stay distinct. Checkbox-select rows for bulk delete (up to 100 rows in one atomic batch). There is deliberately no SQL console: every write is a curated insert/update/delete built from the table’s schema, each recorded in the app’s action log. The owner can read and write.

Back up the database

Apps with a database get automatic daily dumps once active in the prior 24 hours, kept 7 days (30 on Pro). Console offers back up now, download, and restore; MCP offers list_backups, backup_database, and restore_backup (all owner-only). Full detail lives at Backups.

Was this page helpful?