Skip to content

Data

Data is your app’s database. It stores records — flat JSON objects — grouped into collections you declare (todos, posts, entries, whatever the project needs).

It’s the app’s source of truth — strongly consistent, and authoritative (see also Files for bytes).

A collection is declared in data.json, at the root of the project:

{
"collections": {
"todos": {
"fields": { "title": "string", "done": "boolean", "due": "string" },
"indexed": ["done", "due"]
},
"posts": {
"scope": "public",
"fields": {
"slug": { "type": "string", "required": true, "unique": true },
"status": { "type": "string", "enum": ["draft", "live"], "default": "draft" },
"views": "number",
"meta": "json"
},
"indexed": ["status", "meta.category"]
}
}
}

Field types are string, number, boolean, json, and ref. A field is either a bare type, or an object when it needs rules:

  • required — a write without it (or with null) is a 400.
  • default — filled in when a create or replace omits the field.
  • enum — the value must be one of the listed members.
  • unique — no two records may share the value. Enforced by the database itself, as a real unique index, and surfaced as a 409.

indexed lists the fields you filter or sort by — index what you query, and nothing else. A dotted path like meta.category indexes inside a json field. Unique fields are indexed automatically.

Declaring the shape is what lets the server filter, sort and aggregate for you. Adding a field is one line in data.json; there is no migration to run.

id, createdAt, updatedAt and owner are set for you. Never declare them.

A ref field holds another record’s id and names the collection it points at:

{
"collections": {
"posts": { "scope": "public", "fields": { "title": "string" } },
"comments": {
"scope": "public",
"fields": {
"post": { "type": "ref", "to": "posts" },
"body": "string"
},
"indexed": ["post"]
}
}
}

You write it like any other field — the value is the other record’s id:

const comments = collection("comments");
await comments.create({ post: "rec_abc", body: "nice one" });

and you read it back either as the id, or as the record itself with expand:

await comments.list({ post: "rec_abc" });
// → { items: [{ id: "…", post: "rec_abc", body: "nice one" }] }
await comments.list({ expand: ["post"] });
// → { items: [{ id: "…", post: { id: "rec_abc", title: "Hello", … }, body: "nice one" }] }

Up to 3 fields can be expanded at once (?expand=post,author). Index a ref you filter by, so ?post=rec_abc stays fast.

An expanded ref that points at a record which has been deleted — or at one this caller is not allowed to read — comes back as null. Expansion never widens what you can see: the referenced collection is read under its own scope, so expanding a user-scoped ref gives you your own records and nobody else’s, and a staff collection refuses the expansion outright.

A ref is an id, not a foreign key. Nothing stops you deleting the record it points at; the pointer simply stops resolving. That is deliberate — enforcing the constraint would mean a read on every write, which is exactly the per-row cost this feature exists to remove.

Every collection declares who can reach it — the access rule lives on the collection, not in your front-end code:

  • user (the default) — each signed-in user reads and writes only their own records. Private notes, personal todos, settings.
  • team — one pool per team, invisible to every other team. The build requires Teams to be on rather than collapsing everyone into one pool.
  • shared — one pool that every signed-in user reads and writes. Collaborative lists, a team board, a wiki.
  • public — one pool every signed-in user can read. Every record carries its writer in a read-only owner field, and only the owner can change or delete it. Blogs, marketplaces, leaderboards, feeds.
  • staff — the back office. Only a staff user may read it or write it; everyone else gets a 403. Internal notes, moderation queues, an ops log.

Scope says how wide the pool is among people who are signed in. It says nothing about people who are not: a signed-out visitor is refused every collection with a 401 until that collection opts them in with anonymous (below). That includes public, whose name is about the shared pool, not about the front door. A staff collection additionally fails closed: with nobody able to be staff, every read of it is refused, so it needs Sign-in on to mean anything.

anonymous is how a collection says what someone with no account may do. Leave it out and the answer is nothing — which is the default, because a collection the whole internet can reach is a decision worth writing down.

