---
title: Build a complete contact form
description: Build a form that validates server-side, saves each submission into a collection, redirects to a thank-you page, and, when you need them, accepts a file, a captcha and an email notification.
updated: 2026-08-19
---

A Quilium form is a content block with two special fields: one that validates the POST, one that stores it. The template is plain HTML.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Oscilla.</b> Every snippet on this page comes from <b>Oscilla</b>, a bilingual demo site for an observability platform. Its <code>contactForm</code> block validates a demo request, saves it to the <code>demoSubmissions</code> collection, accepts an optional attachment and redirects to <code>/merci</code>.</div></div>

## 1. Create the target collection first

The `save` field points at a collection by key. If that collection does not exist yet, the write fails.

```yaml
demoSubmissions:
  name: Demo requests
  objectName: Request
  icon: inbox
  tabsets:
    main:
      name: Request
      fields:
        name:
          type: text
          label: Name
          searchable: true
        email:
          type: text
          label: Email
          searchable: true
        company:
          type: text
          label: Company
        message:
          type: textarea
          label: Message
  displays:
    - type: datatable
      config:
        cols: [name, email, company]
```

One field per input you intend to keep. Submissions are ordinary collection entries: editors read them in the backoffice, and `retention` (an ISO-8601 duration such as `P1Y`) expires them on schedule, which is usually what you want for personal data.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Do not add a "submitted at" field.</b> A <code>save</code> does not honour <code>default: now</code>, so the field would land empty. Every entry already carries a system <code>created_at</code>; put that in <code>displays.cols</code> instead.</div></div>

## 2. Declare the form block

`validate` holds the rules, `save` holds the destination. Both are field types, declared like any other field on the content-type.

```yaml
contactForm:
  name: Contact form
  icon: inbox
  template: contactForm.liquid
  tabsets:
    main:
      name: Content
      fields:
        title:
          type: text
          label: Title
        intro:
          type: textarea
          label: Introduction
        validation:
          type: validate
          label: Validation
          config:
            rules:
              name:
                - required
              email:
                - required
                - email
              message:
                - required
        saveToCollection:
          type: save
          label: Save
          config:
            key: demoSubmissions
```

Rule keys are the **input names** in your markup, not the collection's field names, though keeping the two identical saves you a mapping. Validation runs on the server, so it holds regardless of what the browser does.

## 3. Write the markup

Four things make it a Quilium form rather than inert HTML: the POST to the same URL, the prefill from `request.body`, the per-field errors from `content.form`, and the submit button that names its target block.

```liquid
{% assign c = content.custom %}
{% assign f = content.form %}
{% assign b = request.body %}

<form method="POST" action="" novalidate>
  {% if c.title != blank %}<h2>{{ c.title }}</h2>{% endif %}

  <label for="name">{{ 'Name' | _ }}</label>
  <input type="text" id="name" name="name" required autocomplete="name"
         value="{{ b.name }}"
         {% if f.name.errors %}aria-invalid="true" aria-describedby="err-name"{% endif %} />
  {% if f.name.errors %}
    <span id="err-name" role="alert">{% include '_formError', errors: f.name.errors %}</span>
  {% endif %}

  <label for="email">{{ 'Work email' | _ }}</label>
  <input type="email" id="email" name="email" required autocomplete="email"
         value="{{ b.email }}"
         {% if f.email.errors %}aria-invalid="true" aria-describedby="err-email"{% endif %} />
  {% if f.email.errors %}
    <span id="err-email" role="alert">{% include '_formError', errors: f.email.errors %}</span>
  {% endif %}

  <label for="message">{{ 'Message' | _ }}</label>
  <textarea id="message" name="message" rows="5">{{ b.message }}</textarea>

  <button type="submit" name="form" value="{{ content.id }}">{{ 'Request a demo' | _ }}</button>
</form>
```

The submit button carries `name="form"` and `value="{{ content.id }}"`. That pair tells Quilium which block on the page the POST belongs to, and it is what lets a page hold two forms without them interfering.

Prefilling from `request.body` is not cosmetic. Without it, a validation error empties the form and the visitor retypes everything.

## 4. Turn rule names into readable messages

`content.form.<field>.errors` is an object keyed by the rule that failed. Map the keys you use to sentences, and keep a fallback for the rest:

```liquid
{%- comment -%} _formError.liquid — arg: errors {%- endcomment -%}
{%- for pair in errors -%}
  {%- case pair[0] -%}
    {%- when 'required' -%}{{ 'This field is required.' | _ }}
    {%- when 'email' -%}{{ 'Invalid email address.' | _ }}
    {%- else -%}{% if pair[1] != blank %}{{ pair[1] }}{% else %}{{ 'Invalid field.' | _ }}{% endif %}
  {%- endcase -%}
{%- endfor -%}
```

