Store and query structured data with SQLite (default), Cloudflare D1, or Supabase. All backends use the same local: fetch syntax and form actions.
wwwhat stores all collection data as JSON blobs inside database tables. Every backend uses the same schema:
-- Each collection (e.g., "posts") becomes a table:
id INTEGER PRIMARY KEY AUTOINCREMENT
data TEXT NOT NULL -- JSON object with all fields
-- Key-value pairs use a system table:
_kv_store (key TEXT PRIMARY KEY, value TEXT)
This means your templates and forms work identically regardless of which backend you use. Switching backends is a config change, not a code change.
With no [database] section in wwwhat.toml, wwwhat automatically creates a SQLite database at data/app.db. No configuration needed.
your-project/
data/
app.db <!-- auto-created on first run -->
sessions.db <!-- separate file for sessions -->
site/
wwwhat.toml <!-- no [database] section needed -->
SQLite is the default backend and works great for development and single-server deployments. Tables are created automatically the first time you write data — no configuration needed. Data persists across restarts but is not guaranteed to survive redeploys unless you explicitly preserve the data/ directory (via Docker volume mount or rsync exclude).
sessions.db) from your app data. They are independent and always use SQLite locally.
All data access goes through fetch directives with the local: prefix. You never write SQL directly.
<what>
fetch.posts = "local:posts"
fetch.posts.filter = "status=published"
fetch.posts.sort = "published_at:desc"
fetch.posts.limit = "20"
</what>
<loop data="#posts#" as="post">
<article>
<h2><a href="/blog/#post.id#">#post.title#</a></h2>
<time>#post.published_at|date:"%B %d, %Y"#</time>
</article>
</loop>
Create, update, and delete records through form actions:
<form w-action="create" w-store="posts">
<input name="title" w-required>
<textarea name="body"></textarea>
<button type="submit">Create Post</button>
</form>
Understanding what survives a redeploy depends on your database backend and deployment method:
| Backend | Where Data Lives | Survives Redeploy? | How to Ensure Persistence |
|---|---|---|---|
| SQLite | data/app.db on disk |
Yes, if data/ is preserved |
Docker volume mount or rsync exclude |
| Sessions | data/sessions.db on disk |
Yes, if data/ is preserved |
Same as SQLite — mount data/ |
| Cloudflare D1 | Cloudflare edge network | Always | Cloud-hosted, no action needed |
| Supabase | Supabase PostgreSQL cloud | Always | Cloud-hosted, no action needed |
| Uploads | uploads/ on disk |
Yes, if uploads/ is preserved |
Docker volume mount or rsync exclude |
data/ directory as a volume. Without this, your database is destroyed on every container restart. See Deployment for exact commands.
D1 is Cloudflare's serverless SQL database. Your data lives on Cloudflare's edge network and survives all redeploys automatically.
my-app-db)a1b2c3d4-...)Account → D1 → Edit permissiondash.cloudflare.com/[ACCOUNT_ID]/...In your D1 database console, run this SQL for each collection your app uses:
-- System tables (required once)
CREATE TABLE IF NOT EXISTS _kv_store (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS _collections (name TEXT PRIMARY KEY);
-- Create a collection table (repeat for each collection)
CREATE TABLE IF NOT EXISTS "posts" (id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT NOT NULL DEFAULT '{}');
INSERT OR IGNORE INTO _collections (name) VALUES ('posts');
[database]
type = "d1"
[cloudflare]
account_id = "${CF_ACCOUNT_ID}"
api_token = "${CF_API_TOKEN}"
d1_database_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
Create a .env file (never commit this):
CF_ACCOUNT_ID=your-account-id-here
CF_API_TOKEN=your-api-token-here
Supabase provides a hosted PostgreSQL database with a REST API. Your data is cloud-hosted and survives all redeploys automatically.
https://xyzcompany.supabase.co)service_role key (the secret one, NOT the anon key)service_role key, not the anon key. The service_role key bypasses Row Level Security and gives your server full access to the database. Never expose this key in client-side code.
Go to SQL Editor in your Supabase dashboard and run this for each collection:
-- System tables (required once)
CREATE TABLE IF NOT EXISTS _kv_store (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS _collections (name TEXT PRIMARY KEY);
-- Create a collection table (repeat for each collection)
CREATE TABLE IF NOT EXISTS "posts" (id BIGSERIAL PRIMARY KEY, data JSONB NOT NULL DEFAULT '{}');
INSERT INTO _collections (name) VALUES ('posts') ON CONFLICT DO NOTHING;
[database]
type = "supabase"
[supabase]
project_url = "${SUPABASE_URL}"
api_key = "${SUPABASE_SERVICE_KEY}"
SUPABASE_URL=https://xyzcompany.supabase.co
SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
| Backend | Best For | Data Persistence | Cost |
|---|---|---|---|
| SQLite (default) | Development, prototyping, single-server | Local file — not guaranteed across deploys | Free |
| Cloudflare D1 | Edge performance, serverless | Cloud — always persists | Free tier (5 GB) |
| Supabase | Full PostgreSQL, dashboard UI, scaling | Cloud — always persists | Free tier (500 MB) |
All backends use the same local: fetch syntax and form actions. Your templates don't change when you switch backends.