Value A signed-out visitor may
(omitted) nothing — every method gets 401
"read" GET. Writing still needs sign-in.
"write" POST, and nothing else — they cannot read the collection back
"read-write" both
{
"collections": {
"posts": { "scope": "public", "anonymous": "read", "fields": { "title": "string" } },
"contact": { "scope": "shared", "anonymous": "write", "fields": { "email": "string" } }
}
}

"write" is the shape of a contact form, a waitlist or an order: the visitor hands something in and cannot read anyone else’s. "read-write" is a guestbook — reach for it last, and turn Bot protection on when you do.

Every anonymous visitor is one and the same person as far as the store is concerned, so anonymous records all land in a single space and nothing separates one stranger’s from another’s. That is why anonymous is refused on team and staff collections, which are addressed by who you are. An app with no sign-in at all still needs this on each collection: the app being open does not make its data open.

Writes are capped at 120 per minute per caller — counted per person when they are signed in, per address when they are not — and a caller past it gets a 429. So a script cannot fill your database through a public form faster than that, and the cap is the floor under your bill rather than the whole answer: Bot protection is what keeps the script from getting a row in at all. Reads are not capped.

On public collections the owner field is also queryable: ?owner=me for the caller’s own records, ?owner=<id> for anyone’s, and groupBy=owner in aggregates — which is how a leaderboard is one query.

Scope decides who can reach a collection. Sharing decides who can reach a record — “share this doc with Sam”, “invite a client to this one project”, “assign this ticket to Kim”. Add "share": true to a user or team collection:

{
"collections": {
"docs": { "share": true, "fields": { "title": "string", "body": "string" } }
}
}

The owner of a record grants it to an address:

const docs = collection("docs");
await docs.share(id, "sam@acme.com", { write: true });
await docs.sharedWith(id);
// → { shared: [{ to: "sam@acme.com", write: true, createdAt: 1750000000000 }] }
await docs.unshare(id, "sam@acme.com");
// → { revoked: true }

From then on the record just appears in Sam’s ordinary list. There is no second endpoint and no “shared with me” view to build — docs.list() returns their own records and the ones granted to them, and every record of a shareable collection carries owner so a screen can tell them apart:

await docs.list(); // yours + shared with you
await docs.list({ owner: "me" }); // only yours

Filters, ?q= search, sorting, cursors and _aggregate all follow the grant, so a count never contradicts the page it summarises.

Rule Why
A grant names an address, not an account Share with someone who has not signed up yet and it is waiting the day they do — the same rule as a team invite. A forwarded link grants nobody.
Only the owner (or staff) may grant A grantee cannot pass the record on. Re-sharing is a 403; someone with no relationship to the record gets a 404, so ids cannot be probed.
write: true edits the owner’s record Not a copy. Two people editing one doc see one doc. Without it, a grantee may only read.
Revoking, and deleting, are immediate A revoked grantee loses the record on their next request; deleting the record takes its grants with it.
Needs Sign-in on An address is who a grant names, and a proven address is what sign-in gives you.
At most 100 people per record

A Realtime socket is the one thing that does not follow a grant: a socket watches one space, so a grantee’s socket carries their own records only. Refresh a shared view on its own terms, and never promise it live — the same caveat as a staff screen.

A signed-in user whose role is staff reads every user’s records in a user collection, and can change or delete anyone’s record in a public one. Nothing about the request changes: the same GET /api/_data/<collection> that returns a member their own slice returns staff the whole collection, each record carrying owner so the screen can say whose it is — and ?owner=<id>, ?sort=owner and groupBy=owner work there too.

So an admin screen is the ordinary list your app already makes, rendered behind a role check. There is no second endpoint, no admin key, and nothing for the app to authorize itself with. You appoint staff in the Auth tab; the app cannot promote anybody from its own code.

