Forms in templates
How a template draws a form that posts to a declared endpoint (form field render(), enquiryForm(), subscribeForm() or hand-built markup), carries the CSRF token and abuse controls, and renders the success and error responses.
A public form posts to a declared endpoint: an entry in config/sites/<key>/endpoints.yaml that names its path, its inputs and their rules, its spam defences and what it does with a submission. No Go is written. The template’s job is to draw the form from that declaration and to render the endpoint’s answer. Because the form is drawn from the declaration, an input added to endpoints.yaml appears in the form with its label, its rules and its error, in the visitor’s language.
This page is the template side. Declaring endpoints, their steps (store, mail, subscribe and the rest) and their spam defences is in Forms and submissions and the endpoints reference.
From page to answer
The page that shows a form and the endpoint that takes it are separate: the page is a document at its own address, and the form posts to the endpoint’s path. One post runs like this:
sequenceDiagram
participant B as Browser
participant E as Engine
B->>E: GET the page (/contact)
E-->>B: Page with the drawn form, action="/contact"
B->>E: GET /_e/forms.js, then the signed challenge
B->>E: POST /contact (inputs, _csrf, hidden fields)
E->>E: Cross-site check, rate limit, spam defences
E->>E: Validate the inputs against endpoints.yaml
alt invalid
E-->>B: 422 with on_error (or the form redrawn with errors)
else valid
E->>E: Steps in order: store, mail, subscribe ...
E-->>B: respond.fragment
endA form posted in place gets the answer as a patch; a plain post gets it inside your layout (Rendering the answer).
The form’s address and locale
action is the endpoint’s path with the site’s path prefix and the request locale’s prefix, and nothing else. An endpoint has one path for every locale, so on a Welsh page at /cy/cysylltu the form posts to /cy/contact, not to the page’s own address. The locale prefix tells the endpoint which language to answer in, and that is the locale its labels, messages, response templates and mail are rendered in.
A page and an endpoint may share an address: a page at /contact (a GET) and an endpoint with path: /contact and methods: [post] do not collide, because declared endpoints are matched before content only for the methods they declare. Give the endpoint its own path (/contact/send) if you prefer the two to differ.
Three ways to draw a form
| Way | Use it when |
|---|---|
A form field’s render() | Editors choose which form a page shows. The engine draws the whole accessible form |
enquiryForm('key') or subscribeForm('key') printed as it is | A template always shows one form. The engine draws the whole form |
Your own markup from the description enquiryForm() returns | You need markup the drawn form does not give |
A form field
Give the type a field of type form (the form field). Editors pick one of the site’s form endpoints, and the template renders it:
{% if entry.contactForm %}
<section aria-labelledby="contact-heading">
<h2 id="contact-heading">{{ t("form.contact.title") }}</h2>
{{ entry.contactForm.render(submit=t("form.contact.send"), class="panel") }}
</section>
{% endif %} render() options:
| Option | Default | Effect |
|---|---|---|
submit | The string form.<key>.submit, else form.submit, else the endpoint’s key as words | The button’s text |
enhance | true | false leaves out the in-place post, so the form is a plain post with or without JavaScript |
| any other keyword | An attribute of the <form> element (class=, aria-labelledby=, data-*) |
id, action, method, enctype and data-on:submit belong to the form and are refused. render() takes keywords only.
The value also has the description’s properties (id, key, action, method, inputs, hidden, honeypot, honeypotName, member). An empty field, or one naming an endpoint the site no longer declares, is false and prints nothing.
An endpoint a form field can name must take a post from the public or a signed-in member, answer with a page (not JSON or a stream), declare at least one input from the form, and take nothing from its path.
enquiryForm() and subscribeForm()
{{ enquiryForm('contact') }}
{{ enquiryForm('contact-listing', listing) }}
{{ subscribeForm('newsletter') }} Printed as it is, the value draws the same accessible form as a form field’s render(), as a plain post (no in-place enhancement). The second argument is the subject an endpoint’s path parameters are filled from: for path: /enquire/{listing} with an input listing: { type: relation, from: path }, enquiryForm('contact-listing', listing) posts to /enquire/<the listing's id>.
Two things engine-dmo check does not catch, so render the page to confirm the form is there:
- An unknown key prints nothing.
{{ enquiryForm('contcat') }}passes the check and renders an empty string. - There is no
render()on it. Only aformfield’s value hasrender().{{ enquiryForm('contact').render() }}passes the check and prints nothing. PrintenquiryForm()as it is, or build markup from it.
Your own markup
enquiryForm() and subscribeForm() return the endpoint’s description. Build the form from it:
{% with form = enquiryForm('contact') %}
<form method="post" action="{{ form.action|url }}" id="form-contact"
data-on:submit="@post(el.getAttribute('action'), {contentType: 'form'})"
{% if form.challenge %}data-mrd-challenge="{{ form.challenge }}"{% endif %}>
{{ csrfInput() }}
{% for k, v in form.hidden %}<input type="hidden" name="{{ k }}" value="{{ v }}">{% endfor %}
{% if form.honeypot %}
<p hidden aria-hidden="true"><label for="hp">{{ t("form.leave.blank") }}</label>
<input id="hp" type="text" name="{{ form.honeypotName }}" tabindex="-1" autocomplete="off"></p>
{% endif %}
{% if errors %}{% focus '#form-contact-errors' %}
<div role="alert" id="form-contact-errors" tabindex="-1">
<h2>{{ t("form.problems", errors._count) }}</h2>
<ul>{% for e in errors._summary %}<li><a href="#f-{{ e.path }}">{{ e.message }}</a></li>{% endfor %}</ul>
</div>
{% endif %}
{% for fld in form.inputs %}
<p>
<label for="f-{{ fld.name }}">{{ fld.label }}{% if fld.required %} <span aria-hidden="true">*</span>{% endif %}</label>
{% for issue in errors[fld.name] %}<span id="f-{{ fld.name }}-error">{{ issue.message }}</span>{% endfor %}
{% if fld.multiline %}
<textarea id="f-{{ fld.name }}" name="{{ fld.name }}"{% if fld.max %} maxlength="{{ fld.max }}"{% endif %}>{{ values[fld.name] }}</textarea>
{% else %}
<input id="f-{{ fld.name }}" name="{{ fld.name }}" value="{{ values[fld.name] }}"
type="{% if fld.format == 'email' %}email{% else %}text{% endif %}"
{% if fld.autocomplete %}autocomplete="{{ fld.autocomplete }}"{% endif %}
{% if errors[fld.name] %}aria-invalid="true" aria-describedby="f-{{ fld.name }}-error"{% endif %}>
{% endif %}
</p>
{% endfor %}
<button type="submit">{{ t("form.contact.send") }}</button>
</form>{{ form.challengeScript }}
{% endwith %} | Property | Value |
|---|---|
key | The endpoint’s key |
action | The address to post to, with the site’s path prefix and the locale prefix |
method | POST |
inputs | The declared inputs in order, each name, type, label, help, required, autocomplete, multiline, max, format, options and visible_when where declared. Inputs taken from the path are left out |
hidden | The hidden fields the spam defences need, name to value |
honeypot, honeypotName | Whether the endpoint has a honeypot, and its field’s name |
challenge | The endpoint’s key when it uses the form challenge, else empty; print it as data-mrd-challenge |
challengeScript | The <script> that fetches the challenge once the page has loaded, or nothing |
csrf | Whether the endpoint checks the CSRF token |
An input with options has them in options, but the drawn form does not draw a <select> or radio buttons from them. An endpoint with a choice input needs your own markup for that input.
A label not declared in the endpoint is its input’s name as words (contactName reads “Contact name”).
The CSRF token
Every unsafe request (a post) is checked against cross-site request forgery, in one of two ways:
- An anonymous visitor has no session for a token to bind to, so the post is checked by where it comes from: the browser’s
Sec-Fetch-Sitemust say same-origin (or none), else theOriginheader’s host must be the request’s host. A cross-site post is refused with 403, “the form was posted from another site”. The token helpers print nothing for an anonymous visitor and set no cookie, so a public page is the same for every visitor and can be page-cached. - A signed-in visitor (a member, or staff) carries a token bound to their session in a field named
_csrf(or theX-CSRF-Tokenheader). A post without a valid token is refused with 403, “the form has expired, reload the page and try again”.
So a public page served to an anonymous visitor has an empty _csrf and sets no cookie on a running server as well as under render, and that is correct, not a fault. Print the token in every form you write yourself, so both cases work:
| Helper | Prints |
|---|---|
csrfInput() | <input type="hidden" name="_csrf" value="...">, or nothing for an anonymous visitor |
csrfToken(), csrf | The token alone, or empty |
Drawn forms carry it already: for an anonymous visitor the drawn form holds an empty _csrf field, which is harmless. A page that printed a token is never stored in the page cache, and a {% cache %} block that names a token helper is an error (Caching).
engine-dmo render has no visitor and no session, so under it csrfInput(), csrfToken() and csrf print nothing and a drawn form’s _csrf is empty. That is expected; check the token on a running server, signed in.
Spam defences in the markup
The defences are declared on the endpoint (spam: { honeypot, min_fill_time, proof_of_work }, plus rate_limit). A drawn form carries what they need; markup of your own must carry:
- every field of
hidden; - the honeypot field, hidden from people and from assistive technology, when
honeypotis true; data-mrd-challengeandchallengeScriptwhenchallengeis set. The script (/_e/forms.js, served by the engine, so allowed byscript-src 'self') fetches a signed challenge that dates the form and, for a browser that can compute it, a small proof of work. A browser without JavaScript can still post; the challenge is a cost at volume, never a gate. There is no CAPTCHA.
A script posts as that browser does. With min_fill_time set, a post with no challenge answers 422 and draws the form again, so fetch one first: GET /_e/challenge/<key> answers field, token, minFill, proofField and proofBits (the steps). The proof of work is optional.
Rendering the answer
The endpoint’s respond block names the templates:
respond:
fragment: partials/contact-sent.html # after a successful post
on_error: partials/contact-form.html # after a failed one {# templates/_shared/partials/contact-sent.html #}
<div id="form-contact" role="status">
<h2>{{ t("form.contact.sent.title") }}</h2>
<p>{{ t("form.contact.sent.body") }}</p>
</div> How the answer arrives depends on the request:
| Request | Answer |
|---|---|
The in-place post (sigmx, from data-on:submit) | The rendered fragment as a patch the page applies by element id, so give the fragment’s root the form’s id. A failure is sent the same way |
| A plain post (no JavaScript) | The fragment inside your base.html, as its {% block body %}, so your layout must have that block. The status is the endpoint’s own (422 for a validation failure) |
A GET to a fragment endpoint, or a project with no base.html | The fragment alone |
A form endpoint, one that takes nothing from its path, with no on_error draws its own form again with the errors, as a form field’s render() draws it, and that is usually what you want. on_error is for a hand-built form: it is rendered with the errors and the values posted.
The root element of a response template must have the id form-<endpoint key>, the id the drawn form has, since an in-place post replaces the element by that id. <div id="form-{{ endpoint.key }}"> lets one template serve several endpoints. A response template has the base page context and the variables below, and no entry, no page values and no type variable such as listing.
An endpoint that takes a value from its path (/enquire/{listing}) must have an on_error. It is not treated as a form, so without one a failed post answers 422 with the success fragment. Print {{ enquiryForm(endpoint.key) }} in the partial: with no subject it takes the address from the post that failed, so it posts to the same listing again, with the errors and a fresh challenge. The same partial serves any form endpoint.
What the answer templates see
| Variable | Value |
|---|---|
errors | After a failure: each input’s issues by name (a list of code, message, hint), _form for issues of the whole form, _summary (one path and message per input, in form order) and _count (the number of inputs with a problem). Empty after a success |
values | The values posted, by input name, to fill the form again |
submission | After a success: the stored submission |
signals | The values a signal step set |
endpoint | The endpoint that answered (key and the rest of the description). A layout that also carries the same form, such as a footer sign-up, can leave its own copy out with {% if endpoint.key != "newsletter" %} |
subscriber | After a subscription endpoint: the subscriber |
Answer templates can compose a richer in-place response with {% patchelements %}, {% patchsignals %}, {% removeelements %}, {% focus %}, {% announce %}, {% location %} and {% replaceurl %} (Tags).
The drawn form’s markup
A form drawn by render() or by printing enquiryForm() has fixed ids, so CSS and a response fragment can target them:
| Element | id or class |
|---|---|
The <form> | id="form-<key>", class="mrd-form" |
| Each input | id="f-<key>-<name>", its label for it; help in f-<key>-<name>-help, the error in f-<key>-<name>-error, both wired with aria-describedby; aria-invalid="true" on a failed input |
| The error summary | id="form-<key>-errors", role="alert", focused after a failed in-place post; each item links to its input |
| The submit button | id="form-<key>-submit" |
| The honeypot | class="mrd-hp", hidden |
Inputs are drawn as a text input (type="email", type="url" or type="tel" for the input’s format email, url or phone), a <textarea> for multiline, or a checkbox for a boolean. Nothing else is drawn: an input’s options never become a <select> or radio buttons. Required inputs carry required and a visual asterisk hidden from screen readers.
Strings the drawn form reads
| String | Used for |
|---|---|
form.<key>.submit, form.submit | The button’s text |
form.problems | The error summary’s heading, plural-aware with {count} |
form.leave.blank | The honeypot’s label, in the drawn form and in a hand-built one. Without the string the label prints as its id, form.leave.blank. The field is hidden, but its label is still in the markup |
Add them to every locale’s config/strings/<locale>.yaml. Labels, help and validation messages come from the endpoint’s own label, help and rule message keys, per locale: give each input’s label a value for every locale the site enables. engine-dmo check warns about a schema field label with no translation, but not about an endpoint input’s label, which falls back to the source locale’s.
Email templates
A mail step renders templates/<root>/emails/<name>.html on the site’s chain (templates/_shared/emails/ in a single-site project; the check’s message says templates/emails/, meaning that folder in any root). The template extends emails/_layout.html, the engine’s layout, and fills its subject and body blocks. The full list of blocks and the engine’s own templates are in Mail.
{# templates/_shared/emails/contact-to-staff.html #}
{% extends "emails/_layout.html" %}
{% block subject %}New enquiry from {{ name }}{% endblock %}
{% block body %}
<p>{{ name }} ({{ email }}) wrote:</p>
<blockquote>{{ message }}</blockquote>
{% endblock %} An email template is not a page template. It sees:
| Name | Value |
|---|---|
| each input, by name | What the visitor posted (name, email, message) |
submission | The stored submission, when a store step ran first |
replyTo | The address the step’s reply_to resolved to, for the template to print. It does not set the message’s Reply-To header |
site.name, site.url | The site’s name in the mail’s locale, and its address |
recipient.email, recipient.displayName, recipient.locale | Who it is for |
locale | The mail’s locale as a code string (cy), not the page’s locale object: write {{ locale }} or {% if locale == "cy" %}, never locale.code |
now | The time of rendering |
Every mail a step sends, the staff copy included, is rendered in the locale of the post: the locale prefix of the form’s action.
No t() in email templates
The string catalogue is not available to email templates: t() prints nothing there, and engine-dmo check does not report it. On a site with more than one locale, write each language’s text in the template and branch on locale:
{% block subject %}{% if locale == "cy" %}Diolch am eich neges{% else %}Thank you for your message{% endif %}{% endblock %} engine-dmo check compiles each email template, but it does not catch a field read on a plain value (locale.code) or an empty subject. Send one to the log before you rely on it: in development the log driver writes the message to the output instead of sending it.
engine-dmo mail test --to you@example.org --template contact-to-staff --locale cy A send that fails reports the template’s line and column, such as not sent: _shared/emails/contact-to-staff.html:3:34: can't access a field by name on type string (variable locale.code). The sample context mail test uses has no form inputs, so they print empty there.
In-place posts need sigmx on the page
data-on:submit is a sigmx attribute. The drawn form posts in place only when your bundle loads the sigmx client, which the example projects do from their Vite entry (Frontend build). Without it, the form is a plain post and still works.
Member forms
An endpoint with access: member is drawn only for a signed-in member, and a page that renders one is sent Cache-Control: private, no-store, since what it shows depends on who asked.
Site search
searchForm() prints a GET form to the site’s search route in the request locale, labelled from the strings search.field and search.go, working without JavaScript. It prints nothing when the site routes no search. See Search.
Related
- Forms and submissions: declaring endpoints, steps, storage and retention.
- Caching: why a page with a form is not page-cached.
- Tags: the response tags.
SEO and structured data
What head() and jsonld() print, how titles, descriptions, canonical URLs, hreflang, robots and Open Graph are decided, how to map a content type to schema.org, and how the sitemap and robots.txt are configured.
Multisite templates
How several sites in one project share templates through an ordered chain of template roots, override a component, a block or a whole file for one site, extend their own name across the chain, and detect copies that have drifted.