Contact Form
/contact/ ships with a complete, working contact form whose server half is written in full but not mounted. The schema, the spam gates, the escaping and the Resend delivery call are live, type-checked, self-checked code. The Astro action that would receive the POST sits at src/actions/contact.ts, where it is compiled and type-checked but never registered.
That is deliberate, and understanding why makes the rest of this page obvious.
Why it ships unmounted
Astro hard-errors on src/actions/index.ts without an adapter — ActionsWithoutServerOutputError — and a route with prerender = false fails the same way. Shipping either would force this theme to pick a host for its buyer and turn a static build into a hybrid one.
Astro only auto-mounts src/actions/index.ts. Under any other filename the file is compiled and type-checked by pnpm check but never registered. That is the whole trick: no dead code, no .example text file that silently rots, and pnpm build still emits 28 static pages.
Everything that is not Astro-specific lives in @js/contact rather than in the action — including the Resend fetch — so the substantial half of the feature is real, exercised code rather than a stub. The action itself is about thirty lines of wrapper.
The shape
| File | Role |
|---|---|
src/js/contact.ts |
Live. Schema, spam gates, escaping, sendContactEmail |
src/js/contact.selfcheck.ts |
Live. The runnable check for all of it |
src/actions/contact.ts |
Inert. The action wrapper — type-checked, unmounted |
Sections/Contact/_form.ts |
The wiring switch and the action-result reader |
Sections/Contact/ContactBoard.astro |
Two-column board: the card and the contact channels |
Sections/Contact/ContactForm.astro |
The form, every server-rendered state, the pending script |
Sections/Contact/ContactField.astro |
One labelled field plus its error |
astro.config.mjs |
env.schema and security.checkOrigin |
Connecting it — four steps
- Add an adapter for your host and mount it:
pnpm add @astrojs/netlify # or cloudflare / node / verceladapter: netlify(), - Mount the action:
echo 'export { server } from "./contact";' > src/actions/index.ts - Uncomment
export const prerender = false;insrc/pages/contact.astro. That one route becomes server-rendered; the other 27 pages still build to static HTML. - Flip one boolean —
CONTACT_WIREDtotrueinsrc/components/Sections/Contact/_form.ts.
Then set RESEND_API_KEY and CONTACT_TO_EMAIL in your host’s environment. See .env.example.
Step 4 is the only one that touches a component, and it is one edit rather than four by design. CONTACT_WIRED gates the POST target, the wrapper element and the submit button’s type together, so the form cannot end up submittable-but-unaddressed. That state was reachable in an earlier draft and it fails silently: a POST to the bare route runs no action and re-renders an empty form, so every message is dropped while the page still looks fine.
astro:actions is imported unconditionally in _form.ts, and that is load-bearing. It resolves and builds with no index.ts mounted, which is what makes the wired branch real code that ships compiled, linted and formatted — rather than a commented-out block nothing checks.
One caveat: while unmounted, actions.contact is typed any because the generated action registry is empty, so the wired branch is only shallowly type-checked until step 2 is done.
What is live before you connect anything
Quite a lot, which is why the page is worth looking at on a fresh clone. The markup, the honeypot, the timestamp, the per-field and form-level error rendering, the sent state, and the entire delivery path in @js/contact are all real. contactState(Astro) returns the empty state while unwired — which is exactly what a plain GET returns once wired — so the section renders identically in both worlds and never learns which one it is in.
Validation
The Zod schema in @js/contact is the trust boundary:
| Field | Rule |
|---|---|
name |
trimmed, 1–100 chars, no CR/LF |
email |
valid email, max 254 |
subject |
trimmed, 1–150 chars, no CR/LF |
message |
trimmed, 10–5000 chars |
_gotcha |
optional string — the honeypot |
_ts |
coerced positive integer — when the form was rendered |
Every message is written for the visitor: “Please tell us your name.”, “Tell us a little more than that.”, “Line breaks are not allowed here.”
Because the action uses accept: "form", it parses native FormData — so the page works with JavaScript disabled. The server re-renders it with the errors or the sent state.
Security
Practical hardening for a small site, not a threat model.
Header injection. \r and \n are rejected, not stripped, in name and subject — the two values that reach a mail header. A newline smuggled into either is what turns a contact form into an open relay: the provider concatenates the value into a header and everything after the break becomes the attacker’s own header. Rejecting at the boundary means nothing downstream has to remember to escape it, and sanitising instead would silently rewrite what the visitor typed. email needs no guard because a valid email cannot contain a break, and message is a body rather than a header.
CSRF. security.checkOrigin is set explicitly in astro.config.mjs and 403s a form POST whose Origin does not match the request URL. Verified: a POST with no Origin header returns 403. It covers form content types on on-demand routes only — which is also why the action uses accept: "form" rather than JSON, since a JSON fetch POST is not covered by it.
Spam. A honeypot field and a three-second time gate, both in the handler rather than in middleware. On a serverless host, middleware often runs in a different function than the handler, so a check up there cannot share state with this one; in-handler behaves the same wherever it lands. Both return a form-level message rather than a field error, because neither points at something the visitor typed.
These stop drive-by bots, not a targeted attacker who reads the markup and waits three seconds. The stated upgrade path is Cloudflare Turnstile — deliberately not shipped, because it would make the form require JavaScript.
HTML injection. The message is escaped before it is interpolated into the email body. escapeHtml covers &, <, > and " and deliberately leaves ' alone, so a human reading the mail does not see ' — which is exactly why it is a separate function from @js/rss’s escapeXml, where ' should be escaped because that document is parsed by machines.
security.csp is off, deliberately. It is incompatible with <ClientRouter />, which BaseHead mounts.
Delivery
sendContactEmail posts to Resend’s REST API with a plain fetch — one less dependency than the SDK, and identical behaviour on Workers, a Netlify function or Node. It carries a 10-second AbortSignal.timeout.
It never throws. The two failure modes are collapsed into one visitor-facing string, because fetch rejects on DNS failure, a dropped connection or the timeout — none of which are !response.ok — and an uncaught one of those is an opaque 500 instead of a form the visitor can act on. The provider’s own reason goes to the log, not to the page, because it can name the account.
Configuration
Three server-only variables through astro:env, all optional, so a fresh clone still builds and its owner can see the page before signing up for anything. A missing key is a readable request-time message, never a build failure.
RESEND_API_KEY— required for delivery.CONTACT_TO_EMAIL— where enquiries land. Required for delivery.CONTACT_FROM_EMAIL— the sending identity. Defaults to Resend’s shared sandbox sender, which works immediately but can only deliver to the address that owns the Resend account. IfCONTACT_TO_EMAILis any other address, mail fails at the provider with everything else correct. Verify a domain in Resend and set this before going live.
The visitor-facing contact details on the page — the three channel cards — are siteData.contact, which also feeds the Organization JSON-LD. One fact, two consumers.
Traps worth remembering
<ClientRouter /> already owns the form submit. Do not intercept it. Astro’s router listens for submit on the document, POSTs the FormData itself and swaps in the response, so the server’s re-render arrives with no reload and no client code. It bails on ev.defaultPrevented, which means a preventDefault() in your script would not layer on top of that path — it would replace it, and then every state needs rebuilding by hand in JavaScript, a second copy of markup the server already renders.
The shipped script does two small things only: a real disabled on the submit button, blocking a double POST, and on astro:after-swap, moving focus to the outcome. The second is not optional — disabling the button drops focus to <body>, the router then replaces <body>, and a role="status" that arrives already holding its text is never announced.
The POST target is ?_action=contact, not the bare path. A POST to /contact/ runs no action and re-renders a blank form, which looks exactly like a broken handler when you are testing by hand.
The honeypot is deliberately permissive in the schema. Rejecting it there would surface it as a per-field error on an input the visitor cannot see — a permanent dead end for a human whose browser autofilled it. spamReason rejects it with a message they can read.
A consumer class outranks the state variant. tv() merges the caller’s class last, so a border-* inside a shared surface string would silently beat the border-error that state="error" sets. _surface.ts therefore carries no border colour, and ContactField picks it per state in the same consumer string. Check by POSTing an invalid field and grepping the response for border-error, not by reading the class list.
astro dev runs a server whether or not an adapter is installed, so a working dev server proves nothing about whether the POST route survives a build. Run pnpm build.
Verification
contact.selfcheck.ts covers the trust boundary: schema bounds, CRLF rejection in both header fields, error keying, both spam gates, the escaper and the email body. Run it with pnpm test.
The wiring itself was proven by temporarily performing all four steps against @astrojs/node and then reverting:
| Case | Result |
|---|---|
POST with no Origin |
403 (checkOrigin) |
| Invalid email + short body | both field errors under their own fields, aria-invalid, border-error |
\n in name |
rejected — “Line breaks are not allowed here.” |
| Sub-3s submit | form-level “That was quick…” |
| Honeypot filled | form-level “Message rejected.” |
| Valid, no env set | form-level “This form is not configured yet…” |
| Valid, bad key | visitor sees the generic failure; the provider’s reason is logged, not shown |
| Valid, stubbed 200 | “Message sent.” renders, form gone, outgoing JSON carried the escaped body and reply_to = the visitor |
Adding a field
Four edits, in order: add it to contactSchema in @js/contact with a real error message; add it to buildEmail’s destructure and body — escaped, unless you have a reason; add a <ContactField> for it in ContactForm.astro; and if it will reach a mail header, give it the same CRLF refinement name and subject carry. Then extend contact.selfcheck.ts, because the check is the only thing that runs this code before a real submission does.