Skip to content
mdlake

Forms

Add a contact or enquiry form to a generated site with the forms module, which brings validation, a honeypot, a rate limit, optional Turnstile and mail. For developers.

On this page

A generated site has no form handler of its own, and you should not write one. The kit has a forms module: one definition file per form, one shared route that serves every form, and a Form component for pages. The module validates input with the same schema in the browser and on the server, hides a trap field from people, limits how often one address can send, optionally checks Cloudflare Turnstile, and sends the message by mail.

A form needs server code, so adding the first form moves a static site to the server hosting model. On a site with a separate backend it does not: the backend serves the form instead. Read Choose a hosting model first if you are not sure which model your site is in.

What you’ll do

  1. Run npx mdlake add-form to create the form, its page and its settings.
  2. Edit the form definition to match what the client needs.
  3. Set the recipient and the mail settings.
  4. Try the form locally, with and without JavaScript.

Before you start

  • A site scaffolded with the builder, with npm install done.
  • The developer or client has approved that the site becomes a server site (an App Service instead of the free Static Web Apps tier). The add-form skill asks for this approval first.
  • Decide the recipient address. It will live in an environment variable, never in code.

Add a form

Run this in the site folder:

Bash
npx mdlake add-form contact --route /contact/ --turnstile
PartMeaning
<name>The form’s name: lower case letters, digits and hyphens, starting with a letter. It becomes the file name src/forms/<name>.ts and the address /api/forms/<name>/.
--route /x/The page address. It must start and end with /. Default: /<name>/.
--recipient-env NAMEThe name of the environment variable that holds the recipient. Default: the name in capitals with hyphens as underscores, plus _FORM_TO (for contact, CONTACT_FORM_TO).
--turnstileTurn on Cloudflare Turnstile for this form. See Turnstile. Off by default.
--intro <key>A key of lake.config.ts. The summary of that document is shown above the form.
--heading <text>The heading of the page. Default: Contact.

You can also ask Claude Code to run the add-form skill. It asks you for the same choices and stops for approval before it commits anything.

The command writes these files and never overwrites one that exists:

FileWhat it is
src/forms/<name>.tsThe definition: schema, fields and options. Site-owned.
src/pages/api/forms/[name].tsOne route that serves every form in src/forms. Created once and shared.
src/pages/<route>.astroA page that shows the form with the Form component.
.env.exampleThe variable names the form needs, once each. No values that matter.
package.jsonAdds @astrojs/node. Run npm install afterwards.
.mdlake/hosting.jsonRecords the server tier. Commit it.

It ends by printing the secrets and settings you have to set where the site runs. Nothing secret goes into the repository or the lake.

Note

If src/pages.config.ts already composes the route you chose (the template composes /contact/), no page is written and the command says so. Pick another route, or replace that page with a hand-written one. See Custom pages.

On a site with a separate backend

On a split site, add-form writes src/forms/<name>.ts and the page, but no [name].ts route and no @astrojs/node. The backend must serve the form too. The command prints the exact line to add. npm run check fails until it is there. See Forms on a split site.

The form definition

This is what the command writes for a form called contact with --turnstile:

TypeScript
import { defineForm, z } from '@mdlake/site-kit/forms'

export default defineForm({
  schema: z.object({
    name: z.string().min(1, 'Enter your name').max(120, 'Use at most 120 characters'),
    email: z.email('Enter a valid email address').max(200),
    message: z.string().min(10, 'Write a few words more').max(4000, 'Use at most 4000 characters'),
  }),
  fields: [
    { name: 'name', label: 'Your name', autocomplete: 'name' },
    { name: 'email', label: 'Email address', type: 'email', autocomplete: 'email' },
    { name: 'message', label: 'Message', type: 'textarea' },
  ],
  recipientEnv: 'CONTACT_FORM_TO',
  subject: 'New message from the website',
  success: { message: 'Thank you. Your message has been sent.' },
  replyTo: 'email',
  turnstile: true,
})

The file is site-owned: edit it freely. The schema says what is valid. fields says what to draw. The labels, the button text and the messages are site chrome and belong in this file, not in the lake. The one thing that must never be in this file is the recipient address: recipientEnv names the variable.

Options

OptionRequiredMeaning
schemayesA Zod object. The messages you write here are shown next to the fields.
fieldsyesEvery key of the schema, in display order.
recipientEnvyesThe name of the environment variable that holds the recipient. Capitals, digits and underscores, starting with a letter. Several addresses can be separated with commas or semicolons.
subjectyesThe subject line of the mail.
success.messageyesShown after sending.
success.pagenoA page of the site, starting with a single /, to go to after sending.
replyTonoThe field whose value is the visitor’s address. It becomes the mail’s Reply-To when it looks like an address.
turnstilenotrue turns on Turnstile. Default false.
submitLabelnoThe button text. Default Send.
honeypotnoThe name of the hidden trap field. Default company_website.
rateLimitno{ max, windowSeconds }. Default 5 submissions per 600 seconds.

