Skip to content
Sign inJoin waitlist

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.

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.

For Mailchimp or Resend without a third-party service, run the form on your server:

  1. Add the adapter for your host: npx astro add vercel (or netlify, node, …).
  2. Set PUBLIC_FORM_MODE=server and choose providers with FORM_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.

  • 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. Use pending to send a double opt-in email, or subscribed only if the form asks for marketing consent.
  • Merge fields: the name (name) → FNAME (a new audience’s default). Change the map at the top of providers/mailchimp.ts if your audience uses other tags.
{
"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…)”
  1. Create src/lib/forms/providers/<name>.ts exporting a FormProvider — 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()}`);
    },
    };
  2. Register it in providers/index.ts (brevo: brevoProvider).

  3. Set FORM_PROVIDER=brevo and 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.

  • Fields and labels live in the copy: contactForm in src/content/pages/contact.json (name is the key services receive, label is what people read, required adds the asterisk and the browser’s check).
  • Required fields come from the same required flags: in server mode src/lib/forms/definitions.ts checks them again on the server.
  • A new form: lay it out with <Form> and <FormField> (src/design-system/components/forms), give it a name, add its definition to src/lib/forms/definitions.ts for server mode, and add the name to FormKind in types.ts.