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 template

Save form data from HTML without a backend

  1. 1The page loads https://brewpage.app/js/state.js and calls bp.state() to save and read entries.
  2. 2You publish it with a state field that names the lists it keeps. The page gets its own address, where saving works.
  3. 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.

TaskList rule in stateCallsSee
One answer per visitor: a form, a sign-up, a quizNone needed: lists are each visitor's ownc.set("me", value), c.get("me")Sign-up form, quiz
Count choices for everyone: a poll, an RSVP, a ratingchoices with the allowed values of a fieldc.set("me", value), c.stats({countBy})Poll, RSVP, feedback
A list everyone reads: a guestbook, who brings what"mode": "shared", often with maxDocsPerVisitorc.add(value), c.list({order, limit}), c.remove(key)Guestbook, bring list
Save progress: a game, a draft, settingsNone neededc.set(key, value), c.get(key); or bp.storage like localStorageClicker game
Top scores"mode": "shared" with index on the score fieldc.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 APIAPI: author API
Show every entry to you on the pageNone neededs.onRole(fn), c.list({all: true}), c.removeById(id)Owner view
Show an entry safelyAnybp.text(value) or textContent, never innerHTMLEvery 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] : "";

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.local is true and the console prints one “[BrewPage] Local preview” line that links back here. On a published page s.local is absent; show your own note with if (s.local) if you want one.
  • Owner view and the off state. Add ?bp-local=owner to the address to see the owner view: s.role is "owner", list({all: true}) gives every entry the same fixed visitor label, and removeById works. ?bp-local=off turns 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 (mine is true). A private window starts empty. You cannot see a second visitor locally.
  • Your state is not read. Shared or private, "write": "author", choices, index and 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 list options. Local errors have e.status 0.
  • Not checked locally: rate limits, the human check, refused content, cookies and network failures. Test those on the published link.
  • bp.storage works 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 the if (!s) guard, because chat previews, older copies of the script and other sites get null.

Test, then publish

  1. 1Locally: save, reload, see the entry again. Try ?bp-local=owner and ?bp-local=off.
  2. 2Publish with state in 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.
  3. 3Open the returned link. It is the page's own address, like abc123.brewpage.app. Save once there.
  4. 4Open the management link, then the Data tab: your entry is there. Delete your test entries before you share the link.
  5. 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:

SymptomCauseFix
Nothing saves on a brewpage.app/... addressSaving needs the page's own addressGive the page its own address: Help, step 1, or the hosting change
Nothing saves on the page's own addressPublished without state, for example with a raw HTML bodyPublish 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 siteThe page is shown in a frame (embedded)Share the link instead of embedding the page
bp is undefined, or the code never runsA relative or changed script address, async on the tag, or a top-level awaitUse 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 workerThey block the script or its requestsLet them through: API → Load the script
Worked locally, fails after publishingLocal preview ignores state: a field missing from choices, or a visitor write to an author-only listDeclare the list the way the page uses it, then publish again
Entries from another page show up locallyPages on one local host and port, and files opened from disk, share one local storeRun localStorage.removeItem("__bp_state_local") in the console, or serve each page on its own port
null in a chat previewChat previews are sandboxedOpen the file from disk or a local server, or test the published link
A save rejectsSee e.codeShow e.message to the visitor; what to do per code: API → Errors
An entry shows as markup or breaks the pageThe page renders entries with innerHTMLUse 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.