Skip to content
Quilium Docs
Back to site
How-to guides

Build a complete contact form

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.

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.

Live example: Oscilla. Every snippet on this page comes from Oscilla, a bilingual demo site for an observability platform. Its contactForm block validates a demo request, saves it to the demoSubmissions collection, accepts an optional attachment and redirects to /merci.

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.

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.

Do not add a "submitted at" field. A save does not honour default: now, so the field would land empty. Every entry already carries a system created_at; put that in displays.cols instead.

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.

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.

{% 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:

{%- 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:

{ "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

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:

        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:

            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:

        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:

<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:

"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:

{% 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.

An uploaded file is a public URL. It lands in the media library like any other asset, and /media/<uuid>.<ext> 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.
file-size informs the visitor, it does not protect the server. The file is received in full before any rule looks at it, so max 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 file-size 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.

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:

    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:

{% 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.

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.

This rule fires even when the field is absent, and that is the point. Measured on Oscilla: a POST carrying no g-recaptcha-response at all is rejected exactly like one carrying a forged token. That is the opposite of file-extensions and file-size, 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.

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.":

{%- 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 }}
Liquid string literals have no escape character. Writing 'vous n''êtes pas' does not produce an apostrophe, it ends the string early and silently truncates the message to "vous n". Use the typographic apostrophe ’ — better typography, and it cannot close the quote.

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:

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

    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:

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.

Tight braces, no spaces. In to, subject and replyTo, a posted field interpolates as {{name}} — not {{ name }}. The mail template body accepts both; these three fields do not. Get it wrong and the subject line ships the literal braces to the recipient.

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.

On the platform sender, no captcha means no mail — silently. sendmail only sends when a recaptcha rule validated on the submission (unless you declare your own smtp, 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 (to: '{{mailto}}') additionally requires a spam check on the submission — the full gate is in Custom fields.

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.

        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.

This YAML is site configuration, not a secret store. 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 **** 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.

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.

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 _
<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>
Never write {{ form.name }} in a mail template. It renders empty, silently. The submit button carries name="form" value="{{ content.id }}" so that Quilium can route the POST to the right block when a page hosts several forms — which means the POST body has a form key holding a block id, a string. form.name resolves against that string and yields nothing. Posted fields are at the root.

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


Last updated September 6, 2026.