Two honest edges. A staff ?q= search over a user collection ranks by words alone, even where the collection has "meaning": true, because the meaning half is indexed per user. And a ?data= Realtime socket still follows the caller’s own records on a user collection, so an all-users staff view refreshes rather than streams.

A collection declared with "notify": true emails you — the human who owns the project — a short heads-up whenever a record is created in it:

{
"collections": {
"signups": { "notify": true, "fields": { "email": "string", "note": "string" } }
}
}

This is what makes a waitlist, a contact form, an order button, or a feedback box complete: the visitor submits, the record lands, and you hear about it without polling the console. The email goes to the project owner’s account address, carries the new record’s fields (long values truncated), and its subject is fixed by the platform — a visitor never chooses who is mailed or what the subject says.

The mail leaves from your app, not from us. It goes out through the same Email capability the app sends everything else with, on your own Cloudflare account — so the entry’s fields never reach Typillar. That also means notify is silent until Email is on, and Email needs a domain you own. Until then the records are simply there in the console, which reads the app’s live data; the Data pane says as much and names the collections that are waiting.

Delivery is best-effort and throttled (10 a minute per collection — bursts collapse and the record itself is never at risk), and it fires only on creates (POST) — not on updates, imports via _batch, or records delivered by Scheduled jobs. Use it where a submission deserves your attention, never as a data channel.

A collection declared with "gate": "<Stripe product id>" is paid: every read and write requires a signed-in user who has bought that product through Payments:

{
"collections": {
"lessons": { "scope": "public", "gate": "prod_ABC123", "fields": { "title": "string", "video": "string" } }
}
}

A signed-out caller gets a 401; a signed-in non-buyer gets a 403 {"error":"this needs a purchase","product":"prod_ABC123"} — the same contract as a gated asset, so the app renders the same way out: a buy button whose checkout uses the price belonging to that product. The gate holds everywhere the collection is reachable — the record API, aggregates, batches, scheduling into it, and Realtime ?data= sockets.

Scope still applies among buyers: user gates each buyer’s own records (a pro feature), shared gates one pool all buyers share (a paid community), public + gate is premium content every buyer can read. It needs Sign-in and Payments on — an entitlement is checked in the app’s own database, written there by Stripe’s webhook, never by the browser.

Records live at /api/_data/<collection>, served from your app’s own origin — but the app does not write those fetches. It imports data, and the module calls them:

import { collection, inc, batch, DataError } from "data";
const todos = collection("todos");
// Create a record — returns the whole record
const todo = await todos.create({ title: "Ship the docs", done: false });
// → { id: "rec_…", title: "Ship the docs", done: false, createdAt: …, updatedAt: … }
// Read one — the SAME shape
const one = await todos.get(todo.id);
// List — items have the SAME shape again
const page = await todos.list({ done: false, sort: "-createdAt" });
// → { items: [ { id, title, done, createdAt, updatedAt }, … ], next }
// Delete
await todos.delete(todo.id);

Every ?key=value in the endpoint table below is one { key: value } in a query object, and a key whose value is undefined or null is left out — so an optional filter needs no if. A failed request throws a DataError carrying .status and the server’s own message.

Every record is flat. Fields sit directly on the record — never nested under a data key — and a list item, a single get, and the result of a write all have exactly the same shape.

