Forms reference — Tustel Astro
The site’s form — the project enquiry on the Contact page — works in one of three ways. You choose with environment
variables (copy .env.example to .env, or set them in your host’s dashboard); no code changes.
| Mode | Set | Hosting | Where submissions go |
|---|---|---|---|
| Demo (default) | nothing | any static host | Nowhere: the form shows “Thank you” and logs the fields in the browser console. |
| Static | PUBLIC_FORM_ENDPOINT |
any static host | Posted from the browser to a form service or webhook. |
| Server | PUBLIC_FORM_MODE=server + FORM_PROVIDER |
a host with an Astro adapter | Delivered by the site’s own route to console, webhook, Mailchimp or Resend. |
Every mode shows the same states on the Send button (spinner, “Thank you”, “Something went wrong”), keeps the typed values after an error, resets the form after success and drops spam caught by the hidden honeypot field.
Static mode: form services and webhooks
Section titled “Static mode: form services and webhooks”Set PUBLIC_FORM_ENDPOINT to the URL the service gives you. The form posts multipart/form-data with
Accept: application/json, and the service must answer with a 2xx status.
| Service | PUBLIC_FORM_ENDPOINT |
Notes |
|---|---|---|
| Formspree | https://formspree.io/f/<form id> |
Emails you each submission. |
| Web3Forms | https://api.web3forms.com/submit |
Also set PUBLIC_FORM_ACCESS_KEY. |
| Getform, Basin | the form’s endpoint URL | |
| Zapier, Make, n8n, Pipedream | the webhook (“catch hook”) URL | Route submissions anywhere: Mailchimp, Google Sheets, Slack, a CRM. |
Fields sent: every input by its name — name, email, organization, project, budget, timeline and
message — plus form (contact) and subject (“New project enquiry”).
The endpoint is public (it is in the page), which is how these services are designed to work; they filter spam themselves.
Server mode: your own route
Section titled “Server mode: your own route”For Mailchimp or Resend without a third-party service, run the form on your server:
- Add the adapter for your host:
npx astro add vercel(ornetlify,node, …). - Set
PUBLIC_FORM_MODE=serverand choose providers withFORM_PROVIDER:
FORM_PROVIDER |
What happens | Variables |
|---|---|---|
console (default) |
Printed to the server log. | — |
webhook |
Posted as JSON to a URL. | FORM_WEBHOOK_URL, optional FORM_WEBHOOK_SECRET (sent as Authorization: Bearer …) |
mailchimp |
Sender added or updated in an audience, tagged with the form, the rest saved as a note. | MAILCHIMP_API_KEY, MAILCHIMP_AUDIENCE_ID, optional MAILCHIMP_STATUS |
resend |
Emailed to you; replying answers the sender. | RESEND_API_KEY, FORM_EMAIL_TO, FORM_EMAIL_FROM |
Use several at once with a comma: FORM_PROVIDER=resend,mailchimp emails you and adds the contact to Mailchimp.
If any of them fails, the visitor sees “Something went wrong” (their input stays in the form) and the reason is logged
on the server. The form then posts to POST /api/forms/contact (src/lib/forms/route.ts, added by
src/integrations/forms.ts). Without an adapter the build stops with NoAdapterInstalled. Astro rejects cross-site
posts to the route, so only your own pages can submit.
Mailchimp notes
Section titled “Mailchimp notes”- API key: Profile → Extras → API keys. It ends in your data center (
…-us21); nothing else to configure. - Audience ID: Audience → Settings → Audience name and defaults.
- Consent: new contacts are added as
transactional— they can receive replies, not campaigns. Usependingto send a double opt-in email, orsubscribedonly if the form asks for marketing consent. - Merge fields: the name (
name) →FNAME(a new audience’s default). Change the map at the top ofproviders/mailchimp.tsif your audience uses other tags.
Webhook payload
Section titled “Webhook payload”{ "form": "contact", "title": "New project enquiry", "submittedAt": "2026-10-05T10:48:23.256Z", "data": { "name": "Jane Smith", "email": "jane@example.com", "organization": "", "project": "Photography", "budget": "$2,000 – $5,000", "timeline": "Next month", "message": "A brand shoot for our new café." }, "labels": { "name": "Name", "email": "Email", "organization": "Organization", "project": "Project Type", "budget": "Estimated Budget", "timeline": "Timeline", "message": "Message" }}form is contact. Every field is sent, as "" when left empty. Add a name to FormKind in types.ts for every new
form.
Your own provider (HubSpot, Brevo, Airtable, a database…)
Section titled “Your own provider (HubSpot, Brevo, Airtable, a database…)”-
Create
src/lib/forms/providers/<name>.tsexporting aFormProvider— throw to show the error state:import { env, valueOf, type FormProvider } from "../types";export const brevoProvider: FormProvider = {name: "brevo",async send(submission) {const response = await fetch("https://api.brevo.com/v3/contacts", {method: "POST",headers: { "api-key": env("BREVO_API_KEY"), "Content-Type": "application/json" },body: JSON.stringify({email: valueOf(submission, "email"),attributes: { FIRSTNAME: valueOf(submission, "name") },updateEnabled: true,}),});if (!response.ok) throw new Error(`Brevo responded ${response.status}: ${await response.text()}`);},}; -
Register it in
providers/index.ts(brevo: brevoProvider). -
Set
FORM_PROVIDER=brevoand its variables in.env.example/ your host.
Helpers from types.ts: valueOf(submission, name) returns one field as text, toLines(submission) returns
"Label: value" lines for every filled field, and env(name) reads a required variable with a clear error.
Change the form
Section titled “Change the form”- Fields and labels live in the copy:
contactForminsrc/content/pages/contact.json(nameis the key services receive,labelis what people read,requiredadds the asterisk and the browser’s check). - Required fields come from the same
requiredflags: in server modesrc/lib/forms/definitions.tschecks them again on the server. - A new form: lay it out with
<Form>and<FormField>(src/design-system/components/forms), give it aname, add its definition tosrc/lib/forms/definitions.tsfor server mode, and add the name toFormKindintypes.ts.