Cloudflare D1
Use Cloudflare's serverless SQLite database as your app's data backend. Data lives on Cloudflare's edge network and survives all redeploys automatically.
What is D1?
D1 is Cloudflare's managed SQLite database. It runs at the edge, has a generous free tier (5 GB storage, 5 million reads/day), and requires zero infrastructure management. Your data is cloud-hosted, so it persists across redeploys, container restarts, and server migrations.
The What framework connects to D1 via the Cloudflare REST API. Your templates and form actions work identically to SQLite — switching backends is a config change, not a code change.
Step 1: Create a D1 Database
- Log in to the Cloudflare Dashboard
- Go to Workers & Pages → D1 SQL Database
- Click Create, give it a name (e.g.,
my-app-db) - Copy the Database ID — it looks like
a1b2c3d4-e5f6-7890-abcd-ef1234567890
Step 2: Get API Credentials
- Click your profile icon (top right) → My Profile
- Go to API Tokens → Create Token
- Use the Edit Cloudflare Workers template, or create a custom token with
Account → D1 → Editpermission - Copy the API Token
- Your Account ID is in the dashboard URL:
dash.cloudflare.com/[ACCOUNT_ID]/...
Step 3: Create Tables
System tables (_kv_store and _collections) are created automatically when What starts. You only need to create your own data tables.
Run this SQL in the D1 console (Workers & Pages → D1 → your database → Console):
Real Columns (Recommended)
Define your table with standard SQL columns. The What framework auto-detects the schema and reads columns directly:
CREATE TABLE IF NOT EXISTS dogs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
breed TEXT,
age INTEGER DEFAULT 0,
is_good_boy INTEGER DEFAULT 1,
metadata TEXT -- stores JSON if needed
);
INSERT OR IGNORE INTO _collections (name) VALUES ('dogs');
Each column maps directly to a template variable: #dog.name#, #dog.breed#, #dog.age#, etc.
JSON Blob Mode (Legacy)
Tables with only id and data columns store all fields as a JSON string in the data column:
CREATE TABLE IF NOT EXISTS dogs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data TEXT NOT NULL DEFAULT '{}'
);
INSERT OR IGNORE INTO _collections (name) VALUES ('dogs');
The What framework auto-detects which mode each table uses. Real columns are recommended for new projects — they give you SQL-level filtering, sorting, and type safety.
id and data, What uses JSON blob mode. Any other column layout uses real column mode. This detection is cached per table for performance.
Step 4: Configure what.toml
[database]
type = "d1"
[cloudflare]
account_id = "${CF_ACCOUNT_ID}"
api_token = "${CF_API_TOKEN}"
d1_database_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
Set your credentials as environment variables. Create a .env file at your project root (never commit this):
CF_ACCOUNT_ID=your-account-id-here
CF_API_TOKEN=your-api-token-here
.env to your .gitignore. Never commit API tokens to source control.
List Records
Fetch and display dogs using the local: prefix, exactly as you would with SQLite:
<what>
fetch.dogs = "local:dogs"
fetch.dogs.sort = "created_at:desc"
</what>
<h1>Dogs</h1>
<if flash.success>
<div class="alert alert-success mb-4">#flash.success#</div>
</if>
<if dogs>
<loop data="#dogs#" as="dog">
<div class="card mb-3">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<strong>#dog.name#</strong>
<span class="text-muted ml-2">#dog.breed#</span>
<span class="badge ml-2">Age: #dog.age#</span>
</div>
<div class="d-flex gap-2">
<a href="/dogs/#dog.id#/edit" class="btn btn-sm">Edit</a>
<form w-action="delete" w-store="dogs" w-id="#dog.id#" w-redirect="/dogs">
<button type="submit" class="btn btn-sm btn-danger"
w-confirm="Delete #dog.name#?">Delete</button>
</form>
</div>
</div>
</div>
</loop>
</if>
<else/>
<p class="text-muted">No dogs yet.</p>
</else>
<a href="/dogs/new" class="btn btn-primary mt-3">Add Dog</a>
Create a Record
<h1>Add a Dog</h1>
<if errors.name>
<div class="alert alert-danger">#errors.name#</div>
</if>
<form w-action="create" w-store="dogs" w-redirect="/dogs" w-validate class="card card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input name="name" type="text" class="form-control"
placeholder="Dog's name" w-required value="#old.name#">
<if errors.name>
<div class="form-text text-danger">#errors.name#</div>
</if>
</div>
<div class="mb-3">
<label class="form-label">Breed</label>
<input name="breed" type="text" class="form-control"
placeholder="e.g. Labrador" value="#old.breed#">
</div>
<div class="mb-3">
<label class="form-label">Age</label>
<input name="age" type="number" class="form-control"
placeholder="Years" w-min="0" w-max="30" value="#old.age#">
</div>
<button type="submit" class="btn btn-primary">Add Dog</button>
<a href="/dogs" class="btn ml-2">Cancel</a>
</form>
Update a Record
Fetch the existing record by ID and pre-fill the form:
<what>
fetch.dog = "local:dogs"
fetch.dog.filter = "id=#params.id#"
</what>
<h1>Edit #dog.name#</h1>
<form w-action="update" w-store="dogs" w-id="#dog.id#" w-redirect="/dogs" class="card card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input name="name" type="text" class="form-control" value="#dog.name#">
</div>
<div class="mb-3">
<label class="form-label">Breed</label>
<input name="breed" type="text" class="form-control" value="#dog.breed#">
</div>
<div class="mb-3">
<label class="form-label">Age</label>
<input name="age" type="number" class="form-control" value="#dog.age#">
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="/dogs" class="btn ml-2">Cancel</a>
</form>
Delete a Record
Inline delete with a confirmation dialog. No separate page needed:
<form w-action="delete" w-store="dogs" w-id="#dog.id#" w-redirect="/dogs">
<button type="submit" class="btn btn-danger"
w-confirm="Permanently delete #dog.name#? This cannot be undone.">
Delete Dog
</button>
</form>
Query Options
The local: fetch prefix supports sort, filter, search, limit, and offset:
<what>
<!-- Sort by name ascending -->
fetch.dogs = "local:dogs"
fetch.dogs.sort = "name:asc"
<!-- Filter by exact field value -->
fetch.labs = "local:dogs"
fetch.labs.filter = "breed=Labrador"
<!-- Full-text search across all fields -->
fetch.results = "local:dogs"
fetch.results.search = "#params.q#"
<!-- Pagination: 10 per page -->
fetch.dogs = "local:dogs"
fetch.dogs.limit = "10"
fetch.dogs.offset = "#params.offset|default:0#"
</what>
Filter operators:
| Syntax | Description | Example |
|---|---|---|
field=value | Exact match | breed=Labrador |
field!=value | Not equal | breed!=Poodle |
field>value | Greater than | age>2 |
field<value | Less than | age<10 |
field~value | Contains (case-insensitive) | name~buddy |
Using D1 as a Named Datasource
Instead of (or in addition to) the [database] config, you can configure D1 as a named datasource using the dsn: prefix:
[datasources.cloud]
type = "d1"
account_id = "${CF_ACCOUNT_ID}"
api_token = "${CF_API_TOKEN}"
d1_database_id = "${CF_D1_DATABASE_ID}"
<what>
[data]
fetch.todos = "dsn:cloud.todos"
fetch.todos.sort = "created_at:desc"
</what>
<loop data="#todos#" as="todo">
<p>#todo.title#</p>
</loop>
This lets you connect to multiple D1 databases simultaneously, or mix D1 with other backends like Supabase or REST APIs.