Skip to content
AstroCraft Docs
On this theme

Contact Form & Email

The contact form is connected, and it is the only thing on the site that needs a server. /contact/ sets prerender = false; the other 122 pages are prerendered files. Everything about it — the schema, the guards, the escaping, the Resend request — is real, live, self-checked code rather than a stub.

The four steps, and where they are done

src/actions/contact.ts opens with a four-step walkthrough for connecting a form. All four are done in this theme, and knowing which file each lives in is what makes it reversible:

  1. An adapter. @astrojs/cloudflare is mounted in astro.config.mjs. Any adapter works.
  2. A mounted action. Astro auto-mounts only src/actions/index.ts, and that file is one line: export { server } from "./contact";. The action lives next door under a name Astro does not pick up, so it keeps its walkthrough beside the code it explains and this file stays the one line that says “and now it is on”.
  3. The route opts out of prerendering. export const prerender = false in src/pages/contact.astro, plus /contact/ in ON_DEMAND_ROUTES so the sitemap and the link check are both right about it.
  4. One boolean. CONTACT_WIRED in src/components/Contact/_form.ts.

To disconnect it — if you would rather ship a fully static site — reverse all four. The form then renders with a disabled submit and a note explaining why, which is a deliberate state rather than a broken one.

Why one switch and not four branches

_form.ts is the only module that knows whether the server half exists, and CONTACT_WIRED gates three things together: the POST target, the wrapper element and the submit’s type.

export const CONTACT_WIRED: boolean = true;

export const contactAction: string | undefined = CONTACT_WIRED
  ? String(actions.contact)
  : undefined;

Gating all three at once is what makes one failure state unreachable: a form that is submittable but unaddressed. That state fails silently — a POST to a bare static route runs no action and re-renders an empty form, so every message is dropped with the page still looking fine.

Two smaller decisions in that file are worth copying if you write another form.

astro:actions is imported unconditionally, and that is load-bearing: it resolves and builds with no src/actions/index.ts mounted at all. So the wired code is real code that compiles, lints and refactors, rather than a commented-out block nothing checks.

The type is annotated boolean, not left as a literal. Annotating stops TypeScript narrowing the wired branches away, so they stay type-checked while the switch is off.

It is not in siteSettings, despite the theme’s usual “drive toggles from typed config” rule. It is not an independent toggle — it is the last step of a structural change. In config it would look like a switch a reader can flip on its own, and flipping it alone gives them a form that silently drops messages.

An action, not an API route

export const server = {
  contact: defineAction({
    accept: "form",
    input: contactSchema,
    handler: async ({ _gotcha, _ts, ...fields }) => { /* … */ },
  }),
};

accept: "form" parses native FormData, which buys three things: the page works with JavaScript off (the server re-renders it holding the errors or the sent state), the POST lands on a content type that security.checkOrigin actually covers (a JSON fetch is not), and Zod validation is wired into the action’s own input handling rather than written by hand.

The no-JS path is taken seriously enough to have its own helper. submittedValues reads the raw request so a rejected submit re-renders the form filled in rather than blank:

export async function submittedValues(astro: AstroGlobal): Promise<Partial<Record<ContactFieldName, string>>>

It exists because isInputError(error) exposes the messages but never the input that produced them — without it, a mistyped email address would discard up to 5000 characters of message. Two details matter: the two hidden fields are excluded deliberately (echoing the honeypot back would defeat it on the retry, and the timestamp must carry the current render’s value or the time gate rejects every resubmission), and the filter keys off the field table rather than a _ prefix, so a POST inventing a third name is dropped rather than waved through.

The trust boundary

src/js/contact.ts is the validation and delivery half, and it deliberately imports nothing from astro:* so its check can run under bare Node. It uses astro/zod — Astro’s own bundled Zod — so it adds no dependency.

export const CONTACT_LIMITS = {
  firstName: 100, lastName: 100, email: 254, topic: 150, message: 5000,
} as const;

Those limits are stated once because two layers enforce them: the schema, and the maxlength attribute the card renders. They were separate literals, which drifts in the direction nobody notices — raise the schema’s ceiling and the browser still stops the visitor typing at the old one, so the form refuses input the server would have accepted, with no error and nothing on screen to explain it.

Header injection

Three fields reach a mail header — first name, last name and topic are interpolated into the subject line — and each rejects CR and LF:

const noLineBreaks = (value: string): boolean => !/[\r\n]/.test(value);

