Skip to main content
Golden wattle in full bloom with bottlebrush spikes threaded through, in a three-ink risograph print

Forms and state

A form turns a user's action into a request; the server validates it, changes state and returns the next representation

Everything the static half of the course shipped was read-only: the browser asked for a page and got one. A form is the other direction. It turns a user’s action into a request that changes something, and the response is the next version of the page.

The request path

The whole mechanism is four attributes of ordinary HTML. This is the guestbook in the lecture demo, complete:

<form method="post" action="/api/messages">
  <label for="body">Leave a message</label>
  <input id="body" name="body" required maxlength="500" />
  <button>Sign</button>
</form>

Server-side routing

A route pairs a method and a path, such as POST /api/messages, with the code that answers it: the handler. Frameworks like Express make you register each route by hand. In Astro the file path is the URL and the exported function is the method:

src/pages/index.astro       GET   /
src/pages/readme.astro      GET   /readme/
src/pages/api/messages.ts   POST  /api/messages     export const POST
src/pages/api/events.ts     GET   /api/events       export const GET

Nothing registers a route. What makes this different from the static half is one line of astro.config.ts — output: "server" plus the Node adapter — which builds a running server instead of a directory of HTML files.

That has a consequence to account for: a server-rendered app has no dist/*.html for a check to walk, so the course’s invariants visit an explicit list of routes in spec/routes.ts. Add a page without adding its route and the checks stop covering it.

Validating and responding

The handler re-does the client’s checks and then answers:

const form = await request.formData();
const body = String(form.get("body") ?? "").trim();
if (body) bus.emit("message", addMessage(body.slice(0, 500)));
return redirect("/", 303);

The 303 is the reason the form works with JavaScript disabled: the browser follows it and re-renders the page from the database. Watch one button press in the network panel and you see both halves, plus the live channel sitting open:

POST  /api/messages   303   body=g'day
GET   /               200   the list, re-rendered from SQLite
GET   /api/events     200   text/event-stream — (pending)

State on each side

Three places state can live, and the distinction matters more once there is a server to get it wrong:

The question to ask of any new feature is which of the three its state belongs in, and the crit-7 spec is blunt about the answer it cares about: create something, and it’s still there after a reload.

The live-update stream is a fourth thing that is easy to confuse with state. /api/events is a long-lived response the browser reads with EventSource; server-sent events are one-directional and plain HTTP, which makes them the simplest live channel that works everywhere. It carries no state of its own — it tells other tabs that state changed, and they re-render.

Deploy the same contract

The point of a plain form post is that it behaves identically on your machine and on the deployed app. The one exception matters here because it fails in exactly one place.

Astro checks that a form POST carries a same-origin Origin header, which is CSRF protection you get for free. Behind Fly’s proxy, TLS is terminated before the request reaches your app, so an app that hasn’t been told to trust x-forwarded-proto decides it is serving http, disagrees with its own origin, and answers 403 to every form in the app — in production only. Naming the deploy domain is what prevents it:

security: {
  allowedDomains: [{ hostname: "**.fly.dev", protocol: "https" }],
}

The lecture demo ships that line, and its CI probes the deployed app for both halves of the behaviour: that a same-origin POST is accepted, and that a cross-site one is still refused. Everything else about getting the app onto a machine is running in production.