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>
methodandactionsay what request to make and where to send it. The browser makes it; there is no JavaScript in this form at allnameis the only part of the markup the server ever sees. The request body isbody=g%27day, andnameis where that key came from. Rename it and the server is reading a field that no longer arrivesrequiredandmaxlengthare a courtesy to the person typing, not validation. Anything can post to this URL, so the handler does the work again
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:
- in the page: whatever the current render is showing. It’s gone on reload, which is fine for a disclosure widget and wrong for anything a user typed
- in the session: who this visitor is, across requests. A cookie carries an identifier; the data behind it stays on the server
- in the database: the facts the app is actually about. The guestbook’s messages are here, which is why a reload still shows them
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.