`pair[0]` is the rule name, `pair[1]` the engine's own message. Going through the dictionary is what makes the errors follow the page's locale.

## 5. Redirect on success

Create a thank-you page, keep it out of the navigation (`visible: false`), then set the redirect **on the block instance**, not on the content-type:

```json
{ "custom": { "validation": { "redirect": ["<thank-you-page-id>"] } } }
```

Quilium answers a valid submission with a 302 to that page. This is the POST-redirect-GET pattern: it stops a browser refresh from resubmitting, and it gives the visitor a URL that means "we got it".

Without a redirect, a valid submission re-renders the same page and the visitor gets no confirmation. Show something either way.

## 6. Test both paths

```bash
quilium run
```

Submit with an empty required field: the page comes back with your messages, the other fields still filled. Submit a valid one: you land on the thank-you page and a new entry appears in the collection.

## 7. Accept a file

An upload is the same form plus one field type, one encoding, two validation rules and one line on the `save` action.

**The field belongs to the collection**, since that is where the submission lands:

```yaml
        attachment:
          type: file
          label: Attachment
          translatable: false
          config:
            multiple: false
            max: 1
```

**The rules go on the `validate` field**, and both take a parameter, so each one is a map inside the list rather than a bare name:

```yaml
            rules:
              attachment:
                - file-extensions:
                    allowed: 'pdf,png,txt'
                - file-size:
                    max: 1048576
```

`allowed` is a comma-separated list with no dots, `max` is a number of bytes. **The error key is the rule name itself** — `file-extensions`, `file-size` — and the message comes back empty, so a rule-to-message map in the template is not optional if you want the visitor to read anything better than "invalid field".

Both rules are **skipped when no file is sent**, so the field stays optional unless you also add `required`.

**The `save` action needs to be told the field is an attachment**, otherwise it has nothing to move:

```yaml
        saveToCollection:
          type: save
          label: Sauvegarde
          config:
            key: demoSubmissions
            attachments:
              - attachment
            path: /demandes-demo
```

`path` is a media-library folder, created for you if it does not exist — on Oscilla it appeared at the root as `/demandes-demo`.

**The markup changes in exactly one place**, the encoding:

```html
<form method="POST" action="" enctype="multipart/form-data">
  <input type="file" name="attachment" accept=".pdf,.png,.txt">
</form>
```

A file input cannot be prefilled — the browser forbids it — so a rejected submission always means the visitor picks the file again, and the error message should say so out loud rather than leaving them to notice.

### What actually gets stored

The file becomes a **media record**, and the collection field holds the same array-of-objects shape as an image:

```json
"attachment": [{
  "id": "d7f25fc2-…", "name": "cdc-test", "extension": "txt",
  "mimeType": "text/plain", "filesize": 61,
  "url": "https://<site>.quilium.app/media/d7f25fc2-….txt",
  "frontUrl": "/media/d7f25fc2-….txt"
}]
```

`name` is the uploaded filename minus its extension. An editor opens it from the entry or from the media library, and a template renders it the way the reference does:

```liquid
{% if item.attachment[0] %}
  <a href="{{ item.attachment[0].url }}" download>{{ item.attachment[0].name }}</a>
{% endif %}
```

Adding the field to the collection's `displays[].config.cols` is worth the one call: it is how an editor sees at a glance which submissions carry an attachment.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>An uploaded file is a public URL.</b> It lands in the media library like any other asset, and <code>/media/&lt;uuid&gt;.&lt;ext&gt;</code> is served to anyone who has the address — no session, no signature, no expiry. The uuid is unguessable, and that is the whole of the protection. For a CV, an invoice or an ID document, that is not enough: keep those off a public form.</div></div>

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b><code>file-size</code> informs the visitor, it does not protect the server.</b> The file is received in full before any rule looks at it, so <code>max</code> is a courtesy to the person filling the form, not a defence. Measured on Oscilla: a 500 KB file against a declared 100 KB max returns 200 with a clean <code>file-size</code> error, exactly as you would want. Declare the same number in the interface, under the field, and the rule becomes a confirmation rather than a surprise. The hard ceiling on the request itself belongs to the infrastructure, not to Quilium, and it sits far above anything a form field should accept.</div></div>

## 8. Stop the bots

One line in the rules, one variable in the template. There is **no key to obtain, no account to open and nothing to configure on the site** — the platform holds the reCAPTCHA credentials and the engine hands the template what it needs.

Add the rule against the hidden field the widget writes into:

```yaml
    validation:
      name: Validation
      fields:
        validation:
          type: validate
          config:
            rules:
              name: [required]
              email: [email, required]
              g-recaptcha-response: [recaptcha]
```

Then drop the widget into the form, above the submit button:

```liquid
{% if content.recaptcha %}
  <div class="mt-8">
    {{ content.recaptcha.html }}
    {% if f['g-recaptcha-response'].errors %}
      <span role="alert">{% include '_formError', errors: f['g-recaptcha-response'].errors %}</span>
    {% endif %}
  </div>
{% endif %}
```

`content.recaptcha.html` is the complete widget: the `<script>` tag and the `<div class="g-recaptcha">`, site key already filled in. It also arrives **in the page's own locale** — the script is requested as `api.js?hl=fr` on the French page and `?hl=en` on the English one, with nothing to declare. On a bilingual site that is one less thing to translate.

If you would rather write the markup yourself, `content.recaptcha.key` gives you the bare site key and you compose the rest.

The captcha is not only a validation rule — it is also the engine's **condition for sending mail** on the platform sender (section 9). And for forms that attract reputation-based spam despite the captcha, a second, invisible layer exists: the opt-in `antispam` block on the same `validate` config, documented in [Custom fields](/en/docs/custom-fields#spam-filtering-antispam).

Note the bracket syntax in the error lookup: `f['g-recaptcha-response']` rather than `f.g-recaptcha-response`. The field name contains hyphens, which Liquid would read as minus signs in dot notation.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>This rule fires even when the field is absent, and that is the point.</b> Measured on Oscilla: a POST carrying no <code>g-recaptcha-response</code> at all is rejected exactly like one carrying a forged token. That is the opposite of <code>file-extensions</code> and <code>file-size</code>, which are skipped when nothing is sent — sensible for an optional attachment, and would be a hole here, since a bot's first move is to omit the field rather than fill it.</div></div>

Give the rule a message while you are there. Rule keys land in `content.form.<field>.errors` under their own name, so an unmapped rule falls through to whatever generic string your partial ends with — `recaptcha`, `file-extensions` and `file-size` all did on Oscilla, and all three said "Champ invalide.":

```liquid
{%- when 'recaptcha' -%}{{ 'Merci de confirmer que vous n’êtes pas un robot.' | t }}
{%- when 'file-extensions' -%}{{ 'Format de fichier non accepté.' | t }}
{%- when 'file-size' -%}{{ 'Fichier trop volumineux.' | t }}
```

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Liquid string literals have no escape character.</b> Writing <code>'vous n''êtes pas'</code> does not produce an apostrophe, it ends the string early and silently truncates the message to "vous n". Use the typographic apostrophe <code>’</code> — better typography, and it cannot close the quote.</div></div>

## 9. Notify someone by email

Two field types extend the same block: `sendmail` notifies by email on top of storing the submission, and `http` forwards the payload to an external endpoint. Both sit beside `validate` and `save` in the same tabset, and the pipeline runs in order — `validate` → `save` → `sendmail` → `http`, each step short-circuiting on failure.

The thing to understand before writing any YAML is that **a `sendmail` field is split in two on purpose.** What is structural lives in the YAML; what an editor may reasonably want to change lives on the content block.

| Lives in the YAML `config` | Lives on the content block |
|---|---|
| `template` — path to the mail body | `to` — the recipient |
| `attachments` — which `file` fields to attach | `subject` |
| `smtp` — the outgoing server | `replyTo` |
| `unflatten` | |

That split is enforced, not conventional. Adding `to:` under `config:` is refused at validation:

```text
A Sendmail field can't have a 'to' property.
```

Which is the good kind of error — it names the mistake instead of quietly ignoring the key.

### The YAML side

```yaml
    actions:
      name: Actions
      fields:
        notification:
          type: sendmail
          label: Email notification
          config:
            template: 'mails/contact.liquid'
            attachments:
              - attachment
        saveToCollection:
          type: save
          label: Save
          config:
            key: messages
```

`template` must resolve to a **`.liquid`** file. A leftover `.njk` or `.html` path is an ENOENT → **500**, not a fallback — and it fails at send time, on a visitor's submission, not when you save the schema. The extension may be omitted entirely; `.liquid` is appended.

`attachments` takes the **names of `file` fields** on the same form. Worth pausing on: an uploaded file already lives at a public, unsigned `/media/<uuid>.<ext>` URL (section 7). Attaching it to the notification is a convenience for the recipient, not a confidentiality measure.

### The block side — and why `to` is not configuration

Set the recipient in the back-office, or over MCP:

```text
update-content
  content_id: "<id of the form block>"
  custom: '{ "notification": { "to": "contact@example.net",
                               "subject": "Nouveau message — {{name}}",
                               "replyTo": "{{email}}" } }'
```

