diff --git a/.gitignore b/.gitignore
index 13785b0d5..3a50abf6e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,4 @@ wordpress/
# would only add transitive Dependabot alerts.
examples/next/block-support/package-lock.json
examples/next/tutorial/package-lock.json
+examples/next/document-store/package-lock.json
diff --git a/docs/how-to/write-data-to-wordpress/index.md b/docs/how-to/write-data-to-wordpress/index.md
index 6906318e3..af6de5b60 100644
--- a/docs/how-to/write-data-to-wordpress/index.md
+++ b/docs/how-to/write-data-to-wordpress/index.md
@@ -128,9 +128,115 @@ if (data.post.modified !== modifiedWhenLoaded) {
`fetchPolicy: "network-only"` skips Apollo's cache so you get the current value from WordPress.
-This narrows the window but doesn't close it: another save can still land between the check and your mutation. Built-in conflict detection is proposed in [#2562](https://github.com/wpengine/faustjs/issues/2562).
+This narrows the window but doesn't close it: another save can still land between the check and your mutation. For app data that several writers update, the FaustWP document store below rejects stale writes instead.
-## 5. Writing from the server
+## 5. Store app data with conflict detection
+
+The FaustWP plugin includes an optional document store for app data such as settings or structured drafts. Documents are grouped into collections, like `preferences` or `drafts`. Each document has an ID that's unique within its collection, a version number, and data that's a JSON object or array. Every write names the version it was based on, and WordPress rejects the write if someone else saved first.
+
+The store is off by default. Enable it with a filter in a small plugin or your theme's `functions.php`:
+
+```php
+add_filter( 'faust_enable_document_store', '__return_true' );
+```
+
+Documents live in their own database table, `wp_faust_documents` (with your site's table prefix), which FaustWP creates the first time WordPress loads with the store enabled. Deleting the FaustWP plugin deletes the table and its documents.
+
+Administrators can read and write documents. To give other users access, add the `read_faust_documents` capability to read, and both `read_faust_documents` and `edit_faust_documents` to write. These capabilities don't grant access to anything else in WordPress.
+
+The routes accept any WordPress REST API authentication, such as the [Application Password](#6-writing-from-the-server) described below.
+
+Read a document:
+
+```http
+GET /?rest_route=/faust/v1/collections/preferences/documents/site-settings
+```
+
+```json
+{
+ "collection": "preferences",
+ "id": "site-settings",
+ "version": 3,
+ "data": { "theme": "dark" },
+ "created": "2026-10-01T09:30:00Z",
+ "modified": "2026-10-20T15:04:05Z"
+}
+```
+
+`created` and `modified` are UTC timestamps.
+
+Write a document by sending the version you read. Use `0` to create a new document. A successful write returns the new version:
+
+```http
+PUT /?rest_route=/faust/v1/collections/preferences/documents/site-settings
+Content-Type: application/json
+
+{ "version": 3, "data": { "theme": "light" } }
+```
+
+If the document has changed since version 3, the write returns `409 Conflict` and doesn't change anything. The response includes the current document, so you can merge the changes or ask the user what to keep, then retry with the current version:
+
+```json
+{
+ "code": "faust_document_conflict",
+ "message": "The document has changed since the version this write was based on.",
+ "data": {
+ "status": 409,
+ "collection": "preferences",
+ "id": "site-settings",
+ "version": 4,
+ "document": { "collection": "preferences", "id": "site-settings", "version": 4, "data": { "theme": "blue" }, "created": "…", "modified": "…" }
+ }
+}
+```
+
+To create a document without choosing an ID, for example a form submission, `POST` to the collection. WordPress generates a random ID and returns the new document with `201 Created`:
+
+```http
+POST /?rest_route=/faust/v1/collections/waitlist/documents
+Content-Type: application/json
+
+{ "data": { "email": "ada@example.com" } }
+```
+
+List a collection's documents, newest first. Pass `limit` (1 to 100, default 20), and to get the next page, pass the `next_cursor` from the previous response as `cursor`. `next_cursor` is `null` on the last page:
+
+```http
+GET /?rest_route=/faust/v1/collections/waitlist/documents&limit=20
+```
+
+```json
+{ "documents": [ { "collection": "waitlist", "id": "3f9c2a7e1b0d4c6a8e5f", "version": 1, "data": { "email": "ada@example.com" }, "created": "…", "modified": "…" } ], "next_cursor": null }
+```
+
+Delete a document by sending the version you read. Like a write, a stale version returns `409 Conflict`, and a document that doesn't exist returns `404`:
+
+```http
+DELETE /?rest_route=/faust/v1/collections/waitlist/documents/3f9c2a7e1b0d4c6a8e5f&version=1
+```
+
+To run code when documents change, for example to send an email for each form submission, use the `faust_document_saved` and `faust_document_deleted` actions:
+
+```php
+add_action(
+ 'faust_document_saved',
+ function ( $document, $created ) {
+ if ( $created && 'waitlist' === $document['collection'] ) {
+ wp_mail( get_option( 'admin_email' ), 'New waitlist signup', $document['data']->email );
+ }
+ },
+ 10,
+ 2
+);
+```
+
+Collection names and document IDs use lowercase letters, numbers and hyphens. A collection exists as soon as it has a document. Documents are limited to 100 KB of JSON by default, which you can change with the `faust_document_max_bytes` filter.
+
+To see what your app has stored, go to **Tools → Faust Documents** in WordPress. It lists each document with its collection, version and when and by whom it was last saved, and shows a document's data. The screen is read-only.
+
+To try it locally, see the [document store example](https://github.com/wpengine/faustjs/tree/canary/examples/next/document-store). It runs WordPress in Docker, shows a conflict between two browser tabs, and has a waitlist page that adds, lists and removes documents.
+
+## 6. Writing from the server
The flow above runs in the browser on behalf of a logged-in user. Faust.js doesn't yet provide a supported way for server-side code (a build step, a cron job, an API route acting on its own) to authenticate to WordPress.
diff --git a/examples/next/document-store/.env.local.sample b/examples/next/document-store/.env.local.sample
new file mode 100644
index 000000000..34c662f70
--- /dev/null
+++ b/examples/next/document-store/.env.local.sample
@@ -0,0 +1,8 @@
+# Your WordPress site URL
+NEXT_PUBLIC_WORDPRESS_URL=http://localhost:8090
+
+# A WordPress user that can read and write documents, and an Application
+# Password for it. `npm run wordpress:setup` creates both and prints the values.
+# These stay on the server: never prefix them with NEXT_PUBLIC_.
+WP_SERVICE_USER=document-app
+WP_SERVICE_APP_PASSWORD=
diff --git a/examples/next/document-store/README.md b/examples/next/document-store/README.md
new file mode 100644
index 000000000..c56026979
--- /dev/null
+++ b/examples/next/document-store/README.md
@@ -0,0 +1,52 @@
+# Document store example
+
+A small Next.js app that uses the [FaustWP document store](https://faustjs.org/docs/how-to/write-data-to-wordpress/). The **Team notes** page edits a shared document and shows what happens when two people save at the same time. The **Waitlist** page adds, lists and removes documents in a collection.
+
+The app's API route talks to WordPress with an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/), so the credentials stay on the server. It doesn't use Faust.js login: every visitor edits the document as the same WordPress user.
+
+## Run it locally
+
+You need Docker and Node.js.
+
+1. Start WordPress with FaustWP and the document store enabled:
+
+ ```bash
+ npm run wordpress:setup
+ ```
+
+ This runs WordPress on `http://localhost:8090` (log in with `admin` / `password`), creates a user that can only read and write documents, and prints the values for the next step. Set `WP_PORT` or `DB_PORT` to use other ports, or `WP_VERSION` to pick a WordPress version.
+
+2. Copy `.env.local.sample` to `.env.local` and paste in the printed values.
+
+3. Install and start the app:
+
+ ```bash
+ npm install
+ npm run dev
+ ```
+
+4. Open `http://localhost:3000` in two tabs and try a few conflicts. The first save in either tab always works. The second is rejected by WordPress with `409 Conflict`, because it's based on a version that's no longer current, and the page decides what to do:
+ - **No edits in the second tab:** it loads the newer version.
+ - **The tabs changed different fields** (for example, notes in one and status in the other): it merges both changes and saves a new version.
+ - **The tabs changed the same field:** it lists just those fields with the earlier value, theirs and yours. Choose what to keep for each and click **Save merged**. Everything else is already merged.
+
+5. Open `http://localhost:3000/waitlist` and add a few emails. Each one becomes a document with an ID WordPress generates, the list shows five at a time, newest first, and **Remove** deletes a signup.
+
+Stop WordPress with `npm run wordpress:stop`. Add `-- --volumes` to also delete its database.
+
+## Working on the document store
+
+WordPress loads FaustWP straight from `plugins/faustwp` in this repository, so changes to the plugin take effect on the next request without restarting anything. To run WP-CLI against the site, for example to inspect a document:
+
+```bash
+docker compose --project-name faust-document-store exec wordpress wp --allow-root eval 'global $wpdb; print_r( $wpdb->get_results( "SELECT collection, document_id, version, modified_gmt FROM {$wpdb->prefix}faust_documents" ) );'
+```
+
+## How it works
+
+- `mu-plugins/enable-document-store.php` is a must-use plugin that turns on the document store with the `faust_enable_document_store` filter.
+- `lib/wordpress.js` forwards requests to `/?rest_route=/faust/v1/...` with the Application Password, and passes WordPress's status code back: `404` before a document exists, `201` when one is created, `200` when it's updated or deleted and `409` for a stale write or delete.
+- `pages/api/collections/[collection]/documents/[id].js` handles `GET`, `PUT` and `DELETE` for one document, and `pages/api/collections/[collection]/documents/index.js` handles `GET` (list) and `POST` (add with a generated ID) for a collection.
+- `pages/waitlist.js` adds signups with `POST`, pages through the list with `next_cursor`, and removes a signup by sending the version it was listed at.
+- `pages/index.js` keeps the version it last read and sends it with every save. On a `409`, the response includes the current document, and the page merges it with the local edits.
+- `lib/mergeDocuments.mjs` is the three-way merge: it compares the version the tab started from with theirs and yours, field by field, and reports fields both sides changed differently. Run its tests with `npm test`.
diff --git a/examples/next/document-store/docker-compose.override.yml b/examples/next/document-store/docker-compose.override.yml
new file mode 100644
index 000000000..1bbb10590
--- /dev/null
+++ b/examples/next/document-store/docker-compose.override.yml
@@ -0,0 +1,13 @@
+# Used with plugins/faustwp/docker-compose.yml by scripts/setup-wordpress.sh.
+# Paths are relative to plugins/faustwp, the directory of the first compose file.
+services:
+ wordpress:
+ environment:
+ # WordPress only allows Application Passwords over HTTPS or on local sites.
+ WP_ENVIRONMENT_TYPE: local
+ volumes:
+ - ../../examples/next/document-store/mu-plugins/enable-document-store.php:/var/www/html/wp-content/mu-plugins/enable-document-store.php
+ db:
+ volumes:
+ # Keep the database when `npm run wordpress:stop` removes the container.
+ - db:/var/lib/mysql
diff --git a/examples/next/document-store/lib/mergeDocuments.mjs b/examples/next/document-store/lib/mergeDocuments.mjs
new file mode 100644
index 000000000..c3a0631de
--- /dev/null
+++ b/examples/next/document-store/lib/mergeDocuments.mjs
@@ -0,0 +1,123 @@
+// Three-way merge for plain JSON objects.
+//
+// `base` is the version both sides started from, `theirs` is what's stored now,
+// and `yours` is the local edit. Fields only one side changed are merged
+// automatically. Fields both sides changed to different values are returned as
+// conflicts, with the merged result keeping theirs until someone decides.
+//
+// Nested plain objects are merged field by field. Arrays and other values are
+// compared as a whole.
+
+function isPlainObject(value) {
+ return (
+ value !== null &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ Object.getPrototypeOf(value) === Object.prototype
+ );
+}
+
+export function deepEqual(a, b) {
+ if (a === b) {
+ return true;
+ }
+ if (Array.isArray(a) && Array.isArray(b)) {
+ return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
+ }
+ if (isPlainObject(a) && isPlainObject(b)) {
+ const keys = Object.keys(a);
+ return (
+ keys.length === Object.keys(b).length &&
+ keys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]))
+ );
+ }
+ return false;
+}
+
+// A missing key and a key set to undefined both mean "not there".
+const MISSING = undefined;
+
+function mergeValue(base, theirs, yours, path, conflicts) {
+ const theyChanged = !deepEqual(base, theirs);
+ const youChanged = !deepEqual(base, yours);
+
+ if (!youChanged) {
+ return theirs;
+ }
+ if (!theyChanged || deepEqual(theirs, yours)) {
+ return yours;
+ }
+ if (isPlainObject(base) && isPlainObject(theirs) && isPlainObject(yours)) {
+ return mergeObjects(base, theirs, yours, path, conflicts);
+ }
+
+ conflicts.push({ path, base, theirs, yours });
+ return theirs;
+}
+
+function mergeObjects(base, theirs, yours, path, conflicts) {
+ const merged = {};
+ const keys = new Set([
+ ...Object.keys(base),
+ ...Object.keys(theirs),
+ ...Object.keys(yours),
+ ]);
+
+ for (const key of keys) {
+ const value = mergeValue(
+ Object.hasOwn(base, key) ? base[key] : MISSING,
+ Object.hasOwn(theirs, key) ? theirs[key] : MISSING,
+ Object.hasOwn(yours, key) ? yours[key] : MISSING,
+ [...path, key],
+ conflicts,
+ );
+ if (value !== MISSING) {
+ // defineProperty rather than assignment, so a "__proto__" key from
+ // the JSON becomes a field instead of changing the prototype.
+ Object.defineProperty(merged, key, {
+ value,
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ });
+ }
+ }
+
+ return merged;
+}
+
+/**
+ * @param {object} base The document both sides started from.
+ * @param {object} theirs The document as it's stored now.
+ * @param {object} yours The local edit.
+ * @returns {{ merged: object, conflicts: Array<{ path: string[], base: any, theirs: any, yours: any }> }}
+ */
+export function mergeDocuments(base, theirs, yours) {
+ const conflicts = [];
+ const merged = mergeObjects(base ?? {}, theirs ?? {}, yours ?? {}, [], conflicts);
+
+ return { merged, conflicts };
+}
+
+/**
+ * Return a copy of `object` with the value at `path` replaced.
+ *
+ * @param {object} object
+ * @param {string[]} path
+ * @param {any} value Use undefined to remove the key.
+ * @returns {object}
+ */
+export function setAtPath(object, path, value) {
+ const [key, ...rest] = path;
+ // A computed key defines an own property, even for "__proto__", so the
+ // assignments below write a field instead of changing the prototype.
+ const copy = { ...object, [key]: undefined };
+ if (rest.length) {
+ copy[key] = setAtPath(isPlainObject(object[key]) ? object[key] : {}, rest, value);
+ } else if (value === MISSING) {
+ delete copy[key];
+ } else {
+ copy[key] = value;
+ }
+ return copy;
+}
diff --git a/examples/next/document-store/lib/mergeDocuments.test.mjs b/examples/next/document-store/lib/mergeDocuments.test.mjs
new file mode 100644
index 000000000..f51a80620
--- /dev/null
+++ b/examples/next/document-store/lib/mergeDocuments.test.mjs
@@ -0,0 +1,141 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { deepEqual, mergeDocuments, setAtPath } from './mergeDocuments.mjs';
+
+const base = { title: 'Launch', notes: 'Draft', status: 'draft' };
+
+test('takes their change when you left the field alone', () => {
+ const { merged, conflicts } = mergeDocuments(base, { ...base, title: 'Launch v2' }, base);
+
+ assert.deepEqual(merged, { ...base, title: 'Launch v2' });
+ assert.deepEqual(conflicts, []);
+});
+
+test('keeps your change when they left the field alone', () => {
+ const { merged, conflicts } = mergeDocuments(base, base, { ...base, notes: 'Mine' });
+
+ assert.deepEqual(merged, { ...base, notes: 'Mine' });
+ assert.deepEqual(conflicts, []);
+});
+
+test('merges changes to different fields', () => {
+ const { merged, conflicts } = mergeDocuments(
+ base,
+ { ...base, title: 'Theirs' },
+ { ...base, status: 'done' },
+ );
+
+ assert.deepEqual(merged, { title: 'Theirs', notes: 'Draft', status: 'done' });
+ assert.deepEqual(conflicts, []);
+});
+
+test('accepts the same change on both sides', () => {
+ const { merged, conflicts } = mergeDocuments(
+ base,
+ { ...base, status: 'done' },
+ { ...base, status: 'done' },
+ );
+
+ assert.equal(merged.status, 'done');
+ assert.deepEqual(conflicts, []);
+});
+
+test('reports different changes to the same field and keeps theirs', () => {
+ const { merged, conflicts } = mergeDocuments(
+ base,
+ { ...base, notes: 'Theirs' },
+ { ...base, notes: 'Yours', title: 'Also yours' },
+ );
+
+ assert.deepEqual(merged, { title: 'Also yours', notes: 'Theirs', status: 'draft' });
+ assert.deepEqual(conflicts, [
+ { path: ['notes'], base: 'Draft', theirs: 'Theirs', yours: 'Yours' },
+ ]);
+});
+
+test('merges nested objects field by field', () => {
+ const nestedBase = { meta: { color: 'red', size: 1 } };
+ const { merged, conflicts } = mergeDocuments(
+ nestedBase,
+ { meta: { color: 'blue', size: 1 } },
+ { meta: { color: 'red', size: 2 } },
+ );
+
+ assert.deepEqual(merged, { meta: { color: 'blue', size: 2 } });
+ assert.deepEqual(conflicts, []);
+});
+
+test('reports nested conflicts with their full path', () => {
+ const { conflicts } = mergeDocuments(
+ { meta: { color: 'red' } },
+ { meta: { color: 'blue' } },
+ { meta: { color: 'green' } },
+ );
+
+ assert.deepEqual(conflicts, [
+ { path: ['meta', 'color'], base: 'red', theirs: 'blue', yours: 'green' },
+ ]);
+});
+
+test('treats arrays as single values', () => {
+ const { merged, conflicts } = mergeDocuments(
+ { tags: ['a'] },
+ { tags: ['a', 'b'] },
+ { tags: ['a', 'c'] },
+ );
+
+ assert.deepEqual(merged, { tags: ['a', 'b'] });
+ assert.equal(conflicts.length, 1);
+ assert.deepEqual(conflicts[0].path, ['tags']);
+});
+
+test('handles added and removed keys', () => {
+ const { merged, conflicts } = mergeDocuments(
+ { keep: 1, drop: 2 },
+ { keep: 1, drop: 2, added: 'theirs' },
+ { keep: 1 },
+ );
+
+ assert.deepEqual(merged, { keep: 1, added: 'theirs' });
+ assert.deepEqual(conflicts, []);
+});
+
+test('reports removing a field they changed as a conflict', () => {
+ const { merged, conflicts } = mergeDocuments({ a: 1 }, { a: 2 }, {});
+
+ assert.deepEqual(merged, { a: 2 });
+ assert.deepEqual(conflicts, [{ path: ['a'], base: 1, theirs: 2, yours: undefined }]);
+});
+
+test('deepEqual compares objects and arrays by value', () => {
+ assert.ok(deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }));
+ assert.ok(!deepEqual({ a: 1 }, { a: 1, b: undefined }));
+ assert.ok(!deepEqual([1, 2], [2, 1]));
+});
+
+test('setAtPath replaces and removes nested values without mutating', () => {
+ const original = { meta: { color: 'red' }, title: 'x' };
+
+ assert.deepEqual(setAtPath(original, ['meta', 'color'], 'blue'), {
+ meta: { color: 'blue' },
+ title: 'x',
+ });
+ assert.deepEqual(setAtPath(original, ['title'], undefined), { meta: { color: 'red' } });
+ assert.deepEqual(original, { meta: { color: 'red' }, title: 'x' });
+});
+
+test('keeps a "__proto__" field from the JSON as a field', () => {
+ const doc = JSON.parse('{"__proto__": {"x": 1}, "title": "a"}');
+ const { merged } = mergeDocuments(doc, doc, { ...doc, title: 'b' });
+
+ assert.equal(Object.getPrototypeOf(merged), Object.prototype);
+ assert.equal(JSON.stringify(merged), '{"__proto__":{"x":1},"title":"b"}');
+});
+
+test('setAtPath writes a "__proto__" field instead of changing the prototype', () => {
+ const result = setAtPath({ title: 'a' }, ['__proto__'], { x: 1 });
+
+ assert.equal(Object.getPrototypeOf(result), Object.prototype);
+ assert.equal(JSON.stringify(result), '{"title":"a","__proto__":{"x":1}}');
+});
diff --git a/examples/next/document-store/lib/wordpress.js b/examples/next/document-store/lib/wordpress.js
new file mode 100644
index 000000000..263be9a59
--- /dev/null
+++ b/examples/next/document-store/lib/wordpress.js
@@ -0,0 +1,65 @@
+// Forwards requests to the FaustWP document store, authenticating with an
+// Application Password so the credentials never reach the browser.
+
+export const COLLECTION_PATTERN = /^[a-z0-9-]{1,64}$/;
+export const ID_PATTERN = /^[a-z0-9-]{1,190}$/;
+const NUMBER_PATTERN = /^[0-9]+$/;
+
+// Builds a query string from the numeric parameters WordPress accepts, such
+// as limit, cursor and version, and drops anything else.
+export function numericQuery(query, names) {
+ return names
+ .filter((name) => NUMBER_PATTERN.test(query[name] ?? ''))
+ .map((name) => `&${name}=${query[name]}`)
+ .join('');
+}
+
+export function rejectMethod(req, res, allowed) {
+ if (allowed.includes(req.method)) {
+ return false;
+ }
+ res.setHeader('Allow', allowed.join(', '));
+ res.status(405).json({ message: 'Method not allowed.' });
+ return true;
+}
+
+export async function forwardToWordPress(req, res, path, query = '') {
+ const { NEXT_PUBLIC_WORDPRESS_URL, WP_SERVICE_USER, WP_SERVICE_APP_PASSWORD } =
+ process.env;
+ if (!NEXT_PUBLIC_WORDPRESS_URL || !WP_SERVICE_USER || !WP_SERVICE_APP_PASSWORD) {
+ return res.status(500).json({
+ message:
+ 'Set NEXT_PUBLIC_WORDPRESS_URL, WP_SERVICE_USER and WP_SERVICE_APP_PASSWORD in .env.local.',
+ });
+ }
+
+ const credentials = Buffer.from(
+ `${WP_SERVICE_USER}:${WP_SERVICE_APP_PASSWORD}`,
+ ).toString('base64');
+ const hasBody = req.method === 'PUT' || req.method === 'POST';
+
+ let response;
+ try {
+ response = await fetch(
+ `${NEXT_PUBLIC_WORDPRESS_URL}/?rest_route=/faust/v1/${path}${query}`,
+ {
+ method: req.method,
+ headers: {
+ Authorization: `Basic ${credentials}`,
+ 'Content-Type': 'application/json',
+ },
+ body: hasBody ? JSON.stringify(req.body) : undefined,
+ },
+ );
+ } catch (error) {
+ return res
+ .status(502)
+ .json({ message: `Could not reach WordPress: ${error.message}` });
+ }
+
+ // Pass WordPress's status and body through, including 404 for a document
+ // that doesn't exist yet and 409 for a stale write.
+ res.status(response.status);
+ res.setHeader('Content-Type', 'application/json');
+ return res.send(await response.text());
+}
diff --git a/examples/next/document-store/mu-plugins/enable-document-store.php b/examples/next/document-store/mu-plugins/enable-document-store.php
new file mode 100644
index 000000000..c1568e9a4
--- /dev/null
+++ b/examples/next/document-store/mu-plugins/enable-document-store.php
@@ -0,0 +1,7 @@
+=20.9",
+ "npm": ">=8"
+ }
+}
diff --git a/examples/next/document-store/pages/_app.js b/examples/next/document-store/pages/_app.js
new file mode 100644
index 000000000..efb4e5993
--- /dev/null
+++ b/examples/next/document-store/pages/_app.js
@@ -0,0 +1,5 @@
+import '../styles/globals.css';
+
+export default function App({ Component, pageProps }) {
+ return ;
+}
diff --git a/examples/next/document-store/pages/api/collections/[collection]/documents/[id].js b/examples/next/document-store/pages/api/collections/[collection]/documents/[id].js
new file mode 100644
index 000000000..08eb8cf98
--- /dev/null
+++ b/examples/next/document-store/pages/api/collections/[collection]/documents/[id].js
@@ -0,0 +1,30 @@
+// Reads, writes and deletes one document.
+
+import {
+ COLLECTION_PATTERN,
+ ID_PATTERN,
+ forwardToWordPress,
+ numericQuery,
+ rejectMethod,
+} from '../../../../../lib/wordpress';
+
+export default async function handler(req, res) {
+ if (rejectMethod(req, res, ['GET', 'PUT', 'DELETE'])) {
+ return;
+ }
+
+ const { collection, id } = req.query;
+ if (!COLLECTION_PATTERN.test(collection) || !ID_PATTERN.test(id)) {
+ return res
+ .status(400)
+ .json({ message: 'Use lowercase letters, numbers and hyphens.' });
+ }
+
+ // A delete sends the version it's based on as a query parameter.
+ return forwardToWordPress(
+ req,
+ res,
+ `collections/${collection}/documents/${id}`,
+ numericQuery(req.query, ['version']),
+ );
+}
diff --git a/examples/next/document-store/pages/api/collections/[collection]/documents/index.js b/examples/next/document-store/pages/api/collections/[collection]/documents/index.js
new file mode 100644
index 000000000..3699d6cb8
--- /dev/null
+++ b/examples/next/document-store/pages/api/collections/[collection]/documents/index.js
@@ -0,0 +1,28 @@
+// Lists a collection's documents and adds documents with generated IDs.
+
+import {
+ COLLECTION_PATTERN,
+ forwardToWordPress,
+ numericQuery,
+ rejectMethod,
+} from '../../../../../lib/wordpress';
+
+export default async function handler(req, res) {
+ if (rejectMethod(req, res, ['GET', 'POST'])) {
+ return;
+ }
+
+ const { collection } = req.query;
+ if (!COLLECTION_PATTERN.test(collection)) {
+ return res
+ .status(400)
+ .json({ message: 'Use lowercase letters, numbers and hyphens.' });
+ }
+
+ return forwardToWordPress(
+ req,
+ res,
+ `collections/${collection}/documents`,
+ numericQuery(req.query, ['limit', 'cursor']),
+ );
+}
diff --git a/examples/next/document-store/pages/index.js b/examples/next/document-store/pages/index.js
new file mode 100644
index 000000000..233fee809
--- /dev/null
+++ b/examples/next/document-store/pages/index.js
@@ -0,0 +1,276 @@
+import Link from 'next/link';
+import { useEffect, useState } from 'react';
+import { deepEqual, mergeDocuments, setAtPath } from '../lib/mergeDocuments.mjs';
+
+const COLLECTION = 'notes';
+const DOCUMENT_ID = 'team';
+const EMPTY_NOTES = { title: '', notes: '', status: 'draft' };
+const FIELDS = [
+ { name: 'title', label: 'Title' },
+ { name: 'notes', label: 'Notes' },
+ { name: 'status', label: 'Status' },
+];
+// How many times a save merges with a newer version and retries before
+// asking the user, in case other tabs keep saving in between.
+const MAX_AUTO_MERGES = 2;
+
+async function request(method, body) {
+ const response = await fetch(`/api/collections/${COLLECTION}/documents/${DOCUMENT_ID}`, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const text = await response.text();
+ try {
+ return { status: response.status, json: JSON.parse(text) };
+ } catch {
+ // WordPress can answer with an HTML error page, for example on a fatal error.
+ return { status: response.status, json: { message: text.slice(0, 200) } };
+ }
+}
+
+function withDefaults(data) {
+ return { ...EMPTY_NOTES, ...data };
+}
+
+// The form label for a conflicting field, or its path for anything else.
+function fieldLabel(path) {
+ const field = path.length === 1 && FIELDS.find(({ name }) => name === path[0]);
+ return field ? field.label : path.join('.');
+}
+
+function formatValue(value) {
+ return value === undefined ? '(removed)' : JSON.stringify(value);
+}
+
+export default function Home() {
+ // The version and data last read from or saved to WordPress. Edits in the
+ // form are based on this, so it's the base for merging.
+ const [saved, setSaved] = useState(null);
+ // What's in the form now.
+ const [draft, setDraft] = useState(EMPTY_NOTES);
+ // Fields both you and someone else changed, while waiting for a decision.
+ const [conflict, setConflict] = useState(null);
+ const [message, setMessage] = useState('Loading...');
+ const [saving, setSaving] = useState(false);
+
+ function showDocument(document) {
+ setSaved({ ...document, data: withDefaults(document.data) });
+ setDraft(withDefaults(document.data));
+ setConflict(null);
+ }
+
+ async function load() {
+ const { status, json } = await request('GET');
+ if (status === 200) {
+ showDocument(json);
+ setMessage(`Loaded version ${json.version}.`);
+ } else if (status === 404) {
+ showDocument({
+ collection: COLLECTION,
+ id: DOCUMENT_ID,
+ version: 0,
+ data: EMPTY_NOTES,
+ });
+ setMessage('No document yet. Saving creates it.');
+ } else {
+ setMessage(`Could not load (${status}): ${json.message}`);
+ }
+ }
+
+ // Save `data` on top of `version`. On a conflict, `base` is the version the
+ // data was edited from, used to work out who changed what.
+ async function save(version, data, base, merges = 0) {
+ setSaving(true);
+ const { status, json } = await request('PUT', { version, data });
+ setSaving(false);
+
+ if (status === 200 || status === 201) {
+ showDocument(json);
+ return { ok: true, version: json.version };
+ }
+ if (status !== 409) {
+ setMessage(`Could not save (${status}): ${json.message}`);
+ return { ok: false };
+ }
+
+ // The document is null if someone else deleted it, so take the version
+ // (0 in that case) from the conflict itself.
+ const theirs = {
+ collection: COLLECTION,
+ id: DOCUMENT_ID,
+ ...json.data.document,
+ version: json.data.version,
+ data: withDefaults(json.data.document?.data),
+ };
+
+ // Nothing to keep from this tab, so just catch up.
+ if (deepEqual(data, base)) {
+ showDocument(theirs);
+ setMessage(`Updated to version ${theirs.version} from another tab.`);
+ return { ok: false };
+ }
+
+ const { merged, conflicts } = mergeDocuments(base, theirs.data, data);
+
+ if (conflicts.length === 0 && merges < MAX_AUTO_MERGES) {
+ const result = await save(theirs.version, merged, theirs.data, merges + 1);
+ if (result.ok && !result.merged) {
+ setMessage(
+ `Merged your changes with version ${theirs.version} and saved version ${result.version}.`,
+ );
+ }
+ return { ...result, merged: true };
+ }
+
+ setSaved(theirs);
+ setDraft(merged);
+ setConflict({
+ theirs,
+ merged,
+ fields: conflicts.map((item) => ({ ...item, choice: 'theirs' })),
+ });
+ setMessage(
+ conflicts.length
+ ? `Version ${theirs.version} changed the same fields you did. Choose what to keep.`
+ : `Version ${theirs.version} keeps changing. Review the merged result and save again.`,
+ );
+ return { ok: false };
+ }
+
+ function choose(index, choice) {
+ setConflict({
+ ...conflict,
+ fields: conflict.fields.map((field, i) => (i === index ? { ...field, choice } : field)),
+ });
+ }
+
+ async function saveMerged() {
+ const data = conflict.fields.reduce(
+ (result, field) =>
+ setAtPath(result, field.path, field.choice === 'yours' ? field.yours : field.theirs),
+ conflict.merged,
+ );
+ const result = await save(conflict.theirs.version, data, conflict.theirs.data);
+ if (result.ok && !result.merged) {
+ setMessage(`Saved version ${result.version} with your choices.`);
+ }
+ }
+
+ useEffect(() => {
+ load();
+ }, []);
+
+ function update(field) {
+ return (event) => setDraft({ ...draft, [field]: event.target.value });
+ }
+
+ // Fields with edits that aren't saved yet.
+ const changed = new Set(
+ saved ? FIELDS.filter(({ name }) => draft[name] !== saved.data[name]).map(({ name }) => name) : [],
+ );
+ const labelClass = (name) => (changed.has(name) ? 'changed' : undefined);
+
+ return (
+
+
Team notes
+
+ Waitlist example →
+
+
+ Open this page in two tabs and save in both. If the tabs changed
+ different fields, the second save merges them. If they changed the
+ same field, you choose which value to keep.
+
+
+
+ Version: {saved ? saved.version : '...'}
+
+
+
+
+
{message}
+
+ {conflict && (
+
+
Choose what to keep
+
+ Changes to other fields are already merged. These fields were
+ changed in version {conflict.theirs.version} and in this tab.
+
+
+ )}
+
+ );
+}
diff --git a/examples/next/document-store/pages/waitlist.js b/examples/next/document-store/pages/waitlist.js
new file mode 100644
index 000000000..1861eeadf
--- /dev/null
+++ b/examples/next/document-store/pages/waitlist.js
@@ -0,0 +1,141 @@
+import Link from 'next/link';
+import { useEffect, useState } from 'react';
+
+const COLLECTION_URL = '/api/collections/waitlist/documents';
+const PAGE_SIZE = 5;
+
+async function request(method, url, body) {
+ const response = await fetch(url, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const text = await response.text();
+ try {
+ return { status: response.status, json: JSON.parse(text) };
+ } catch {
+ // WordPress can answer with an HTML error page, for example on a fatal error.
+ return { status: response.status, json: { message: text.slice(0, 200) } };
+ }
+}
+
+export default function Waitlist() {
+ const [signups, setSignups] = useState([]);
+ const [nextCursor, setNextCursor] = useState(null);
+ const [email, setEmail] = useState('');
+ const [message, setMessage] = useState('Loading...');
+ const [busy, setBusy] = useState(false);
+
+ // Loads the first page, or the page after `cursor`.
+ async function load(cursor) {
+ const query = cursor ? `&cursor=${cursor}` : '';
+ // Block adding and removing until the page arrives, so the list can't
+ // get out of step with WordPress.
+ setBusy(true);
+ const { status, json } = await request('GET', `${COLLECTION_URL}?limit=${PAGE_SIZE}${query}`);
+ setBusy(false);
+ if (status !== 200) {
+ setMessage(`Could not load (${status}): ${json.message}`);
+ return;
+ }
+ setSignups((current) => (cursor ? [...current, ...json.documents] : json.documents));
+ setNextCursor(json.next_cursor);
+ setMessage(json.documents.length || cursor ? '' : 'No signups yet.');
+ }
+
+ async function add(event) {
+ event.preventDefault();
+ setBusy(true);
+ // POST to the collection, and WordPress picks the document ID.
+ const { status, json } = await request('POST', COLLECTION_URL, { data: { email } });
+ setBusy(false);
+ if (status !== 201) {
+ setMessage(`Could not add (${status}): ${json.message}`);
+ return;
+ }
+ setEmail('');
+ setSignups((current) => [json, ...current]);
+ setMessage(`Added ${json.data.email} as ${json.id}.`);
+ }
+
+ async function remove(signup) {
+ setBusy(true);
+ // A delete names the version it's based on, like a write.
+ const { status, json } = await request(
+ 'DELETE',
+ `${COLLECTION_URL}/${signup.id}?version=${signup.version}`,
+ );
+ setBusy(false);
+ if (status === 200) {
+ setSignups((current) => current.filter(({ id }) => id !== signup.id));
+ setMessage(`Removed ${signup.data.email}.`);
+ } else if (status === 409 || status === 404) {
+ setMessage('That signup changed or was removed somewhere else. Reloaded the list.');
+ load();
+ } else {
+ setMessage(`Could not remove (${status}): ${json.message}`);
+ }
+ }
+
+ useEffect(() => {
+ load();
+ }, []);
+
+ return (
+
+
+ ← Team notes
+
+
Waitlist
+
+ Each signup is a document in the waitlist collection.
+ Adding one lets WordPress generate its ID, the list pages through
+ the collection newest first, and removing one sends the version it
+ was based on.
+
',
+ esc_html__( 'No documents yet. Your app creates a document the first time it saves one through the document store API.', 'faustwp' ),
+ esc_url( 'https://faustjs.org/docs/how-to/write-data-to-wordpress/' ),
+ esc_html__( 'Learn how to write data to WordPress.', 'faustwp' )
+ );
+ return;
+ }
+
+ echo '