Storage bindings

Store links in D1

Create a D1 database and migration, then use prepared statements and bound values for Link Vault CRUD operations.

D1 is Cloudflare’s managed SQLite. You talk to it through a binding, write plain SQL, and it fits Link Vault perfectly: links are relational records, and we want to filter and sort them.

Let’s create the database, then a migration, then apply it locally:

npx wrangler d1 create link-vault
npx wrangler d1 migrations create link-vault create-links
npx wrangler d1 migrations apply link-vault --local

The first command prints a database_id. Paste it into the D1 binding you added to wrangler.jsonc earlier. The second creates an empty file under migrations/, something like 0001_create-links.sql. The third runs it against your local database only.

The schema carries the rules

Put the invariants in the schema, not only in code. Code paths multiply; the database is one place:

CREATE TABLE links (
  id TEXT PRIMARY KEY,
  url TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  created_at TEXT NOT NULL,
  archived INTEGER NOT NULL DEFAULT 0
);

NOT NULL and UNIQUE survive every route, every script, and every future refactor. A check that lives only in a Hono handler does not.

Always bind values

Never build SQL by concatenating strings. Use prepare() with placeholders and bind() the values:

const link = await c.env.DB
  .prepare('SELECT * FROM links WHERE id = ?')
  .bind(id)
  .first()

first() returns one row or null. For a list, chain .all() and read results. For an insert, chain .run().

Binding keeps SQL and user input apart, which closes the door on SQL injection. It does not validate the URL or check that this user may see this link. Do those before the query.

Local is not production

Notice the --local flag on migrations apply. A migration applied locally has changed nothing in production. When you are ready, and only after checking the account and database name Wrangler prints, run it with --remote.

In tests, apply migrations to an isolated local database every time. Never let one test depend on rows another test left behind.

Make changes additive during rollouts

For a while after a deployment, the old Worker version and the new one may both run. Adding a nullable column keeps both happy. Renaming or dropping one breaks whichever version doesn’t expect it.

If a change truly can’t be reversed, write down the data recovery step before you apply it. A code rollback is not a plan for a dropped column.

A common failure: you add the binding, deploy, and every query returns D1_ERROR: no such table: links. That means the migration ran locally but not remotely. npx wrangler d1 migrations list link-vault --remote shows which migrations are pending.

Now write the migration above, apply it locally, and implement GET /api/links and POST /api/links with bound statements. The create route should return 201 and the new row, and a duplicate URL should surface as a 409, not a 500.

Lesson completed