Method & path Body Returns
POST /api/_data/:collection {...fields} 201 {id, ...fields, createdAt, updatedAt}; 409 if the id or a unique field is taken
GET /api/_data/:collection {items: [{id, ...fields, createdAt, updatedAt}], next}
GET /api/_data/:collection/:id {id, ...fields, createdAt, updatedAt} or 404
PUT /api/_data/:collection/:id {...fields} the full replacement — an upsert, so retries and imports land exactly once
PATCH /api/_data/:collection/:id {...fields} — a number field takes {"inc": n} the merged record; the merge and every inc are atomic in the database
DELETE /api/_data/:collection/:id 204
POST /api/_data/_batch {ops: [{collection, op: "put"/"patch"/"delete", id?, fields?}]} {results} — one transaction across collections, all or nothing
GET /api/_data/:collection/_aggregate {groups: [{...groupBy, <agg>_<field>}]}
GET /api/_data/:collection/_export a csv download, or a json array with ?format=json — the same rows a read returns, filters and all
POST /api/_data/:collection/_import a csv body, or a json array — content-type says which {"imported":n,"results":[…]} — every row upserted
POST /api/_data/:collection/:id/_share {"to":"sam@acme.com","write?":false} 201 {"shared":{"to","write"}} — owner only; grants one record to one address
GET /api/_data/:collection/:id/_share {"shared":[{"to","write","createdAt"}]}
DELETE /api/_data/:collection/:id/_share/:email {"revoked":true} — owner only

GET /api/_data/_schema returns the declared schema.

An undeclared collection, an unknown field, a missing required field, a value outside its enum, or a filter on a field you never declared is a 400 that names what is declared — the contract is enforced, not guessed.

POST creates, and never overwrites. A POST may carry an id in the body — that becomes the record’s id instead of a generated rec_… — but posting an id that already exists is a 409, not a silent replacement. (An id can’t start with _.)

PUT is the idempotent upsert. A retried request, or an import keyed by ids you already hold, lands exactly once — the second write replaces the first instead of failing or duplicating.

PATCH merges atomically. The merge happens inside the database in a single statement, so two concurrent patches to different fields both land. A number field takes {"inc": n} to add to whatever is stored — counters, votes and stock never read-modify-write:

await collection("posts").patch(id, { views: inc(1) });

_batch is one transaction, across collections. Up to 100 put/patch/delete ops succeed or fail as a unit. Each op names the collection it writes, so an order and its line items either all land or none do — no half-applied state to clean up:

await batch([
{ collection: "orders", op: "put", id: "o_1", fields: { total: 4200 } },
{ collection: "items", op: "put", fields: { order: "o_1", sku: "A" } },
{ collection: "items", op: "put", fields: { order: "o_1", sku: "B" } },
{ collection: "carts", op: "delete", id: "c_9" },
]);
// → { results: [ {...}, {...}, {...}, { id: "c_9", deleted: true } ] }

Every collection in the batch is checked the way a direct write to it would be — its scope, its gate, and whether you may write it at all. results answers in the order you sent.

Filter, sort, paginate and aggregate in the database. Never pull every row and do it in the browser.

?<field>=<op>:<value> ops: eq ne gt gte lt lte like in has null (bare ?date=x means eq)
?meta.color=navy dots reach into json fields; ?tags=has:sale matches inside a json array
?due=null:true true = field unset, false = set; public collections: ?owner=me or ?owner=<id>
?q=oak+chair searches the collection's "searchable" fields, best match first
?meaning=off with ?q=, ranks by words alone and spends nothing on embedding
?expand=author a "ref" field comes back as the RECORD, not the id (max 3)
?sort=-createdAt "-" = descending; default -createdAt (newest first)
?limit=50&cursor=<next> pass back the "next" you got; null means no more pages
?sum=kcal&groupBy=date on /_aggregate; aggregates: count sum avg min max

json fields are queryable, not opaque: a dotted path (?meta.color=navy) filters, sorts, groups and indexes inside them, has: matches inside a json array (?tags=has:sale), and null: finds records with a field unset (true) or set (false).

// Today's calories, grouped by meal — one query, computed in SQLite
const { groups } = await collection("entries").aggregate({
date: "2026-07-13",
sum: "kcal",
count: 1,
groupBy: "meal",
});
// → { groups: [ { meal: "breakfast", sum_kcal: 200, count: 2 }, … ] }

Lists are keyset-paginated: pass the returned next back as ?cursor= for the following page. When next is null, there are no more pages. limit is 1–500, default 50.

Every collection exports itself. No code builds a CSV in the browser, and none parses an uploaded one.

