Recipes
- Prebuilt patterns for common Puter.js tasks — the recommended way to do
- each of these. Copy one rather than working it out from the API reference.
+ Build specific Puter.js features with recipes from Puter
+ and the community.
${renderCards(recipes)}
@@ -254,7 +262,7 @@ function renderIndexPage (recipes) {
return renderPage({
title: 'Recipes | Puter.js',
- description: 'Prebuilt, copy-pasteable patterns for building with Puter.js — AI, storage, auth, hosting, and more.',
+ description: 'Build specific Puter.js features with recipes from Puter and the community.',
canonical: `${site}/recipes/`,
body,
});
@@ -269,7 +277,6 @@ function renderRecipePage (recipe, recipes) {
${encode(recipe.title)}
${renderTagChips(recipe.tags, { linked: true })}
- ${encode(recipe.description)}
${marked.parse(recipe.body)}
← All recipes
diff --git a/src/docs/src/recipes/assets/css/style.css b/src/docs/src/recipes/assets/css/style.css
index bf09ec094..734cca251 100644
--- a/src/docs/src/recipes/assets/css/style.css
+++ b/src/docs/src/recipes/assets/css/style.css
@@ -85,16 +85,6 @@ body {
font-size: 32px;
font-weight: 600;
}
-
-.recipes-intro,
-.recipe-lede {
- margin: 0 0 28px;
- font-size: 16px;
- line-height: 1.6;
- color: #55585f;
- max-width: 62ch;
-}
-
/* ---------- Sidebar: search + tag filters ---------- */
.recipes-search input {
@@ -296,21 +286,6 @@ a.recipe-tag:hover {
.recipe-detail .code-wrapper { margin: 18px 0; }
-/* The anchor links the docs' heading renderer emits; the docs sheet styles them
- only under .docs-content, so they need positioning here. */
-.recipe-detail .anchored-heading { position: relative; }
-
-.recipe-detail .anchor::before {
- content: '#';
- position: absolute;
- left: -20px;
- color: #c3c7cf;
- opacity: 0;
- transition: opacity 0.12s;
-}
-
-.recipe-detail .anchored-heading:hover .anchor::before { opacity: 1; }
-
.recipes-back {
display: inline-block;
margin-top: 36px;
diff --git a/src/docs/src/recipes/host-file.md b/src/docs/src/recipes/host-file.md
new file mode 100644
index 000000000..442c13c89
--- /dev/null
+++ b/src/docs/src/recipes/host-file.md
@@ -0,0 +1,103 @@
+---
+title: Host Files Online
+description: "Learn how to host files from the user's Puter filesystem online and give them a public URL that anyone can open."
+tags: [fs, hosting]
+order: 50
+---
+
+A file written with [`puter.fs.write()`](/FS/write/) or
+[`puter.fs.upload()`](/FS/upload/) lives in the user's Puter filesystem, where
+only the SDK can reach it. To hand out a link anyone can open, such as an
+` ` source or a URL you can share, host the directory that file is in
+online. You host the directory once, and every file you put in it after that
+already has a URL.
+
+## Host a Directory
+
+To host a directory, use the [`puter.hosting.create()`](/Hosting/create/)
+method. It maps a subdomain onto a directory:
+
+```js
+const dir = await puter.fs.mkdir('public');
+const site = await puter.hosting.create(puter.randName(), 'public');
+
+site.subdomain; // 'lucky-hill-8121'
+site.root_dir.path; // '/username/public'
+```
+
+The site is at `https://.puter.site`, and everything inside the directory
+will be served from there.
+
+Subdomain names are global, so [`puter.randName()`](/Utils/randName/) claims one
+nobody else has. A name already in use rejects with a `conflict` error, so catch
+it and try another name.
+
+## Reuse the Same Subdomain
+
+One directory needs one subdomain for its lifetime. To find the subdomain a
+directory already has, use the [`puter.fs.stat()`](/FS/stat/) method with
+`returnSubdomains: true`:
+
+```js
+const DIR = 'public';
+
+async function publicSite () {
+ const dir = await puter.fs.stat(DIR, { returnSubdomains: true })
+ .catch(() => puter.fs.mkdir(DIR));
+
+ // [{ uuid, subdomain, address: 'https://lucky-hill-8121.puter.site' }]
+ const existing = dir.subdomains?.[0];
+ const subdomain = existing
+ ? existing.subdomain
+ : (await puter.hosting.create(puter.randName(), DIR)).subdomain;
+
+ return { dir, base: `https://${ subdomain }.puter.site` };
+}
+```
+
+Each entry carries the label and a ready-made `address`.
+
+## Write Files Into It
+
+To put a file in the hosted directory, use the [`puter.fs.write()`](/FS/write/)
+or [`puter.fs.upload()`](/FS/upload/) method. Both hand back the
+[`FSItem`](/Objects/fsitem/) that was stored:
+
+```js
+const item = await puter.fs.write('public/report.csv', csvText);
+const items = await puter.fs.upload(fileInput.files, 'public');
+```
+
+The [`puter.fs.upload()`](/FS/upload/) method deduplicates names, so `photo.png`
+lands as `photo (1).png` when the name is taken. Read the name back off the
+result and use it to keep the URL consistent.
+
+## Compose the URL
+
+Everything inside the directory you host is reachable at the site address, and
+any folder inside it becomes part of the URL:
+
+```
+public/photo.png → https://lucky-hill-8121.puter.site/photo.png
+public/2026/march/pic.jpg → https://lucky-hill-8121.puter.site/2026/march/pic.jpg
+```
+
+In code, that is the site address followed by the path you wrote the file to:
+
+```js
+const { base } = await publicSite();
+
+await puter.fs.write('public/2026/photo.png', blob);
+
+const url = `${ base }/2026/photo.png`;
+// https://lucky-hill-8121.puter.site/2026/photo.png
+```
+
+## Revoke a Link
+
+To take a link offline, delete the file with the
+[`puter.fs.delete()`](/FS/delete/) method:
+
+```js
+await puter.fs.delete('public/photo.png');
+```
diff --git a/src/docs/src/recipes/kv-append-to-list.md b/src/docs/src/recipes/kv-append-to-list.md
index a71982f21..387b46c2a 100644
--- a/src/docs/src/recipes/kv-append-to-list.md
+++ b/src/docs/src/recipes/kv-append-to-list.md
@@ -1,8 +1,9 @@
---
title: Append items to a growing list
-description: Keep an append-only list such as an event log or a chat history as a real array in one entry, so adding to it is a single write that never reads the list first.
+description: Keep a running log of things as they happen, like chat messages, events or activity history, and add to it in one line.
tags: [kv, data-modeling]
order: 10
+draft: true
---
**Use this when** the only operation is append, such as an event log, a chat
@@ -25,8 +26,8 @@ log.length; // 2 (already an array, no parse step)
## Append with add()
-[`puter.kv.add()`](/KV/add/) appends without the list ever travelling to your app
-and back:
+[`puter.kv.add()`](/KV/add/) appends without the list ever travelling to your
+app and back:
```js
await puter.kv.add('log', [{ at: 3, event: 'saved' }]);
@@ -99,9 +100,9 @@ expression is invalid for update"* and the stored value is left unchanged.
## Notes
- Counters use [`puter.kv.incr()`](/KV/incr/); `add()` is the append operation.
-- Array elements are not path-addressable, so there is no `log.0` to target. If you
- find yourself needing to change or remove one item, store the items
- [keyed by id](/recipes/kv-edit-items-by-id/) instead.
+- Array elements are not path-addressable, so there is no `log.0` to target. If
+ you find yourself needing to change or remove one item, store the items
+ [storing a small list](/recipes/store-small-list/) instead.
- A value is capped at **400 KB**. For a list that grows indefinitely, cap it,
roll over to a new key, or move to [one key per
- item](/recipes/kv-prefix-listing/).
+ item](/recipes/store-large-collection/).
diff --git a/src/docs/src/recipes/kv-edit-items-by-id.md b/src/docs/src/recipes/kv-edit-items-by-id.md
deleted file mode 100644
index ecc79fb95..000000000
--- a/src/docs/src/recipes/kv-edit-items-by-id.md
+++ /dev/null
@@ -1,105 +0,0 @@
----
-title: Managing a list of objects inside a key-value entry
-description: Store items such as todos, tasks or saved records as an object keyed by id in one entry, so changing or removing one of them is a single write instead of rewriting the whole value.
-tags: [kv, data-modeling]
-order: 20
----
-
-**Use this when** items are edited or deleted after they are written, such as a
-todo list, a task board, saved records, or a set of settings. Keying by id is
-what makes a single item addressable; editing and deleting both fall out of that.
-The whole value is still read with a single `get()`.
-
-## Key the items by id
-
-Store the items as an **object keyed by id**, not as an array. Object paths
-are addressable, so every operation becomes a single round trip that never reads
-the list first:
-
-```js
-// Add
-await puter.kv.update('todos', {
- [id]: { text: 'Buy milk', done: false, at: Date.now() },
-});
-
-// Mark done, changing one field of one item
-await puter.kv.update('todos', { [`${id}.done`]: true });
-
-// Delete
-await puter.kv.remove('todos', id);
-
-// Show
-const todos = Object.values(await puter.kv.get('todos') ?? {});
-```
-
-Each of those is one call with no read-modify-write, which is what an array
-cannot give you: array elements are not path-addressable, so changing one entry
-means reading the whole list, editing it in memory, and writing it back.
-
-That also makes concurrent edits safe. Two tabs marking different todos done
-write disjoint paths, so both land. The read-modify-write version has a genuine
-race: both read the list, both write it back, and one of the two updates is
-lost.
-
-## A complete todo list
-
-```html
-
-
-```
-
-## Ordering
-
-A map has no inherent order, and the stored field order is not preserved on read.
-Carry an explicit `at` (or `order`) field on each item and sort when you render,
-as `listTodos()` does above. At sizes that fit in one entry this costs
-nothing measurable.
-
-## Notes
-
-- **Keep dots out of ids**, since a dot separates path segments.
- `crypto.randomUUID()` is safe. Avoid numeric-looking ids such as `"1"`, which
- risk being read as an array index.
-- `puter.kv.remove(key, ...paths)` takes several paths, so a few items can be
- deleted in one call: `puter.kv.remove('todos', idA, idB)`.
-- Removing by array index is the case this shape exists to avoid: `remove(key,
- 'items.0')` returns a success payload and silently changes nothing.
-- A value is capped at **400 KB**, roughly a few thousand small items. Past that,
- or when items need their own TTL, switch to [one key per
- item](/recipes/kv-prefix-listing/).
diff --git a/src/docs/src/recipes/kv-prefix-listing.md b/src/docs/src/recipes/kv-prefix-listing.md
deleted file mode 100644
index 8b379ff3f..000000000
--- a/src/docs/src/recipes/kv-prefix-listing.md
+++ /dev/null
@@ -1,121 +0,0 @@
----
-title: Managing a collection of key-value entries
-description: Give each record its own key under a shared prefix, so single records are written, changed and expired directly while the collection is read back a slice at a time.
-tags: [kv, data-modeling, performance]
-order: 30
----
-
-**Use this when** the collection is too big for one entry, past **400 KB**, or
-records need to expire on their own schedule, or reads want one slice at a time
-rather than everything at once.
-
-## Design the key
-
-Each record is its own entry, so the key is what organises the collection. Build
-it so the field you filter on comes first:
-
-```js
-// todo::
-await puter.kv.set(`todo:${category}:${id}`, { text: 'Buy milk', done: false });
-```
-
-Pick a field that actually partitions the data, such as a category, a project or
-a status. The store is already scoped to one user, so a user id in the key adds a
-level that never varies.
-
-Everything else in this recipe follows from that layout: a full key addresses one
-record, and a prefix addresses a slice.
-
-## Work on one record
-
-Because each record has its own key, every single-record operation is a direct
-call with no read of the rest:
-
-```js
-const key = `todo:${category}:${id}`;
-
-await puter.kv.update(key, { done: true }); // change one field
-await puter.kv.set(key, { text: 'Buy oat milk', done: false }); // replace
-await puter.kv.del(key); // delete
-await puter.kv.expire(key, 60 * 60 * 24); // expires on its own, 24h
-```
-
-Per-record expiry is the thing this shape gives you that the single-entry ones
-cannot: a TTL applies to a whole key, so records sharing one entry can only
-expire together.
-
-## Read the collection
-
-[`puter.kv.list()`](/KV/list/) reads back a slice, with values:
-
-```js
-const rows = await puter.kv.list(`todo:${category}:`, true);
-// [ { key: 'todo:home:a', value: { text: 'Buy milk', done: false } },
-// { key: 'todo:home:b', value: { text: 'Water plants', done: true } } ]
-```
-
-Records come back sorted lexicographically by key.
-
-## Page through it
-
-Pass `limit` to get a page plus a `cursor`, and keep going until a page comes
-back without one:
-
-```js
-let cursor;
-do {
- const page = await puter.kv.list({
- pattern: `todo:${category}:`,
- returnValues: true,
- limit: 100,
- cursor,
- });
- render(page.items);
- cursor = page.cursor;
-} while ( cursor );
-```
-
-`for await` does the same thing with `stream: true`:
-
-```js
-for await ( const page of puter.kv.list({ pattern: `todo:${category}:`, returnValues: true, limit: 100, stream: true }) ) {
- render(page.items);
-}
-```
-
-## What a prefix buys you
-
-The pattern is **prefix-only**, with `*` allowed at the end and nowhere else. So
-a key buys you exactly **one** filter dimension: whichever field you put first.
-
-```js
-puter.kv.list('todo:home:', true); // every todo in the home category
-puter.kv.list('todo:home:*', true); // the same thing
-```
-
-Filtering on a second field, done vs. not done for example, means listing the
-prefix and filtering the results client-side:
-
-```js
-const rows = await puter.kv.list(`todo:${category}:`, true);
-const open = rows.filter(r => ! r.value.done);
-```
-
-Only the leading field is selectable, so give that position to whichever one you
-read by most often. If that is status rather than category, key on
-`todo::` instead and let category become the client-side filter. This
-is key-prefix partitioning, not a query engine, and there is no secondary
-index.
-
-## Notes
-
-- Every page is metered, and a bare `list()` with no pattern reads the entire
- store. Always pass a `pattern`, a `limit`, or both.
-- Results sort lexicographically by key, so zero-pad numbers
- (`todo:home:000042`) if you want them to sort numerically.
-- `includeTotal` costs a full count and grows with the store, so request it once
- on the first page rather than in a hot path. To find out only whether more
- pages exist, check for `cursor`.
-- Listing is the expensive operation here. If showing the whole collection is what
- your app does most and it fits in 400 KB, [keying the items by id](/recipes/kv-edit-items-by-id/)
- makes that a single `get()` instead of a scan.
diff --git a/src/docs/src/recipes/query-collection.md b/src/docs/src/recipes/query-collection.md
new file mode 100644
index 000000000..9387515cf
--- /dev/null
+++ b/src/docs/src/recipes/query-collection.md
@@ -0,0 +1,137 @@
+---
+title: Query a Collection
+description: "Learn how to perform queries on a collection of records in the Puter.js key-value database."
+tags: [kv, data-modeling, performance]
+order: 35
+---
+
+In some cases your app needs filtering, whether by category, status, date or
+something else. You can do that with a key prefix. This picks up where [store a
+large collection](/recipes/store-large-collection/) leaves off, with each record
+under its own key.
+
+## Put the Filter Criteria in the Key
+
+To query by a field, embed it in the key when you write the record with the
+[`puter.kv.set()`](/KV/set/) method. Notice that the status comes before the
+unique id:
+
+```js
+// order::
+await puter.kv.set(`order:${ status }:${ id }`, order);
+```
+
+To read one slice, use the [`puter.kv.list()`](/KV/list/) method with that
+prefix. The `true` brings the values back with the keys, so one round trip both
+selects and loads:
+
+```js
+const pending = await puter.kv.list('order:pending:*', true);
+// [ { key: 'order:pending:0001', value: { customer: 'alice', total: 48 } }, ... ]
+```
+
+## How a Pattern Matches
+
+The [`puter.kv.list()`](/KV/list/) method matches a prefix of the key, byte for
+byte:
+
+```js
+await puter.kv.list('order:pending:'); // every pending order
+await puter.kv.list('order:pending:*'); // the same set, the trailing * is optional
+await puter.kv.list('order:pend'); // also matches, a prefix can stop mid-segment
+await puter.kv.list('order:*:alice:'); // empty, the * is a literal asterisk here
+await puter.kv.list('ORDER:pending:'); // empty, matching is case-sensitive
+```
+
+Lowercase the segments you build keys from, and the case a value arrives in
+stops mattering:
+
+```js
+await puter.kv.set(`order:${ status.toLowerCase() }:${ id }`, order);
+```
+
+## Filter on a Second Field
+
+Every prefix of a key is a filter you can read, so an extra segment adds a
+filter:
+
+```js
+// order:::
+await puter.kv.set(`order:${ status }:${ customer }:${ id }`, order);
+
+await puter.kv.list('order:pending:', true); // by status
+await puter.kv.list('order:pending:alice:', true); // by status and customer
+```
+
+The segments read left to right, so this layout answers "pending" and "pending
+for alice", while "everything for alice" needs its own key. Put the field every
+view filters on first, and the one only some views add second.
+
+## Query by Another Field
+
+To filter by a different field, you need a duplicate key that starts with that
+field. Write it beside the record and copy in the fields the view shows:
+
+```js
+const order = { id, status, customer, total };
+
+await puter.kv.set(`order:id:${ id }`, order);
+await puter.kv.set(`order:by-customer:${ customer }:${ id }`, { id, status, total });
+```
+
+Reading by customer is then one query, the same as any other prefix:
+
+```js
+const rows = await puter.kv.list('order:by-customer:alice:*', true);
+```
+
+Since both keys hold the data, you need to make sure every write happens to
+both. Update the duplicate whenever the record changes, and delete it whenever
+the record is deleted.
+
+## Filter the Rest in Your Code
+
+A field you rarely filter on can stay out of the key. Narrow the read with a
+prefix first, then filter what came back:
+
+```js
+const rows = await puter.kv.list('order:pending:', true);
+const large = rows.filter(row => row.value.total > 50);
+```
+
+That works once the prefix has already cut the set down to about a page, and it
+keeps rarely used filters out of the key layout.
+
+## Filter by Date
+
+ISO 8601 timestamps sort chronologically as text, so a prefix of one is a
+calendar range:
+
+```js
+await puter.kv.set(`log:${ new Date().toISOString() }`, entry);
+// log:2026-09-09T14:03:11.204Z
+
+await puter.kv.list('log:2026-09-', true); // September
+await puter.kv.list('log:2026-09-09', true); // one day
+await puter.kv.list('log:2026-09-09T14', true); // one hour
+```
+
+A listing comes back in ascending key order, so newest first is a `reverse()` on
+the page, or a key built from a counted-down timestamp such as `String(1e13 -
+Date.now())`.
+
+## Filter by Number
+
+A number in a key sorts as text, so `10` lands before `9`. Pad it to a fixed
+width and the order comes out numeric, which also makes a digit prefix a range:
+
+```js
+await puter.kv.set(`invoice:${ String(number).padStart(6, '0') }`, invoice);
+// invoice:000042
+
+await puter.kv.list('invoice:*', true); // every invoice, in numeric order
+await puter.kv.list('invoice:000*', true); // 000000 to 000999
+```
+
+Pick a width the numbers will not outgrow. Once the count passes six digits,
+`invoice:1000000` sorts before `invoice:999999` and the order breaks.
diff --git a/src/docs/src/recipes/store-data.md b/src/docs/src/recipes/store-data.md
new file mode 100644
index 000000000..39d690e6c
--- /dev/null
+++ b/src/docs/src/recipes/store-data.md
@@ -0,0 +1,107 @@
+---
+title: Store Data
+description: "Learn how to store data in the Puter.js key-value database. Every entry lives inside the user's own Puter account."
+tags: [kv, auth, data-modeling]
+order: 5
+---
+
+The foundation of every application is storing data, and in Puter.js you do that
+with the [key-value store API](/KV/). It supports the standard operations you
+would expect from any database, such as writes, reads, updates, deletes, and
+more. All data lives inside the user's own Puter account.
+
+## Set
+
+To store data, use the [`puter.kv.set()`](/KV/set/) method. It takes a key and a
+value:
+
+```js
+await puter.kv.set('theme', 'dark');
+```
+
+A value can be a string, a number, a boolean, or a whole object or array, so a
+structured record goes in the same way a single setting does:
+
+```js
+await puter.kv.set('settings', { theme: 'dark', sound: false, volume: 0.8 });
+await puter.kv.set('recent', ['puter.js', 'kv', 'workers']);
+```
+
+Setting the same key again replaces its value:
+
+```js
+await puter.kv.set('theme', 'dark');
+await puter.kv.set('theme', 'light'); // 'theme' is now 'light'
+```
+
+## Get
+
+To read it back, use the [`puter.kv.get()`](/KV/get/) method. It takes a key and
+returns the value in the shape you stored it:
+
+```js
+const settings = await puter.kv.get('settings');
+settings.theme; // 'dark'
+```
+
+A key that was never written comes back empty, which is where your defaults go:
+
+```js
+const settings = await puter.kv.get('settings') ?? { theme: 'light', sound: true };
+```
+
+For most apps that is the whole storage layer. You call
+[`puter.kv.set()`](/KV/set/) when something changes and
+[`puter.kv.get()`](/KV/get/) when the app loads.
+
+## Where the Data Lives
+
+Each entry is written to the **signed-in user's own account**, inside a sandbox
+that belongs to your app. User A's `settings` and user B's `settings` are
+separate entries, and neither user can read the other's. Every other app in the
+same account gets its own sandbox, so your keys and another app's keys never
+mix. The user covers their own storage under the [User-Pays
+Model](/user-pays-model/).
+
+## List
+
+To see what you stored, use the [`puter.kv.list()`](/KV/list/) method. It
+returns the keys your app wrote for this user, sorted by key:
+
+```js
+const keys = await puter.kv.list();
+// ['recent', 'settings', 'theme']
+```
+
+Pass `true` to get the values along with them:
+
+```js
+const entries = await puter.kv.list(true);
+// [{ key: 'recent', value: ['puter.js', 'kv', 'workers'] }, ...]
+```
+
+## Delete
+
+To remove one entry, use the [`puter.kv.del()`](/KV/del/) method:
+
+```js
+await puter.kv.del('theme');
+```
+
+## Flush
+
+To empty your app's sandbox for this user, use the
+[`puter.kv.flush()`](/KV/flush/) method:
+
+```js
+await puter.kv.flush();
+```
+
+## Notes
+
+- Binary data, such as an image, an audio clip or a PDF, goes to the user's
+ drive with the [filesystem API](/FS/) instead. [Store files in the user's own
+ Puter account](/recipes/store-files/) covers it.
+- Data every user has to see, such as a leaderboard or a guestbook, goes [behind
+ a worker](/recipes/store-server-side-data/), which runs under your account
+ instead of theirs.
diff --git a/src/docs/src/recipes/store-files.md b/src/docs/src/recipes/store-files.md
new file mode 100644
index 000000000..6a646ff5f
--- /dev/null
+++ b/src/docs/src/recipes/store-files.md
@@ -0,0 +1,145 @@
+---
+title: Store Files
+description: "Learn how to store binary files in Puter.js object storage. Every file lives inside the user's own Puter account."
+tags: [fs, auth]
+order: 7
+---
+
+Most applications need somewhere to keep binary files, such as images, videos,
+PDFs, or exports the app generates. In Puter.js that object storage is the
+[filesystem API](/FS/). It supports the standard operations you would expect
+from any filesystem, such as writes, reads, listings, deletes, and more. Files
+are addressed by path rather than by bucket and key, and they all live inside
+the user's own Puter account.
+
+## Write
+
+To save a file, use the [`puter.fs.write()`](/FS/write/) method. It takes a path
+and the contents:
+
+```js
+await puter.fs.write('notes/todo.txt', 'Buy milk');
+```
+
+The contents can be a `String`, `File`, `Blob`, `ArrayBuffer` or typed array, so
+text and binary data are written the same way. Writing to a path that already
+exists replaces the file, and passing `dedupeName` keeps both copies by saving
+the new one under a free name:
+
+```js
+await puter.fs.write('uploads/photo.png', blob, { dedupeName: true });
+```
+
+Writing into a directory that does not exist yet is one more option:
+
+```js
+await puter.fs.write('exports/2026/report.csv', csv, { createMissingParents: true });
+```
+
+## Read
+
+To read a file back, use the [`puter.fs.read()`](/FS/read/) method. It hands
+back a `Blob`:
+
+```js
+const blob = await puter.fs.read('notes/todo.txt');
+await blob.text(); // 'Buy milk'
+```
+
+A `Blob` is what the browser already works with, so `.text()`, `.arrayBuffer()`
+and `URL.createObjectURL()` all work on the result.
+
+## Where the Files Live
+
+Each file is written to the **signed-in user's own drive**. User A's
+`notes/todo.txt` and user B's are separate files, neither user can read the
+other's, and the user covers their own storage under the [User-Pays
+Model](/user-pays-model/).
+
+A relative path resolves against `~/AppData//`, the sandbox Puter
+creates for your app the first time the user signs in. You can create any files
+and folders you like inside it, and your app cannot see anything outside it.
+
+## Upload
+
+To take a file from an ` `, use the
+[`puter.fs.upload()`](/FS/upload/) method:
+
+```js
+input.onchange = async () => {
+ const file = await puter.fs.upload(input.files, 'uploads', { createMissingParents: true });
+ file.path; // '/user/AppData/app-.../uploads/photo.png'
+};
+```
+
+One selected file resolves to one [`FSItem`](/Objects/fsitem/) and several
+resolve to an array of them. A name that is already taken stays as it is, and
+the new file lands under a free one.
+
+## List
+
+To see what a directory holds, use the [`puter.fs.readdir()`](/FS/readdir/)
+method:
+
+```js
+const items = await puter.fs.readdir('uploads', { sortBy: 'modified', sortOrder: 'desc' });
+// [ { name: 'photo.png', path: '/user/AppData/app-.../uploads/photo.png', size, modified, isDir }, ... ]
+```
+
+It pages the way a key listing does, with `limit` and `cursor`, or `stream:
+true` for `for await`, which is what keeps a directory of thousands affordable
+to display.
+
+To read the metadata of one file, use the [`puter.fs.stat()`](/FS/stat/) method:
+
+```js
+const info = await puter.fs.stat('uploads/photo.png'); // size, timestamps, id
+```
+
+## Rename, Move and Copy
+
+To reorganize what is there, use the [`puter.fs.rename()`](/FS/rename/),
+[`puter.fs.move()`](/FS/move/) and [`puter.fs.copy()`](/FS/copy/) methods:
+
+```js
+await puter.fs.rename('uploads/photo.png', 'cover.png'); // second argument is a name
+await puter.fs.move('uploads/draft.png', 'archive'); // second argument is a directory
+await puter.fs.copy('uploads/cover.png', 'archive');
+```
+
+## Delete
+
+To remove a file, use the [`puter.fs.delete()`](/FS/delete/) method:
+
+```js
+await puter.fs.delete('uploads/old.png');
+```
+
+It also takes an array of paths to remove several items in one call, and
+deleting a directory is recursive by default.
+
+## Get a Link
+
+To show a file in an ` ` tag or hand it to a download button, use the
+[`puter.fs.getReadURL()`](/FS/getReadURL/) method. It mints a temporary URL for
+one file:
+
+```js
+const url = await puter.fs.getReadURL('uploads/photo.png', '1h');
+```
+
+The URL gives temporary access to that one file. It expires after the duration
+you pass, or in 24 hours if you leave the duration off.
+
+## Notes
+
+- Structured data, such as settings, records and lists, belongs in the
+ [key-value database](/KV/) instead. A JSON file means reading and rewriting
+ the whole thing on every change, while a key-value entry addresses one record
+ at a time. [Store data in the Puter.js key-value
+ database](/recipes/store-data/) covers it.
+- Files every user of the app has to reach go [behind a
+ worker](/recipes/store-server-side-data/), which runs under your account
+ instead of theirs.
+- A URL that stays valid takes hosting a directory instead of minting a link per
+ file. [Host a file online](/recipes/host-file/) covers it.
diff --git a/src/docs/src/recipes/store-large-collection.md b/src/docs/src/recipes/store-large-collection.md
new file mode 100644
index 000000000..df6efae88
--- /dev/null
+++ b/src/docs/src/recipes/store-large-collection.md
@@ -0,0 +1,157 @@
+---
+title: Store a Large Collection
+description: "Learn how to store a large collection of data in the Puter.js key-value database. It fits any number of records and you can read or change one at a time."
+tags: [kv, data-modeling, performance]
+order: 30
+---
+
+You can keep an entire table of records in the Puter.js key-value database, such
+as every todo in an app or every order in a store. Each record gets its own key,
+and the key is the part you design up front. In a regular database you pick a
+primary key and an index. Here you do both in one string, since a full key reads
+one record and a key prefix reads a group of them.
+
+## Store a Record
+
+Give each record its own key and write it with the [`puter.kv.set()`](/KV/set/)
+method. Name the collection first and put the record id last:
+
+```js
+await puter.kv.set(`todo:${ id }`, { text: 'Buy milk', done: false });
+```
+
+The id works like a primary key. It has to be unique inside the collection, and
+it is how you reach that record later, so any unique string does, such as
+`crypto.randomUUID()` or a timestamp.
+
+## Read, Change or Delete a Record
+
+To read one record, use the [`puter.kv.get()`](/KV/get/) method with its full
+key. To change a field, use [`puter.kv.update()`](/KV/update/), and to replace
+the record, use [`puter.kv.set()`](/KV/set/) again. To remove it, use
+[`puter.kv.del()`](/KV/del/). None of them read or write the rest of the
+collection:
+
+```js
+const key = `todo:${ id }`;
+
+const todo = await puter.kv.get(key);
+await puter.kv.update(key, { done: true });
+await puter.kv.set(key, { text: 'Buy oat milk', done: false });
+await puter.kv.del(key);
+```
+
+## Read the Collection
+
+To read the records back, use the [`puter.kv.list()`](/KV/list/) method with the
+name of the collection. The `true` brings the values back with the keys, so one
+round trip both selects and loads:
+
+```js
+const rows = await puter.kv.list('todo:*', true);
+// [ { key: 'todo:a', value: { text: 'Buy milk', done: false } },
+// { key: 'todo:b', value: { text: 'Water plants', done: true } } ]
+```
+
+To narrow that down, filter the results in your own code:
+
+```js
+const open = rows.filter(row => ! row.value.done);
+```
+
+Every record is still sent to your app that way, and you throw most of them
+away.
+
+## Filter with a Key Prefix
+
+To have the database do the filtering, you can perform filter based on the key.
+The [`puter.kv.list()`](/KV/list/) method accepts a key prefix match, allowing
+you to only retrieve the records with the matched prefix. The tradeoff is that
+you design the key before you write any records:
+
+```js
+// todo::
+await puter.kv.set(`todo:${ category }:${ id }`, { text: 'Buy milk', done: false });
+```
+
+A prefix now reads one category, and only those records come back:
+
+```js
+const rows = await puter.kv.list(`todo:${ category }:*`, true);
+```
+
+Pick a field that actually partitions the data, such as a category, a project or
+a status.
+
+The pattern is prefix-only, with `*` allowed at the end, so a key gives you
+exactly one filter dimension. Decide which field needs the filter by prefix,
+since filtering on a different field later means rewriting every key you have
+already written.
+
+Records sort lexicographically by key, so zero-pad any number you want to sort
+numerically, as in `todo:home:000042`.
+
+## Page Through a Collection
+
+To read a large collection a page at a time, pass a `limit` and keep going until
+a page comes back with no `cursor`:
+
+```js
+let cursor;
+do {
+ const page = await puter.kv.list({
+ pattern: `todo:*`,
+ returnValues: true,
+ limit: 100,
+ cursor,
+ });
+ console.log(page.items);
+ cursor = page.cursor;
+} while ( cursor );
+```
+
+To show numbered pages instead, jump to a page with an `offset`:
+
+```js
+const pageSize = 20;
+const pageNumber = 3;
+
+const page = await puter.kv.list({
+ pattern: `todo:*`,
+ returnValues: true,
+ limit: pageSize,
+ offset: (pageNumber - 1) * pageSize,
+ includeTotal: true,
+});
+
+console.log(page.items);
+console.log(`Page ${ pageNumber } of ${ Math.ceil(page.total / pageSize) }`);
+```
+
+The `offset` skips the records on the pages before this one, and `includeTotal:
+true` adds a `total` to the page with the number of records matching the
+pattern, which is what gives you a page count. The largest offset allowed is
+5000.
+
+## Set a TTL
+
+To have a record delete itself later, set a TTL on it with the
+[`puter.kv.expire()`](/KV/expire/) method. It takes the key and a number of
+seconds:
+
+```js
+await puter.kv.expire(`todo:${ category }:${ id }`, 60 * 60 * 24);
+```
+
+A TTL applies to one key, so each record counts down on its own.
+
+## Notes
+
+- A [`puter.kv.list()`](/KV/list/) call with no pattern reads the entire store,
+ and every page is metered. Always pass a `pattern`, a `limit`, or both.
+- There is no secondary index, so a read path that needs a different filter
+ needs a different key layout. [Query a collection](/recipes/query-collection/)
+ covers the layouts that work, such as composite key segments and a second key
+ per read path.
+- For a list of a few hundred items that you always read all of, [store a small
+ list](/recipes/store-small-list/) is one read instead of a page at a time.
diff --git a/src/docs/src/recipes/store-server-side-data.md b/src/docs/src/recipes/store-server-side-data.md
new file mode 100644
index 000000000..902cc2544
--- /dev/null
+++ b/src/docs/src/recipes/store-server-side-data.md
@@ -0,0 +1,115 @@
+---
+title: Store Server-Side Data
+description: "Learn how to store data that every user of your app can read and update, instead of each user only accessing their own storage."
+tags: [workers, kv, auth]
+order: 40
+---
+
+Everything you store with [`puter.kv`](/KV/) or [`puter.fs`](/FS/) lives in the
+user's own account, which works differently than a traditional backend. If your app requires users reading and writing to the same data, use a [serverless worker](/Workers/) instead. This allows you to run server-side JavaScript code and you can have your users accessing the same data, which belongs to you as the developer.
+
+## Worker Context and User Context
+
+Inside a worker you get your own Puter context in the `me.puter` global object,
+so you can store data in your [key-value database](/KV/). That centralizes the
+data and keeps it server-side for your users.
+
+You also get the caller's Puter context in the `user.puter` parameter when the
+request is authenticated, which is how you get information about the user.
+
+Data only its owner reads has no reason to make this trip.
+[Store data](/recipes/store-data/) covers [`puter.kv`](/KV/) in app code, which
+is already per-user and costs no round trip through your worker.
+
+## Write a Record
+
+The handler writes to your own [key-value database](/KV/) through `me.puter`, so
+every player's row lands in the same store, and it uses `user.puter` to find out
+who is calling:
+
+```js
+// leaderboard.js
+const PREFIX = 'leaderboard:score:';
+
+router.post('/scores', async ({ request, user }) => {
+ if ( ! user ) {
+ return new Response('sign in required', { status: 401 });
+ }
+
+ const { uuid, username } = await user.puter.getUser();
+ const { score } = await request.json();
+ if ( typeof score !== 'number' ) {
+ return new Response('invalid score', { status: 400 });
+ }
+
+ await me.puter.kv.set(`${ PREFIX }${ uuid }`, { username, score, at: Date.now() });
+ return { username, score };
+});
+```
+
+The key is built from the caller's `uuid`, so a request can only ever touch its
+own row, whatever the request body says.
+
+For `user` to be there at all, your app has to call the worker with the
+[`puter.workers.exec()`](/Workers/exec/) method, which attaches the signed-in
+user's session:
+
+```js
+await puter.workers.exec(`${ API }/scores`, { method: 'POST', body });
+```
+
+A plain `fetch()` of the same URL arrives without a session, so the `! user`
+branch is how a route becomes sign-in-only. The last section covers the client
+side in full.
+
+## Read the Records
+
+To read the data back, use the same `me.puter` context and list it with the
+[`puter.kv.list()`](/KV/list/) method:
+
+```js
+router.get('/scores', async () => {
+ const rows = await me.puter.kv.list(PREFIX, true);
+ return rows
+ .map(row => row.value)
+ .sort((a, b) => b.score - a.score)
+ .slice(0, 20);
+});
+```
+
+A handler returning a plain object or array is sent as JSON. Read-all and
+write-own both fall out of the key, since the `GET` handler lists the prefix and
+the `POST` handler addresses exactly one key inside it. See
+[`router`](/Workers/router/) for the rest of the handler surface.
+
+## Deploy the Worker
+
+The worker is one file you write to your Puter account and create once, which
+gives it a permanent URL such as `https://leaderboard-api.puter.work`. See
+[deployment](/Workers/#deployment) for the steps.
+
+## Call It From Your App
+
+To call the worker with the signed-in user's session attached, use the
+[`puter.workers.exec()`](/Workers/exec/) method, which takes the same arguments
+as `fetch()`:
+
+```js
+const API = 'https://leaderboard-api.puter.work';
+
+// Submit this player's score
+const res = await puter.workers.exec(`${ API }/scores`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ score: 120 }),
+});
+await res.json(); // { username: 'grace', score: 120 }
+
+// Read the whole board
+const board = await (await puter.workers.exec(`${ API }/scores`)).json();
+// [ { username: 'ada', score: 450, at: 1788820000000 },
+// { username: 'grace', score: 120, at: 1788827048741 } ]
+```
+
+Every user of your app now reads and writes the same board, because every one of
+these requests lands on the same store.
diff --git a/src/docs/src/recipes/store-small-list.md b/src/docs/src/recipes/store-small-list.md
new file mode 100644
index 000000000..ac162ee27
--- /dev/null
+++ b/src/docs/src/recipes/store-small-list.md
@@ -0,0 +1,83 @@
+---
+title: Store a Small List
+description: "Learn how to keep a list of items, such as todos or notes, inside one key-value entry so you can retrieve data in a single read. It fits a few thousand small items."
+tags: [kv, data-modeling]
+order: 20
+---
+
+Most applications keep a list the user edits later, such as todos, notes, saved
+records or a task board. The whole list can live in one key-value entry, so a
+screen loads with a single [`puter.kv.get()`](/KV/get/) and there is nothing to
+page through.
+
+You can store the list as an object with an item id key. This lets you add,
+edit, and delete each item in one call without manually reading the entire list.
+
+## Add an Item
+
+To add an item, use the [`puter.kv.update()`](/KV/update/) method with the id as
+the path:
+
+```js
+const id = crypto.randomUUID();
+
+await puter.kv.update('todos', {
+ [id]: { text: 'Buy milk', done: false, at: Date.now() },
+});
+```
+
+The id becomes the key you reference later to update or delete that item. Any
+unique string works, and
+[`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID)
+is a safe default.
+
+## Show the List
+
+To load the list, use the [`puter.kv.get()`](/KV/get/) method. One read returns
+every item:
+
+```js
+const todos = await puter.kv.get('todos') ?? {};
+
+const items = Object.entries(todos)
+ .map(([id, todo]) => ({ id, ...todo }))
+ .sort((a, b) => a.at - b.at);
+```
+
+An object has no inherent order, and the stored field order is not preserved on
+read, so carry an `at` or `order` field on each item and sort when you render.
+At sizes that fit in one entry, sorting in memory costs nothing measurable.
+
+## Edit an Item
+
+To change one field of one item, use the [`puter.kv.update()`](/KV/update/)
+method with the item's id and the field you are changing:
+
+```js
+await puter.kv.update('todos', { [`${ id }.done`]: true });
+```
+
+This updates the specific property of the object with that id, without you
+having to manually iterate the whole list and update it.
+
+## Delete an Item
+
+To remove an item, use the [`puter.kv.remove()`](/KV/remove/) method with its
+id:
+
+```js
+await puter.kv.remove('todos', id);
+```
+
+It also takes several paths in one call, so `remove('todos', idA, idB)` deletes
+two items at once.
+
+## When to Switch
+
+One entry holds up to [400 KB](/KV/MAX_VALUE_SIZE/), which is a few thousand
+small items. If your list holds more than that, or you expect it to, [store a
+large collection](/recipes/store-large-collection/) instead, which gives you:
+
+- no ceiling on how many records you keep
+- an expiry per record, instead of one for the whole list
+- reads a page at a time, instead of the whole list on every render