Forms & Email
Read this first. Urengi’s six forms ship inert, and that is deliberate rather than unfinished. Each has real, live, self-checked server code behind an action that Astro never mounts, because it auto-mounts only
src/actions/index.tsand there isn’t one. That is what keeps the template a 100% static build with no adapter and no host baked in. Connecting a form is configuration, not implementation — and it is a required step if you want the form to work.
The six
| Form | Where it renders | Action file |
|---|---|---|
| Newsletter | the footer band across the blog area | actions/newsletter.ts |
| Reference call | the footer band on the customers area | actions/reference-call.ts |
| Sales enquiry | the footer band on /pricing/ |
actions/sales-enquiry.ts |
| Integration request | /integrations/ |
actions/integration-request.ts |
| Contact enquiry | /contact/ |
actions/contact.ts |
| Trial request | /signup/ |
actions/sign-up.ts |
A seventh form exists and is not in that list: /signin/. It is covered at the end, and the short version is that it must never be connected this way.
What “inert” looks like
Every form renders exactly as designed — real labels, real inputs, real autocomplete tokens, real native validation — with its submit disabled and a note printed underneath naming the file that explains how to connect it.
That is not a placeholder aesthetic; it is preventing a specific silent failure. A form that is submittable but unaddressed fails quietly: a POST to a bare static route runs no action and re-renders an empty form, so every submission is dropped with the page still looking fine. Verified as a 405 before the gate existed. A form that looks live and isn’t is worse than one that says it isn’t.
The mechanism is Sections/Global/_formGate.ts, and each form’s own switch is four lines beside it:
export const CONTACT_WIRED: boolean = false;
export const contact = formGate({
wired: CONTACT_WIRED,
action: actions.contact,
note: "The contact form isn't connected yet — see the walkthrough in src/actions/contact.ts.",
fields: ["name", "email", "company", "subject", "message"],
});
Three details in there are load-bearing:
The wired boolean is declared per form and passed in. The mechanism is shared; the decision is not. You can mount any one action without the others, and one boolean must never half-wire another form.
It is annotated boolean rather than left as the false literal. The annotation stops TypeScript narrowing the wired branches away, so they stay type-checked while the form is off — the wired path is real code that compiles, lints and refactors rather than a commented-out block nothing checks.
astro:actions is imported unconditionally, and it resolves and builds with no src/actions/index.ts mounted, which is what makes the above possible.
fields lists every input a per-field Zod error can name, so a five-input form surfaces five separate messages rather than collapsing them under whichever box came first. Reading fields.tool on a form that never declared a tool input is a compile error.
Connecting one — the four steps
The walkthrough lives at the top of each action file. The shared recipe is written once, in src/actions/newsletter.ts, and the other five point at it.
1. Add an adapter for your host.
pnpm add @astrojs/node # or cloudflare / netlify / vercel
// astro.config.mjs
adapter: node({ mode: "standalone" }),
2. Mount the action. Astro auto-mounts exactly one file, and every action file exports a server object whose keys become action names — so export { server } from … twice would collide. Spread them into one object:
// src/actions/index.ts — keep the forms you want, delete the lines you don't
import { server as contact } from "./contact";
import { server as integrationRequest } from "./integration-request";
import { server as newsletter } from "./newsletter";
import { server as referenceCall } from "./reference-call";
import { server as salesEnquiry } from "./sales-enquiry";
import { server as signUp } from "./sign-up";
export const server = {
...contact, ...integrationRequest, ...newsletter,
...referenceCall, ...salesEnquiry, ...signUp,
};
Each file used to carry its own copy of this list — a snapshot of the actions that existed the day it was written — so no two agreed and none listed every form. Following the newest of them still left one action unmounted, which fails at request time rather than at build.
3. Make the receiving pages render on demand. An action posts back to the page its form sits on, and only an on-demand page can read the result to swap the form for its confirmation:
export const prerender = false;
Six routes need it if you connect all six forms: /blog/, /contact/, /customers/, /integrations/, /pricing/ and /signup/. Add each one to the sitemap’s customPages in astro.config.mjs at the same time, because @astrojs/sitemap enumerates the built tree and cannot see an on-demand route.
The bands that appear site-wide — the newsletter, the reference call — take an at option naming the on-demand route that owns their action, so they post there from wherever they are drawn. A POST to a prerendered route is a 405.
Whether to make a page on-demand or move the band onto its own /newsletter/ route is deliberately not decided for you: it is a hosting-cost question, not a code one.
4. Flip the boolean and set the env vars. One *_WIRED constant per form, then the variables, which are declared in astro.config.mjs’s env.schema:
RESEND_API_KEY=re_xxxxxxxx # one key serves every action
RESEND_AUDIENCE_ID=... # newsletter only — the audience to grow
CONTACT_FROM=... # a verified Resend sender
CONTACT_TO=... # the mailbox that receives it
Every form reuses RESEND_API_KEY; the *_FROM / *_TO pair is just that form’s verified sender and destination mailbox. All are optional, so a fresh clone still builds before its owner has signed up for anything, and all are read at request time.
They use astro:env rather than import.meta.env for a specific reason: on Cloudflare Workers, secrets live only in the runtime env, which import.meta.env never sees. astro:env/server is the one form that resolves at request time on every adapter.
Locally that is a .env file; on a host it is that host’s environment-variable UI.
The shared envelope
Every action is built by formAction in src/actions/_formAction.ts, which owns four decisions that each have a non-obvious reason:
- The spam gates run first, because they are the cheapest checks and neither costs a provider request.
- The env is checked at request time, not build time, so an unconfigured clone still builds.
- The operator and the visitor are told different things. The visitor’s message renders on the page, so 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 — and because
envis passed as an object, the log names only the keys actually missing. - Delivery never throws, so a failure is a string to show rather than an opaque 500.
What each action still owns: its schema, its delivery call, its env vars, its log label, its visitor-facing copy, and its own four-step walkthrough. The mechanism is shared; the decisions are not.
The spam gates
Two hidden fields on every form, checked by spamReason in @js/formGuards:
export function spamReason({ _gotcha, _ts }: SpamFields, now = Date.now()): string | null {
if (_gotcha) return "This submission was rejected.";
if (now - _ts < MIN_FILL_MS) return "That was quick — please try again.";
return null;
}
_gotcha is a honeypot, drawn hidden, so anything in it was filled by something that is not a person. _ts is when the server rendered the form, judged server-clock to server-clock and never against the visitor’s device. MIN_FILL_MS is two seconds.
Both return a form-level message rather than a field-level one, because neither points at something the visitor typed, and the message is deliberately generic — every form on the site shows it, so a string naming one of them would be wrong on all the others.
The schema type pins this: formAction requires an input schema extending SpamFields, so a form that forgot the hidden fields is a compile error rather than a form that silently accepts every bot.
Its ceiling is stated rather than hidden. This stops drive-by bots, not a targeted attacker who reads the markup and waits two seconds — _ts is a forgeable hidden field. The upgrade path is Cloudflare Turnstile: a widget in each form plus one siteverify call. It is deliberately not shipped, because it would make every form on the site require JavaScript.
The same module holds the length bounds — EMAIL_MAX, NAME_MAX, COMPANY_MAX — in one place because two layers enforce them: the Zod schemas that import them and the maxlength each input renders. Separate literals drift in the direction nobody notices, where the browser stops the visitor typing at a limit the server would have accepted, with nothing on screen to explain why.
The one form that must not be wired this way
/signin/ has no action, no formGate and no SIGN_IN_* env pair, and it should stay that way.
Every other form on the site ends in a message. Signing in ends in a session — which needs an account store, a password hash, a cookie and a CSRF story, none of which a static template has and none of which an email provider is. Reusing the enquiry pattern here would collapse into “a sign-in that emails the operator”, putting plaintext passwords in an inbox. (The same reasoning keeps password out of the sign-up schema entirely, pinned there by a self-check.)
The form still renders as designed, with its submit disabled and its note printed, for exactly the reason the other six do.
To connect it, point it at an identity provider:
- Add an adapter, then install a provider — Better Auth, WorkOS, Clerk, Auth.js, or your own.
- Set
SIGN_IN_ACTIONinSections/Auth/_signIn.tsto that provider’s POST endpoint and flipSIGN_IN_WIRED. The input names the form renders areemailandpassword, which is what every provider listed expects. - Point
authData.signIn.form.forgotat the provider’s reset route — it currently goes to/help/, which is honest while nothing can reset anything but is not a reset flow. - The federated buttons are the same job; see
FederatedAuth.astro.
While unwired, SIGN_IN_ACTION is undefined so no action attribute is emitted at all, and the browser cannot be talked into posting the credential to the page itself.
Returning to the static state
If you connected forms and want the stock behaviour back: delete the adapter line and its dependency, delete src/actions/index.ts, flip the *_WIRED booleans back to false, and remove the prerender = false lines and their customPages entries. Everything else in astro.config.mjs is host-neutral.