const entries = collection("entries");
// A download link — point it straight at the url the module builds
<a href={entries.exportUrl()} download>Export CSV</a>
// The same rows as JSON
const rows = await fetch(entries.exportUrl({ format: "json" })).then((r) => r.json());

An export carries exactly the rows a read would, so every filter, search and sort works on it too — ?status=live&sort=-createdAt exports what that screen shows, and nothing a person cannot already see. It streams, so a large one never has to fit in memory.

Importing is the same endpoint backwards:

await entries.importRows(await file.text());
// → { "imported": 120, "results": [ … ] }

importRows() sends a string as CSV and an array as JSON, so neither content type is yours to remember.

What an export writes, an import reads. The CSV header is the collection’s declared fields, and every row is upserted by its id column — so re-importing a file you exported changes nothing, and importing an edited one updates exactly the rows that changed. Drop the id column, or leave it empty, and each row becomes a new record instead.

Every cell is coerced to its declared type, and every row is checked against the schema before any of them is written — a file with one bad row writes nothing at all, and the 400 names the row. An import is at most 5,000 rows and 5 MB; an export at most 50,000 records. Past either you get a 413 that says what it matched, so narrow it with a filter.

Orders and entitlements are not collections you write — they’re written by Stripe’s webhook alone, and read at GET /api/_pay/orders and GET /api/_pay/entitlements. See Payments.

Received mail, by contrast, is a collection. Each mailbox you configure in Email writes arriving messages into the collection you name, so you read, filter, sort and subscribe to your mail exactly as you do everything else.

Search is not a separate store you keep in step with your records, and not something to switch on. It is a property of a collection: you say which fields are searchable, and the route you already read gains ?q=.

{
"collections": {
"products": {
"scope": "public",
"searchable": { "fields": ["name", "blurb"] },
"fields": { "name": "string", "blurb": "string", "brand": "string" },
"indexed": ["brand"]
}
}
}
const { items, next } = await collection("products").list({
q: "oak chair",
brand: "acme",
limit: 20,
});

That is the whole API. Records are searchable the instant they are written — the index lives in the same database, updated in the same transaction, so there is no lag to design around and nothing to keep in sync.

searchable.fields is ordered, and the order is the ranking weight. The first field is the lead — a product’s name, a post’s title — and a match there outranks a match in the supporting text. Put the name first and a search for “leather” finds the Leather chair before the desk lamp whose description happens to mention one.

Only string fields can be searchable. Numbers, booleans and dates are for filtering, which q composes with: { q: "chair", brand: "acme", price: "lt:20000" } searches, narrows and ranks in one query.

?q= also works on _aggregate, so a filter sidebar counts within the search, not across the whole collection:

await collection("products").aggregate({ q: "chair", groupBy: "brand", count: 1 });
// → { groups: [ { brand: "acme", count: 42 }, { brand: "bolt", count: 17 } ] }

Results come back best-match first, and page with the same next cursor as any other list. Relevance paging stops at 200 results — past that next is null. Nobody reaches page eleven of a search; if they would, they need a filter, not another page.

Ask for an explicit sort and q becomes a plain filter, ordered your way instead of by relevance.

What the visitor types is text, never syntax

Section titled “What the visitor types is text, never syntax”

Whatever someone types is treated as words to find. Search operators, quotes and punctuation carry no special meaning and cannot produce an error — you can bind an input straight to ?q= without sanitising it first. An empty or whitespace-only q is not a search at all: it lists, so a cleared search box shows the full list again.

Words match on their stem, so a plural finds the singular and a tense finds its verb: chairs finds the Oak chair, run finds the Running shoes, and neither needs the person searching to guess which form you happened to store. The last word they typed also matches as a prefix, so results narrow while they are still typing it.

Everything above ranks by words. A collection can also rank by meaning, and asking for it changes nothing you write — same route, same query, same shape of results. It is declared where the cost is, collection by collection:

"searchable": { "fields": ["name", "blurb"], "meaning": true }

