Skip to content
AstroCraft Docs
On this theme

Booking Form

/book/ is the most intricate page in Medice and POST /api/book/ is its only server-rendered route. Together they are the theme’s only trust boundary and its only SSR surface — which makes this the one subsystem where the codebase’s usual instinct toward the smallest possible implementation explicitly does not apply to the validation.

The chain

Five files, each owning one thing, and the split runs along a specific line: what a check can load.

File Owns Checked by
Sections/Book/BookingForm.astro the markup: six panels, the stepper, the summary aside markup
Sections/Book/_booking.ts the DOM wiring: step navigation, enable/disable, submit DOM
Sections/Book/_estimate.ts the cost rule _estimate.test.ts
src/js/booking.ts the field spine, the validator, the email booking.test.ts
src/pages/api/book.ts transport: read, validate, send, answer exercised via pnpm preview

src/js/booking.ts has no imports at all, which is exactly why the validator is testable — a module importing astro:content, astro:env/server or the DOM cannot be loaded by Node’s type stripping. _estimate.ts was split out of _booking.ts for the same reason: it is the part that is neither DOM nor wiring.

That split matters more than it sounds. The cost rule is three sentences of policy — insured is nothing today, paying yourself is the published figure, nothing chosen states no figure at all — which used to be an if and two ternaries in the middle of a render function, reachable by no test and verifiable only by clicking through six steps and reading a card. The wrong output there is not a broken layout; it is a price the practice did not quote.

The six steps

ServiceStep · ClinicianStep · WhenStep · DetailsStep · InsuranceStep · ConfirmStep.

None of them restates data. The visit types come from the price list, the clinicians from the roster, the insurers from the pricing band. A thirteenth insurer is one edit in one file.

The spine

BOOKING_FIELDS in src/js/booking.ts is one list of twelve fields, and everything else derives from it — the validator’s loop, the email’s rows, and the order both print in. A thirteenth field is one entry rather than an edit in four places that can disagree.

The order is the order the email prints them, which is the order the form asks for them.

One entry, location, is not a wire field at all, and it is the only optional one. The clinic is a fact about the chosen clinician, so the endpoint derives it from the roster with clinicOf() and whatever arrived is replaced.

A hidden input used to carry it, kept in step by the form’s script — which made the row spoofable by a crafted POST and absent whenever JavaScript was off. Deriving it server-side deleted the input, the script that maintained it, and the difference between the two paths at once. It stays in the spine because the email still prints it, in that position.

What guards the endpoint

Four things, and one deliberate absence.

Origin. Astro’s security.checkOrigin is on by default for on-demand routes and rejects a cross-origin POST with a 403 before the handler runs. Verified against a real pnpm preview: foreign Origin gives 403, same-origin gives 303.

Shape and length. parseBooking checks presence, type and a per-field cap, so one submission cannot become a megabyte of email. It reports every failure at once rather than one per round trip.

Consent as a value, not a presence. An unticked checkbox is simply absent from a form payload, so “missing” and “declined” arrive identically — and only one of those may be accepted. The validator tests body.consent !== true rather than testing for the key.

A honeypot — a field named for something a clinic form would never ask, invisible to humans, rejected when filled, with a flat rejection that does not name it. It is deliberately the cheap measure, and it stops the bots that post every form they find, which is most of them.

No rate limit, on purpose. A real one needs shared state a static theme has nowhere to put, and the blast radius is bounded by design: to is a fixed BOOKING_TO read from the server’s environment and never from the request, so a flood is a desk inbox filling up rather than an open relay. Every host that runs this endpoint rate-limits at the edge, and the endpoint’s own header says so.

Two callers, two shapes

The endpoint answers whichever caller arrived, resolved once by responderFor rather than re-decided at all six exits:

  • the form’s script sets accept: application/json and gets JSON;
  • a browser posting the form natively gets a 303 to /book/#booked or #booking-error, which :target reveals.

It dispatches on content-type for the body and accept for the answer — what shape arrived and what shape should leave are two questions, and one header answers each.

The :target notices sit above the form, which is not a layout preference: a display: none element has no position to scroll to, and the browser resolves the fragment before :target reveals it.

Failures are reported, never swallowed

A missing key is a 500 with a server-side console.error naming the variable. The visitor sees “we could not send that” and never Resend’s own message.

The alternative — a cheerful 200 and a booking nobody receives — is the worst outcome this endpoint has, and every branch is written to avoid it.

The no-JavaScript path is real

Every step panel is in the DOM at once and the script hides five. Without the script all six are visible, the navigation buttons never appear (they ship hidden and the script reveals them), and the native submit posts the whole form.

Two consequences are worth knowing if you edit the markup.

Fields in hidden panels are disabled, not merely hidden, because native validation refuses to report on a control it cannot focus — a required field inside a hidden panel throws “An invalid form control is not focusable” and blocks the submit with no message anywhere. The submit therefore re-enables every panel before reading the payload. Each panel is a <fieldset>, so both states are one property on one element: panel.hidden = panel.disabled = !on.

One booking per submit. A busy latch guards the handler and the control that submitted — event.submitter — goes disabled for the duration. The guard used to disable the wizard’s Next button, which on the last step is hidden and is not the control anyone presses: three impatient clicks sent three POSTs and three identical bookings.

Accessibility

Step panels take tabindex="-1" and receive focus on each step change. aria-current="step" moves with the stepper. Errors land in a role="status" aria-live="polite" region. The confirmation panel is focused on success.

The stepper’s labels drop below md while the discs remain — six 32px discs fit a 375px phone and six 116px labels do not — and nothing is lost, because the panel beneath names the current step in its own heading.

All six steps were verified in a browser, along with the derived clinic and price. A real screen-reader pass has not been done, and it remains the one manual check worth doing before launch, because a clinic’s booking form is the page where accessibility is the product.

The three secrets

RESEND_API_KEY, BOOKING_FROM and BOOKING_TO, set in your host’s environment and in a local .env for pnpm dev. BOOKING_FROM must be an address on a domain verified in Resend; unverified senders are rejected.

They are declared in the env block of astro.config.mjs as access: "secret", optional: true, and read through astro:env/servernever through import.meta.env. That is not style, it is a bug fix.

Vite replaces import.meta.env.RESEND_API_KEY with its value at build time, which on a build machine without the secret is undefined. The “is it configured?” guard then folds to always-true and everything after it is dead-code-eliminated. The compiled chunk was four lines long and returned 500 to every booking, for ever, while astro dev worked perfectly. Nothing but reading the built output would have caught it.

optional: true is what lets a fresh clone with no keys still build, so the endpoint reports its own misconfiguration rather than failing the build for someone who has not signed up for Resend yet.

Why the adapter exists

src/pages/api/book.ts sets prerender = false, and that single line is the only reason the project carries an adapter. Every page still prerenders; the build just splits into dist/client/ (all 52 pages) and dist/server/ (this endpoint). A static host deploys dist/client.

Which adapter it is was never load-bearing. The endpoint is a standard APIRoute over Web Request/Response using fetch, so @astrojs/node, @astrojs/netlify and @astrojs/vercel each substitute in one line with nothing else touched. See Deployment.

trailingSlash: "always" covers endpoints too: /api/book redirects and /api/book/ is the route. A form’s action is a URL like any other.

Pointing it somewhere else

To use a third-party form service instead, delete the adapter and src/pages/api/book.ts together, and change the form’s action in src/components/Sections/Book/. The client-side wizard, the validation and the price estimate all keep working — they do not know where the form posts.

Keep parseBooking in mind if you do: the client does not run it, so whatever you post to has to do its own validation.

NEXT STEPImages