A newline smuggled into a header is what turns a contact form into an open relay: the provider concatenates the value and everything after the break becomes an attacker’s own header — a Bcc:, a forged Reply-To:. It rejects rather than strips, so nothing downstream has to remember, and the visitor is never silently rewritten. z.email() already rejects a line break, so the email field needs no guard of its own.

The topic field is free text, not an enum

The three subject options are content, living in contactData. Typing the schema as an enum of them would be a second copy of that list — one that goes stale silently the day a fourth door is added, rejecting a choice the page itself offered. The length and line-break guards are what actually matter, because that value reaches the subject line.

Two spam gates

Both are checked first, because neither costs a provider request.

A honeypot (_gotcha) — a field a human never sees. It is deliberately permissive in the schema: rejecting it there would surface a per-field error on an invisible input, so a browser that autofilled it would fail forever with nothing on screen to fix. spamReason rejects it with a message a person can read.

A time gate (_ts) — the server’s render timestamp, compared against arrival. Under MIN_FILL_MS (three seconds), assume a bot. It is judged server clock to server clock, never the visitor’s device.

Escaping

escapeHtml runs over every value that reaches the HTML body of the notification. & is replaced first, or the escapes it introduces get escaped again. The apostrophe is deliberately not escaped here, unlike in the RSS feed’s escaper — the two files each document the difference rather than sharing one function that is wrong for one of them.

Delivery

sendContactEmail builds the message and posts it to Resend, with a ten-second timeout — “we decide what a hung provider is” — and returns a visitor-facing string on failure rather than throwing raw provider output at the page.

Configuration is read at request time through astro:env/server:

env: {
  schema: {
    RESEND_API_KEY:     envField.string({ context: "server", access: "secret", optional: true }),
    CONTACT_TO_EMAIL:   envField.string({ context: "server", access: "secret", optional: true }),
    CONTACT_FROM_EMAIL: envField.string({ context: "server", access: "secret",
                                          default: "[email protected]" }),
  },
}

All optional, so a fresh clone still builds and its owner can see the page before signing up for anything. The action checks for the two required values in its handler and returns a readable error, never a build failure.

astro:env rather than import.meta.env is not a style preference: on Cloudflare Workers, secrets live only in the runtime env, which import.meta.env never sees. This is the one form that resolves at request time on every adapter.

The error message split is worth noticing:

console.error("[contact] Not configured: set RESEND_API_KEY and CONTACT_TO_EMAIL (see step 4 above).");
throw new ActionError({
  code: "INTERNAL_SERVER_ERROR",
  message: "This form is not available right now. Please email us directly.",
});

The visitor gets no configuration detail — that string renders on the page, and naming environment variables there tells them nothing they can act on while telling a stranger how the form is wired. The operator’s version goes to the server log, where the operator is looking.

Setting it up

npx wrangler secret put RESEND_API_KEY     # resend.com -> API keys
npx wrangler secret put CONTACT_TO_EMAIL   # where submissions land

Locally, a .env file works instead. On another host, its own environment-variable UI.

Then the one thing that will otherwise waste an afternoon. Until you verify a sending domain at resend.com/domains, Resend’s sandbox sender delivers only to the Resend account owner’s own address. Any other CONTACT_TO_EMAIL returns 403, and the form renders “Your message could not be sent.” That is not a bug in the form. Verify a domain, then set CONTACT_FROM_EMAIL to an address on it to reach anyone else.

The card

Contact/ContactForm.astro renders from a field table (_fields.ts) rather than from hand-written markup, which is what keeps the schema and the rendered inputs describing the same form — and contact.test.ts asserts that the two still agree.

The field table and the wiring switch are deliberately separate modules: one owns what boxes exist, the other owns whether they can post. The card itself stays declarative and grows no branches.

Everything the visitor reads — labels, placeholders, the sent confirmation, the enquiry-route cards — is content and lives in contactData. The one string that does not is UNWIRED_NOTE, the line printed under a disabled submit, which sits beside the switch it describes: it names a source file, it is addressed to whoever is building the site rather than to a visitor, and it must never reach a translator.

Using a different provider

Replace the body of sendContactEmail and the ResendConfig interface. The schema, both spam gates, the escaper, the email builder and the whole no-JS path are provider-agnostic and stay as they are. Update the env schema in astro.config.mjs to match your provider’s keys, and keep contact.test.ts passing — it stubs the network call, so it will tell you if the surrounding contract changed.

NEXT STEPImages & Assets