Those records are embedded as well as indexed, and the word match is blended with a meaning match, so a search for “couch” finds the record that only ever says “sofa”. A collection that leaves meaning out goes on ranking by words alone and costs nothing to run.

Declaring meaning is what creates the vector index, on the next ship, in your own Cloudflare account. There is no separate switch to find: the schema is the switch.

Because the word half is transactional, a new record is always findable immediately; the meaning half catches up within a few seconds and only ever improves the order.

Turning meaning on for a collection that already holds records backfills them in the background — the Data tab shows the progress as “420 of 5,000 understood”, and until it finishes those records still rank by words. Turning it off stops the cost and discards what was embedded; the fields stay searchable, by words.

A box someone types into is not one request, it is a race. Keystrokes overlap, the answer for ch can arrive after the answer for chair, and a list that renders whatever landed last shows results for what was typed two keystrokes ago. searchBox owns that timing so you never write it:

import { searchBox } from "data";
const box = searchBox("products", {
onResults: (items) => { found = items; },
onBusy: (b) => { busy = b; },
filters: () => ({ brand }),
});
<input oninput={(e) => box.type(e.target.value)} />
<button onclick={() => box.submit()}>Search</button>

box.type(value) on every keystroke — debounced, and ranked by words alone, so a keystroke embeds nothing and costs nothing even on a meaning collection. box.submit() runs at once and brings meaning in, where the collection declares it. box.clear() empties the box and lists again; box.close() when the component goes away.

Between those two it cancels the request a newer keystroke replaced, and discards a slow answer that would have landed after a newer one. filters is read fresh on every query, so the state of your sidebar at that moment is what narrows the search.

It renders nothing at all — no markup, no styles, no classes. The input, the results, the empty state and every class on them are yours, so it carries none of its own look into your design.

A record that came back from a search carries _match: a fragment of the text the query hit, already split into the parts that matched and the parts around them.

[
{ text: "a wide ", hit: false },
{ text: "chair", hit: true },
{ text: " for a long desk…", hit: false },
]
{#each item._match as part}
{#if part.hit}<mark>{part.text}</mark>{:else}{part.text}{/if}
{/each}

It arrives split rather than as a string of markup on purpose: the surrounding text is whatever someone typed into your app, so a highlighted string would have to be rendered as HTML to show the mark, and that is an injection waiting to happen. Split, every part renders as text and the highlight is a class you choose.

The mark lands on the word the record holds, not the word that was typed — search chairs and chair comes back marked. A record only the meaning half found has no _match at all, since no word in it was hit. Neither does a plain list: with no q there is nothing to mark.

Nothing new to learn: a search obeys the collection’s scope exactly as a list does. A user collection searches only the caller’s records, shared searches the one pool every signed-in user shares, and public is searchable by anyone, signed in or not. A collection gated on a purchase stays gated — searching it needs the same entitlement reading it does.

The word index is SQLite’s own full-text search, in the same database as your records and maintained by the database itself, so it cannot drift from them. For a meaning collection, meanings are stored in Cloudflare’s vector search and embedded with Workers AI, both in your account, and the two rankings are combined into one order. You never handle a vector, a dimension or an embedding model.

When Realtime is on, every write to a collection is pushed to any client subscribed to it over a WebSocket — so a list can re-render the moment a record changes, with no polling. The socket follows the collection’s scope: a user collection pushes your own changes, a shared or public one pushes everyone’s. You don’t wire anything up; it rides on the same write. See Realtime.

Data is backed by SQLite (Cloudflare D1) in your own Cloudflare account. Each project has one database — <project>-db — and everything that persists lives in it: your records, plus whatever Sign-in, Payments and Email keep. Each indexed field becomes a real SQLite index — a dotted json path becomes an expression index, and a unique rule becomes a unique index the database enforces — so a filter or a sort is answered by the database rather than by scanning. Every deploy shares that one database, so dropping a ticket branch never touches your live data.