Skip to content

Forms reference

The Contact form and the career application form share one pipeline:

<Form> (design system) → server action (src/lib/actions.ts) → deliver() → provider(s) chosen in FORM_PROVIDER

The forms, their states (sending, “Thank you”, “Something went wrong”) and the spam honeypot work out of the box. Where the submissions go is a setting, not code.

Copy .env.example to .env.local (or add the variables in your host, e.g. Vercel → Settings → Environment Variables) and set FORM_PROVIDER.

FORM_PROVIDER What happens Variables
console (default) Printed to the server log. For development.
webhook Posted as JSON to a URL — Zapier, Make, n8n, Pipedream, Formspree, Slack workflow, your own API. FORM_WEBHOOK_URL, optional FORM_WEBHOOK_SECRET (sent as Authorization: Bearer …)
mailchimp Sender added or updated in an audience, tagged with the form and chosen services, 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.

  • 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: first name → FNAME, last name → LNAME, phone → PHONE (a new audience’s defaults). Change the map at the top of providers/mailchimp.ts if your audience uses other tags.
{
"form": "contact",
"title": "New contact enquiry",
"submittedAt": "2026-09-16T10:48:23.256Z",
"data": { "firstName": "Ana", "lastName": "Lee", "email": "ana@example.com", "phone": "", "services": ["Content Marketing"], "message": "Hi!" },
"labels": { "firstName": "First Name", "lastName": "Last Name", "email": "Email", "phone": "Phone Number", "services": "Services", "message": "Message" }
}

form is contact or application. Application data also carries position, linkedin, otherSocial and website.

2. Add your own provider (HubSpot, Brevo, Airtable, a database…)

Section titled “2. Add your own provider (HubSpot, Brevo, Airtable, a database…)”
  1. Create src/lib/forms/providers/<name>.ts:

    import { env, toLines, 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, "firstName"), LASTNAME: valueOf(submission, "lastName") },
    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 page copy: src/content/pages/contact.ts and src/content/pages/career.ts (name is the key providers receive, label is what people read).
  • Required fields and multi-choice fields are listed in src/lib/actions.ts. Keep them in step with the required flags in the copy, which the browser checks first.
  • A new form: lay it out with <Form>, <FormField> and <CheckboxGroup>, add a server action next to the others that calls deliver(data, { form, title, fields, required }), and add the form name to FormKind in types.ts.