`notification` is **the name you gave the `sendmail` field** — there is no fixed key.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Tight braces, no spaces.</b> In <code>to</code>, <code>subject</code> and <code>replyTo</code>, a posted field interpolates as <code>{{name}}</code> — <b>not</b> <code>{{ name }}</code>. The mail template <i>body</i> accepts both; these three fields do not. Get it wrong and the subject line ships the literal braces to the recipient.</div></div>

`replyTo: '{{email}}'` is the line that makes the whole thing usable: the notification arrives from the site, but hitting Reply answers the visitor. Without it, someone copies the address out of the message body by hand, every time.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>On the platform sender, no captcha means no mail — silently.</b> <code>sendmail</code> only sends when a <code>recaptcha</code> rule validated on the submission (unless you declare your own <code>smtp</code>, which lifts the requirement). A form without the rule of section 8 stores its submissions but notifies no one, and nothing on the page says so. An interpolated recipient (<code>to: '{{mailto}}'</code>) additionally requires a spam check on the submission — the full gate is in <a href="/en/docs/custom-fields#when-the-mail-actually-leaves">Custom fields</a>.</div></div>

**`From` is not on this list, and cannot be set per block.** It defaults to the Quilium sender. Changing it means declaring your own SMTP.

### Your own SMTP server

Only needed when the mail must leave from your own domain — for deliverability, for SPF/DKIM alignment, or because a client insists their mail transits their server.

```yaml
        notification:
          type: sendmail
          label: Email notification
          config:
            template: 'mails/contact.liquid'
            smtp:
              host: smtp.example.net
              port: 465
              secure: true
              username: '****'
              password: '****'
              from: 'contact@example.net'
```

`from` is mandatory once `smtp` is declared. Port 465 with `secure: true` is the pairing that works with Gmail and Microsoft 365; port 587 goes with `secure: false` and STARTTLS.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>This YAML is site configuration, not a secret store.</b> A password written here is readable by anyone who can read the site's settings, and it lands in whatever repository holds your theme. The <code>****</code> above is not a placeholder for the docs — it is what belongs in any example, any ticket and any screenshot. If the mail can leave through the platform's own sender, that is one credential you never have to keep.</div></div>

Sharing one server across several forms is what YAML anchors are for — declare `_smtp: &smtp` once at the top of the file and merge it into each `sendmail` config. See the anchor pattern in [Content-type schema](/en/docs/content-types).

### The mail body

The email template is an ordinary `.liquid` file under `mails/`, and it reads its data **differently from a front-end template**. This is the part that costs an afternoon if nobody says it:

| What you want | How to read it in `mails/*.liquid` |
|---|---|
| A posted form field | `{{ name }}` — at the **root** |
| A field of the content block | `{{ _content.custom.title }}` — note the leading `_` |

```liquid
<h1>{{ _content.custom.title | default: 'Nouveau message' }}</h1>
<p><strong>Nom :</strong> {{ name | escape }}</p>
<p><strong>Email :</strong> {{ email | escape }}</p>
<p><strong>Message :</strong></p>
<p>{{ message | escape }}</p>
```

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Never write <code>{{ form.name }}</code> in a mail template.</b> It renders <b>empty, silently</b>. The submit button carries <code>name="form" value="{{ content.id }}"</code> so that Quilium can route the POST to the right block when a page hosts several forms — which means the POST body has a <code>form</code> key holding a block id, a string. <code>form.name</code> resolves against that string and yields nothing. Posted fields are at the root.</div></div>

And `| escape` on every posted value, for the same reason as everywhere else in this guide: the body is assembled from raw visitor input, and Liquid escapes nothing on its own. An email client is a rendering surface too.

### What we did not build

The demo sites carry no mailbox, so this section documents the shape rather than a live send — the YAML above was validated against a real site, the delivery was not exercised. Two consequences worth knowing before you trust it in production: a `sendmail` failure surfaces at submit time on a visitor's request, and the pipeline short-circuits, so a bad SMTP password can also cost you the `http` step behind it. Send yourself one real message before announcing the form.

## Pitfalls

- **Never trust the browser.** `required` in the markup is a convenience; the `validate` rules are the actual gate.
- **A form with no `save` and no `sendmail` accepts input and drops it.** Validation alone does nothing with a valid submission.
- **Renaming an input breaks its rule.** Rule keys match input names. Rename one, rename both.

## Related

<div class="q-cards">
  <a class="q-card reco" href="/en/docs/custom-fields"><span class="k">Recommended</span><span class="t">Custom fields</span><span class="d"><code>validate</code>, <code>save</code>, <code>sendmail</code> and their config.</span></a>
  <a class="q-card" href="/en/docs/blog"><span class="t">Build a blog</span><span class="d">The collection submissions land in.</span></a>
  <a class="q-card" href="/en/docs/template-variables"><span class="t">Variables and context</span><span class="d"><code>request.body</code> and <code>content.form</code>.</span></a>
</div>