Field options

OptionMeaning
nameA key of the schema. Required.
labelThe visible label. Required.
typetext (default), email, tel, url, textarea, select or checkbox.
requiredDefault: whatever the schema says. A field whose schema accepts undefined is optional. A checkbox is optional unless you set required: true.
autocompleteThe HTML autocomplete token, such as name, email or tel.
helpA help text under the field.
placeholderPlaceholder text. For a select, the text of the empty first choice (default Choose one).
optionsFor select only: a list of { value, label }.
rowsFor textarea only. Default 6.

An example with an optional field, a choice and a consent checkbox:

TypeScript
import { defineForm, z } from '@mdlake/site-kit/forms'

export default defineForm({
  schema: z.object({
    name: z.string().min(1, 'Enter your name'),
    email: z.email('Enter a valid email address'),
    topic: z.enum(['sales', 'support'], 'Choose a topic'),
    phone: z.string().max(40).optional(),
    consent: z.literal(true, 'Please accept that we store your message'),
    message: z.string().min(10, 'Write a few words more'),
  }),
  fields: [
    { name: 'name', label: 'Your name', autocomplete: 'name' },
    { name: 'email', label: 'Email address', type: 'email', autocomplete: 'email' },
    { name: 'topic', label: 'Topic', type: 'select', options: [{ value: 'sales', label: 'Sales' }, { value: 'support', label: 'Support' }] },
    { name: 'phone', label: 'Phone (optional)', type: 'tel', autocomplete: 'tel' },
    { name: 'message', label: 'Message', type: 'textarea', help: 'The more you tell us, the better we can help.' },
    { name: 'consent', label: 'I accept that you store my message', type: 'checkbox', required: true },
  ],
  recipientEnv: 'CONTACT_FORM_TO',
  subject: 'New message from the website',
  success: { message: 'Thank you. We will answer within two working days.', page: '/thanks/' },
  replyTo: 'email',
})

What defineForm checks

defineForm runs when the dev server or the build loads the file. It throws with a list of problems, so a broken form is never found by a visitor:

  • the schema has a key that no field describes, or a field is not a key of the schema, or a field is listed twice;
  • recipientEnv is not the name of a variable (for example you wrote an address);
  • success.page does not start with a single /;
  • replyTo is not a field;
  • a field has the same name as the honeypot;
  • rateLimit has a max or windowSeconds below 1;
  • a select has no options.

The page

The generated page is a normal Astro page:

Astro
---
import Form from '@mdlake/site-kit/components/modules/Form.astro'
import Layout from '@mdlake/site-kit/layouts/Layout.astro'
---

<Layout title="Contact">
  <Form name="contact" heading="Contact" level={1} />
</Layout>
PropMeaning
nameThe form’s name: the file name of src/forms/<name>.ts.
headingThe heading text (site chrome). Without it the form region is named “Contact form” for screen readers.
introA key of lake.config.ts. The summary of that document is shown above the form. Nothing is shown when the document has no summary.
level1 or 2. The heading level. Default 2.

You can put the Form component on any hand-written page. The page is yours: change the layout, add text around it, link it from the navigation when the client asks. To render the form differently for the whole site, override the module in site.config.ts (modules: { form: './src/components/MyForm.astro' }). See Custom components.

What happens when someone sends the form

The browser posts to /api/forms/<name>/. The trailing slash is part of the address: the site uses trailing slashes everywhere, and a redirect would turn the POST into a GET. The server runs these steps in order and stops at the first that fails:

  1. Rate limit. Too many recent submissions from this address answer 429 with a Retry-After header.
  2. Body. The body must be a normal form post (application/x-www-form-urlencoded or multipart/form-data) or JSON, and at most 64 KB.
  3. Honeypot. Anything in the trap field gets a success answer and nothing is sent.
  4. Validation. The schema checks the values. Invalid input answers 422 with one message per field.
  5. Turnstile, if the form asks for it.
  6. Mail. The message is sent through the mail provider. See Mail.
  7. Answer. JSON for a fetch call, or a 303 redirect for a plain form post.

Answers

Statuserror codeWhen
200none (ok: true)Sent. The JSON has message and, with success.page, redirect.
303noneSent, from a plain form post without JavaScript.
400bad-requestThe body could not be read or is not a JSON object.
403captcha-failedTurnstile did not accept the token.
404unknown-formNo src/forms/<name>.ts for that name.
413too-largeThe body is larger than 64 KB.
415unsupported-typeThe content type is not a form post or JSON.
422invalidValidation failed. errors has the message per field.
429rate-limitedToo many submissions. Retry-After says how long to wait.
500captcha-not-configured, mail-not-configured, recipient-not-configuredThe server is missing settings. The visitor sees a neutral message; the log names the missing variable (never its value).
502captcha-unavailable, delivery-failedCloudflare or the mail provider could not be reached or refused.

