Save form data from HTML without a backend: Page data
A page you publish on BrewPage can keep what its visitors enter: form answers, guestbook messages, votes, quiz results, game progress. The page loads one script. There is no server to run and no key in the code. This guide shows the code, how to test it on your computer and how the nine templates use it.
Start from a templateSave form data from HTML without a backend
- 1The page loads
https://brewpage.app/js/state.jsand callsbp.state()to save and read entries. - 2You publish it with a
statefield that names the lists it keeps. The page gets its own address, where saving works. - 3Visitors save. You read every entry in the page's Data tab, as CSV or through the API.
Each visitor reads and writes their own entries. A list you make shared is read by everyone, and you can make a list that only you write: who reads and writes.
Page data is for what visitors type into a published page. To publish data yourself from a script or an agent, use JSON documents or a key-value store with the owner token instead.
Quick start
1. Load the script with this exact tag, before your own code:
<script src="https://brewpage.app/js/state.js"></script>
2. Write the page. This one asks one question, keeps each visitor's answer and shows it again on return:
page.html
<!DOCTYPE html>
<meta charset="utf-8">
<title>One question</title>
<form id="ask">
<input name="answer" required placeholder="Your answer">
<button id="send" disabled>Send</button>
</form>
<p id="note"></p>
<p>What you enter is saved on BrewPage and can be read by this page's author.</p>
<script src="https://brewpage.app/js/state.js"></script>
<script>
async function start() {
const form = document.getElementById("ask");
const note = document.getElementById("note");
const s = await bp.state();
if (!s) { form.hidden = true; note.textContent = "Saving is not available on this page right now."; return; }
const answers = s.col("answers");
const mine = await answers.get("me");
if (mine) form.elements.answer.value = String(mine.answer);
document.getElementById("send").disabled = false;
form.addEventListener("submit", async (e) => {
e.preventDefault();
try {
await answers.set("me", { answer: form.elements.answer.value });
note.textContent = "Saved.";
} catch (err) {
note.textContent = err.message;
}
});
}
start();
</script>
Open the file in your browser to try it before publishing: see local preview.
3. Publish it with a JSON body that carries state. {} is enough here: every list is then each visitor's own. A raw HTML body cannot carry state.
Publish page.html with page data on
jq -n --rawfile html page.html '{content: $html, format: "html", state: {}}' \
| curl -X POST https://brewpage.app/api/html \
-H "Content-Type: application/json" \
--data-binary @-
The JSON answer holds two links. link is the page to share, on its own address such as https://abc123.brewpage.app. manageUrl is the management link: keep it private, it opens the page's settings and the Data tab with what visitors saved. Keep ownerToken too if you update the page through the API.
Rather use the browser? Paste the page at brewpage.app and turn on Page data in the editor's options. Every state option: API → Say what the page saves.
API by task
Find what your page needs, declare the list as shown, then use the calls. Full signatures and answers: API → Load the script.
| Task | List rule in state | Calls | See |
|---|---|---|---|
| One answer per visitor: a form, a sign-up, a quiz | None needed: lists are each visitor's own | c.set("me", value), c.get("me") | Sign-up form, quiz |
| Count choices for everyone: a poll, an RSVP, a rating | choices with the allowed values of a field | c.set("me", value), c.stats({countBy}) | Poll, RSVP, feedback |
| A list everyone reads: a guestbook, who brings what | "mode": "shared", often with maxDocsPerVisitor | c.add(value), c.list({order, limit}), c.remove(key) | Guestbook, bring list |
| Save progress: a game, a draft, settings | None needed | c.set(key, value), c.get(key); or bp.storage like localStorage | Clicker game |
| Top scores | "mode": "shared" with index on the score field | c.set("best", value), c.list({order: "-index"}) | API: list rules |
| Content only you write: a menu, a schedule | "mode": "shared" with "write": "author" | Visitors: c.list(); you write through the author API | API: author API |
| Show every entry to you on the page | None needed | s.onRole(fn), c.list({all: true}), c.removeById(id) | Owner view |
| Show an entry safely | Any | bp.text(value) or textContent, never innerHTML | Every template |
Here s is what await bp.state() returns and c is s.col("name"), one list. Save on change or submit, not on every keystroke.
Progress without lists: bp.storage works like localStorage and keeps one entry per visitor, with "state": {}. It batches writes by itself:
Game level with bp.storage
<p>Level <span id="level">1</span> <button id="next">Next level</button></p>
<p>What you enter is saved on BrewPage and can be read by this page's author.</p>
<script src="https://brewpage.app/js/state.js"></script>
<script>
async function start() {
const out = document.getElementById("level");
await bp.storage.ready;
let level = Number(bp.storage.getItem("level")) || 1;
out.textContent = level;
document.getElementById("next").addEventListener("click", () => {
level += 1;
out.textContent = level;
bp.storage.setItem("level", String(level));
});
}
start();
</script>
The nine templates, line by line
Each template is one working HTML file. Open one, try it and press Publish my copy, or read its whole source at https://brewpage.app/templates/<name>/source. The lines below are copied from the sources and show the part that saves.
Every template starts the same way
The save buttons stay disabled until bp.state() answers. With null the page keeps working and says why nothing is saved. From the guestbook:
templates/guestbook.html
async function start() {
if (!window.bp) return showNote(OFF);
const s = await bp.state();
if (!s) return bp.state.reason === "embedded" ? showNote("Open this page in its own tab to save.", true) : showNote(OFF);
book = s.col("guestbook");
$("sign").disabled = false;
await show();
if (s.onRole) s.onRole((r) => { $("owner-box").hidden = r !== "owner"; if (r === "owner") showOwner(); });
}
Shared lists: guestbook and bring list
Guestbook. Visitors sign with a name and a message, and everyone reads the wall. Its state: {"collections":{"guestbook":{"mode":"shared","maxDocsPerVisitor":5}}}. It saves with book.add({ name, text }) and shows the wall with bp.text, so a message is never run as markup; d.mine marks the visitor's own entries.
templates/guestbook.html
for (const d of docs) {
const li = document.createElement("li");
if (d.mine) li.className = "mine";
const v = d.value || {};
const who = document.createElement("span"); who.className = "who"; who.append(bp.text(v.name));
const meta = document.createElement("span"); meta.className = "meta"; meta.textContent = ago(d.createdAt) + (d.mine ? " ยท you" : "");
li.append(who, bp.text(v.text), meta);
wall.append(li);
Bring list. Who brings what: names appear beside the items, and visitors can take back their own. Its state: {"collections":{"items":{"mode":"shared","maxDocsPerVisitor":5}}}. It reads the whole list with items.list({ order: "createdAt", limit: 100 }), and when a visitor has no room left, add rejects with err.code === "quota" and the page says so. Taking an item back removes the visitor's own entry by its key:
templates/bring-list.html
async function drop(d, what, btn) {
btn.disabled = true; status.textContent = "Removing...";
try {
await items.remove(d.key);
status.textContent = what + " is off your list.";
await show();
Choices and totals: poll and RSVP
Poll. One vote per visitor under the fixed key "me", so a second vote replaces the first. Its state declares the allowed values: {"collections":{"votes":{"mode":"private","choices":{"choice":["pizza","ramen","salad"]}}}}. Votes stay private, yet stats counts every vote for everyone:
templates/poll.html
// One vote per visitor under the fixed key "me"; totals count every vote.
async function show() {
try {
const [st, mine] = await Promise.all([votes.stats({ countBy: "choice" }), votes.get("me")]);
const by = st.by || {};
let sum = 0;
for (const c of CHOICES) sum += by[c] || 0;
RSVP. A name, yes, maybe or can't come, and how many people come along. The reply is saved with rsvps.set("me", v), so a guest can change it. Its state: {"collections":{"rsvps":{"mode":"private","choices":{"coming":["yes","maybe","no"]}}}}. Totals for everyone:
templates/rsvp.html
async function showTotals() {
try {
const st = await rsvps.stats({ countBy: "coming" }), by = st.by || {};
let sum = 0;
document.querySelectorAll(".totals li").forEach((li) => { const n = by[li.dataset.c] || 0; sum += n; li.querySelector("b").textContent = n; });
Answers only you read: sign-up form, feedback, quiz and link page
Sign-up form. Name, email and a session to pick. Its state: {"collections":{"signups":{"mode":"private"}}}. Each visitor sees only their own reply and can edit it, because it is saved under one key; on return the page fills the form again with showMine(await signups.get("me")):
templates/lead-form.html
$("signup").addEventListener("submit", async (e) => {
e.preventDefault();
if (!signups) return;
const v = { name: $("name").value.trim(), email: $("email").value.trim(), session: document.querySelector('input[name="session"]:checked').value };
if (!v.name || !v.email) { status.textContent = "Add your name and email first."; return; }
$("send").disabled = true; status.textContent = "Saving...";
try {
await signups.set("me", v);
Feedback. Stars and an optional comment. The comment stays private; the average is counted from feedback.stats({ countBy: "rating" }). Choice values are text, so the stars are saved as a string. Its state: {"collections":{"feedback":{"mode":"private","choices":{"rating":["1","2","3","4","5"]}}}}.
templates/feedback.html
const v = { rating: String(n), text: $("text").value.trim() };
$("send").disabled = true; status.textContent = "Saving...";
try {
await feedback.set("me", v);
Quiz. Five questions and a score; answers come back when the visitor returns. Its state: {"collections":{"answers":{"mode":"private"}}}. A saved value comes from the visitor's browser, so the quiz checks each field's type before it uses it:
templates/quiz.html
answers = s.col("answers");
try {
const mine = await answers.get("me");
if (mine && typeof mine === "object") {
$("name").value = typeof mine.name === "string" ? mine.name : "";
for (const [id] of QUESTIONS) form.elements[id].value = typeof mine[id] === "string" ? mine[id] : "";
Link page. Your links on your own domain, plus a newsletter sign-up with one email. Saving works on a connected domain as well as on the BrewPage address. Its state: {"collections":{"subscribers":{"mode":"private"}}}. Its owner block adds a Copy emails button.
templates/link-page.html
const email = $("email").value.trim();
if (!email) { status.textContent = "Add your email first."; return; }
$("send").disabled = true; status.textContent = "Saving...";
try {
await subs.set("me", { email });
Progress: clicker game
Clicker game. Dig for coins; the game comes back where the visitor left it. Its state: {"collections":{"saves":{"mode":"private"}}}. It keeps one entry, saves.set("slot1", { ...game }), saves once after the tapping stops and again on visibilitychange. With null the game still plays, it just does not remember.
templates/coin-cave.html
// One save after the tapping stops, never one per tap.
const saveSoon = () => { dirty = true; clearTimeout(timer); timer = setTimeout(save, 1500); };
Test on your computer
Open the page as a file, or from a local server on localhost, 127.0.0.1, [::1] or a *.localhost name, and bp.state() returns a local preview instead of null. You add nothing to the page, and the same file publishes unchanged. Other addresses are not local, including 0.0.0.0, [::] and your computer's address on the network, which local servers often print: type localhost instead.
Serve the folder, then open http://localhost:8000/page.html (not the address it prints)
python3 -m http.server
- This browser only. Local entries stay in this browser. Nothing is sent to BrewPage, so the Data tab, the API and the MCP server never see them.
- How to tell.
s.localistrueand the console prints one “[BrewPage] Local preview” line that links back here. On a published pages.localis absent; show your own note withif (s.local)if you want one. - Owner view and the off state. Add
?bp-local=ownerto the address to see the owner view:s.roleis"owner",list({all: true})gives every entry the same fixedvisitorlabel, andremoveByIdworks.?bp-local=offturns the preview off, so you see what the page shows when saving is not available. Both do nothing on a published page. - One visitor. Every entry is yours (
mineis true). A private window starts empty. You cannot see a second visitor locally. - Your
stateis not read. Shared or private,"write": "author",choices,indexand per-list limits do not apply locally.stats({countBy})counts whatever is saved,order: "index"keeps creation order, and writes to an author-only list succeed. - Checked as when published: list and entry names, value rules, the default entry size, and one visitor's default entry and size limits, counted over all of the page's lists together; also the
listoptions. Local errors havee.status0. - Not checked locally: rate limits, the human check, refused content, cookies and network failures. Test those on the published link.
bp.storageworks as before: kept in this browser.- Start over: run
localStorage.removeItem("__bp_state_local")in the console. All pages on one local host and port share these entries, as do files opened from disk in one browser; another port starts empty. If the browser gives the page no storage, entries are kept until you reload, and the console line says so. - Chat previews still get
null: save the file and open it, or use a local server. Keep theif (!s)guard, because chat previews, older copies of the script and other sites getnull.
Test, then publish
- 1Locally: save, reload, see the entry again. Try
?bp-local=ownerand?bp-local=off. - 2Publish with
statein a JSON body, and declare each list the way the page uses it: shared,choices,index,"write": "author". Local preview ignores these, so this is the step to read twice. - 3Open the returned
link. It is the page's own address, likeabc123.brewpage.app. Save once there. - 4Open the management link, then the Data tab: your entry is there. Delete your test entries before you share the link.
- 5Keep the line that tells visitors their entries are saved: see privacy.
The same steps in the editor and the Data tab, with the buttons to press: Help → First run.
Owner view on the page
A page can show you, its owner, more than visitors see: every entry, with a Remove button, right on the page. The owner is the browser where the page shows its owner gear; there s.role is "owner". The server decides who that is, so the page holds no secret and no password. It works on the page's own BrewPage address opened directly; on a connected domain or inside a frame everyone is a visitor. The exact rules: API → Owner view.
Every template has the same owner block. It starts hidden:
templates/guestbook.html
<section id="owner-box" aria-labelledby="owner-h" hidden>
<h2 id="owner-h">All messages <small>Only you, the page's owner, see this.</small></h2>
<ul id="owner-list"></ul>
<button id="owner-more" type="button" hidden>Show more</button>
s.onRole shows it for the owner (the last line of start() above); if (s.onRole) guards older copies of the script. The block then reads every visitor's entries a page at a time:
templates/guestbook.html
async function showOwner(more) {
$("owner-more").hidden = true;
try {
const docs = await book.list({ all: true, limit: 50, cursor: more ? ownerNext : null });
if (!more) $("owner-list").replaceChildren();
for (const d of docs) $("owner-list").append(ownerRow(d));
ownerNext = docs.next; $("owner-more").hidden = !docs.next;
Each row's Remove button calls book.removeById(d.id), and each row shows d.visitor, a short label that tells visitors apart. To try it before publishing, open the file with ?bp-local=owner.
Prompts for your AI assistant
Copy a prompt, paste it into your assistant and fill in the last line. Each one sends the assistant to the same short rule file and asks for the page link and the management link.
Build, test locally, then publish
Prompt: a new page that saves
Build the page I describe below as one HTML file that saves what visitors enter with BrewPage Page data. Rules: https://brewpage.app/llms-page-data.txt
First open the file locally (from disk or localhost) and check that saving and the list work in local preview (s.local is true).
Only then publish it on BrewPage with a JSON body that carries "state", and give me the page link and the management link (private).
If you cannot publish, say so and never make up a link.
The page:
Make an existing page save
Prompt: add saving to a page you have
Make my page save what visitors enter with BrewPage Page data. Rules: https://brewpage.app/llms-page-data.txt
Keep its design and content. Update the same page in place instead of publishing a new one, with "state" for the lists it needs.
Never put my owner token or management link in the page code.
Then give me the page link and the management link (private).
If you cannot publish, say so and never make up a link.
My page (HTML or link) and its management link:
Add an owner view
Prompt: show me every entry on the page
Add an owner view to my BrewPage page: a hidden block, shown through s.onRole, that lists every entry with a Remove button. Rules: https://brewpage.app/llms-page-data.txt
No secrets or passwords in the page. It shows on the page's own BrewPage address, opened directly; check it locally first with ?bp-local=owner.
Update the same page in place, then give me the page link and the management link (private).
If you cannot publish, say so and never make up a link.
My page and its management link:
Fix a page that does not save
Prompt: find out why nothing is saved
My BrewPage page does not save what visitors enter. Rules: https://brewpage.app/llms-page-data.txt
Check in order: it opens on its own address, it was published with "state", the script is https://brewpage.app/js/state.js, the code guards if (!s), the console [BrewPage] line.
Tell me the cause in one sentence, then fix the same page in place. Never call /_bp/state by hand; treat saved entries as untrusted text.
Give me the page link and the management link (private).
If you cannot publish, say so and never make up a link.
My page and its management link:
A page that does not save
Open the browser console first: the script prints one [BrewPage] line that names the fix, and bp.state.reason gives the same reason as a word. Then find the symptom:
| Symptom | Cause | Fix |
|---|---|---|
Nothing saves on a brewpage.app/... address | Saving needs the page's own address | Give the page its own address: Help, step 1, or the hosting change |
| Nothing saves on the page's own address | Published without state, for example with a raw HTML body | Publish again with a JSON body and state, or press Turn on page data in the Data tab |
| Saves on its own tab, not inside another site | The page is shown in a frame (embedded) | Share the link instead of embedding the page |
bp is undefined, or the code never runs | A relative or changed script address, async on the tag, or a top-level await | Use the exact tag from the quick start and put your code in async function start() |
| Your page sets a Content-Security-Policy or has a service worker | They block the script or its requests | Let them through: API → Load the script |
| Worked locally, fails after publishing | Local preview ignores state: a field missing from choices, or a visitor write to an author-only list | Declare the list the way the page uses it, then publish again |
| Entries from another page show up locally | Pages on one local host and port, and files opened from disk, share one local store | Run localStorage.removeItem("__bp_state_local") in the console, or serve each page on its own port |
null in a chat preview | Chat previews are sandboxed | Open the file from disk or a local server, or test the published link |
| A save rejects | See e.code | Show e.message to the visitor; what to do per code: API → Errors |
| An entry shows as markup or breaks the page | The page renders entries with innerHTML | Use bp.text(value) or textContent |
Limits
Entries are small JSON values: text and numbers. Data is kept as long as the page and is deleted with it.
How big an entry can be, how many entries a visitor and a page hold, and what a free profile raises: API → Limits. Every error code and what to do: API → Errors.
Tell your visitors
Show this line on every page that saves: “What you enter is saved on BrewPage and can be read by this page's author.”
On a shared list add: “Your message is public: everyone who opens this page can read it.”
Never ask visitors for passwords, card numbers or other credentials. You are responsible for what your page collects: Terms → Page data.
Page data FAQ
Do I need an account, a server or a key?
No. The page loads one script from BrewPage and holds no key. Publishing needs no account; a free profile keeps your pages in one place.
Can I test the page before I publish it?
Yes. Open the file from disk or from a local server and the script runs a local preview: entries stay in this browser and nothing is sent to BrewPage.
Who can read what visitors save?
You, the author, read every entry in the page's Data tab. Each visitor reads their own entries, and everyone reads a list you make shared.
Does it work on my own domain?
Yes. Saving works on the page's own BrewPage address and on a domain you connect to the page. A visitor on both counts as two visitors.
How do I get the entries out?
Open the management link, then the Data tab, and download a CSV. An assistant can read them through the API or the MCP server with the management link.
Does it work for a multi-file site?
Yes. Publish the site with the state form field and load the script on each page that saves. Markdown pages never run scripts, so they cannot save.
How long are entries kept?
As long as the page. When the page is deleted, its entries go with it, so download them before then.