A client that sends Accept: application/json or a JSON body gets JSON. Any other client gets the same status with a small HTML page that says what went wrong and links back to the form.

Honeypot

The form contains a text field named company_website (or the name you set) that no person sees. It is moved off screen, hidden from assistive technology and skipped by the tab key. A script that fills every field fills it too, and gets a success answer while nothing is sent. The hit still counts against the rate limit.

Rate limit

The limit is per form and per client address. The counters live in the memory of one server process. Behind a scaled-out App Service plan, each instance counts for itself, so the real limit is max times the number of instances, and a restart forgets everything. That is enough to stop a script from filling a mailbox. It is not a security boundary.

The client address comes from the X-Forwarded-For header, counting from the right. TRUSTED_PROXY_HOPS says how many proxies you trust (default 1). Never trust the left side of that header: the visitor can forge it. The generated Bicep sets TRUSTED_PROXY_HOPS to 2 for the split model, where Static Web Apps sits in front of the App Service.

Without JavaScript

The form is rendered on the server as a plain <form method="post" action="/api/forms/<name>/">, so it works when JavaScript is off or fails to load:

  • A valid post redirects (303) to success.page, or back to the page the visitor came from with #form-<name>-sent added. The page shows the success message when that anchor is the target.
  • An invalid post shows a small page with the problems and a link back to the form.

With JavaScript, the form validates in the browser with the same schema, shows the errors next to the fields, moves focus to the first invalid field, posts with fetch, and shows the success message in place. Turnstile is the one feature that needs JavaScript.

Settings

Add the values to .env locally (copy .env.example first) and to the hosting environment for deployment. The names are the same everywhere.

VariableSecretMeaning
<RECIPIENT> (default <NAME>_FORM_TO)NoWhere this form’s messages go.
MAIL_PROVIDERNoresend or log. See Mail.
RESEND_API_KEYYesThe Resend API key.
MAIL_FROMNoThe sender, on a domain verified in Resend.
PUBLIC_TURNSTILE_SITE_KEYNo (public)Turnstile site key. Read at build time.
TURNSTILE_SECRET_KEYYesTurnstile secret key.
TRUSTED_PROXY_HOPSNoProxies to trust in X-Forwarded-For. Default 1.

Warning

Never commit a key, and never put a recipient address into a form definition, a parameter file, the lake or a log. Secrets belong in App Service settings, Key Vault references, pipeline secrets, or your local .env, which Git ignores.

Try it

  1. Copy .env.example to .env and set MAIL_PROVIDER=log and your recipient variable.

    PowerShell
    Copy-Item .env.example .env
    Bash
    cp .env.example .env
  2. Run npm run dev and open the form page. Send a message. The dev server prints the mail instead of sending it:

    [mdlake mail:log] to=owner@example.com from=forms@localhost subject="New message from the website" reply-to=anna@example.com
    Your name: Anna
    ...
    [mdlake mail:log] end
  3. Turn JavaScript off in the browser’s developer tools and send again. The page should reload with the success message.

  4. Send the form with the name left empty. You see the field error next to the field, and no mail is sent.

  5. Test the endpoint directly. The answer for an invalid body is a 422 with the message per field (-SkipHttpErrorCheck needs PowerShell 7):

    Bash
    curl -i -X POST http://localhost:4321/api/forms/contact/ \
      -H "content-type: application/json" \
      -d '{"name":"","email":"x","message":"short"}'
    PowerShell
    Invoke-WebRequest -Method Post -Uri http://localhost:4321/api/forms/contact/ `
      -ContentType 'application/json' -Body '{"name":"","email":"x","message":"short"}' `
      -SkipHttpErrorCheck
  6. Run npm run check. It fails until the pipelines and Bicep are written for the server model. Run the setup-hosting skill, or npx mdlake hosting init --ci github (see Azure and Bicep).

What can go wrong

SymptomCause and fix
The dev server stops with “Invalid form definition”.Read the list. The schema and fields must name the same keys.
The build stops with “The form module asks for … but src/forms/… does not export a definition”.The page uses a name with no file, or the file does not use defineForm.
The form posts but you get 404.The name in <Form name="..."> is not a file in src/forms. On a split site the backend does not know the form yet.
500 recipient-not-configured.The variable named in recipientEnv is empty or holds no valid address on the server.
500 mail-not-configured.MAIL_PROVIDER=resend without RESEND_API_KEY or MAIL_FROM. See Mail.
Mail is printed in the log in production.MAIL_PROVIDER is log, or RESEND_API_KEY is not set. The server logs a warning once.
Everyone gets 429.Behind a proxy, TRUSTED_PROXY_HOPS is wrong, so all visitors share one address.
A page for /contact/ was not written.src/pages.config.ts composes that route. Use another route.

What’s next