EDS for Forms
EDS extends the same edge-delivery model to Adaptive Forms. Authors compose forms in AEM Forms or in a document, and EDS renders them as fast, accessible, semantic HTML served from the CDN. The form runtime is just another block.
Two authoring paths
Document-based forms
The form definition lives in a spreadsheet (Excel or Google Sheets), one row per
field. When you preview/publish the sheet, EDS serves it as JSON (for example
/forms/contact.json). A page then references it with a Form block that contains a link
to that sheet:
| Form |
|-----------------------------------------------|
| https://main--site--org.aem.page/forms/contact.json |
The sheet columns describe each field. Exact column names depend on the block version you use; a typical definition looks like:
| Name | Type | Label | Placeholder | Mandatory | Options |
|---------|--------|------------|-----------------|-----------|------------------|
| first | text | First name | | true | |
| email | email | Email | you@example.com | true | |
| country | select | Country | | | Germany, France |
| submit | submit | Subscribe | | | |
The form block fetches the JSON and renders a semantic <form>. Start from a maintained
implementation rather than writing your own - the
aem-block-collection has a simple
spreadsheet-driven form, and aem-boilerplate-forms
contains the full Adaptive Forms block.
Adaptive Forms (AEM Forms)
For complex forms - multi-step flows, conditional fields, rules, server-side validation --
use the Adaptive Forms block from aem-boilerplate-forms, authored either in a
spreadsheet or in the Universal Editor on AEM Forms as a Cloud Service. EDS handles delivery;
the block's rule engine handles conditional logic, and AEM Forms handles submission actions
and storage.
This requires an AEM Forms entitlement.
The form block
Whichever authoring path you pick, the runtime side is a form block. Like any EDS block,
it's a folder of JS + CSS, often split into helper modules:
/blocks/form/
form.js
form.css
form-fields.js
The exact file layout differs between implementations (the Adaptive Forms block has many more modules), but the responsibilities are the same:
- decoration entry point: fetch the definition, build
<form>, wire submit - field rendering: text, email, select, radio, checkbox, textarea, file, date
- client-side validation and ARIA error wiring
- submission: collect values, post to the configured endpoint, handle success / error UI
Submission targets
Common targets:
| Target | Use case |
|---|---|
| AEM Forms Submission Service | Adobe-hosted service that writes submissions into the form's spreadsheet (e.g. an incoming sheet) |
| AEM Forms submit actions | For Adaptive Forms on AEM Forms: store in AEM, trigger a workflow, send email, call a form data model |
| External REST API | Anything else - CRM, marketing automation, custom backend |
| Webhook | For lightweight integrations (Slack, Teams, custom) |
For a custom endpoint, a submit helper posts JSON:
export async function submit(form, endpoint) {
const data = Object.fromEntries(new FormData(form).entries());
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(`Submit failed: ${res.status}`);
return res.json();
}
Validation
Client-side validation uses the HTML constraint API plus ARIA wiring:
<input type="email" name="email" required aria-describedby="email-error">
<p id="email-error" class="form-error" hidden>Please enter a valid email address.</p>
For server-side validation (especially with AEM Forms), the submit endpoint returns a structured error response that the block displays inline.
Spam and abuse
Edge delivery means the form is very fast to load - attackers will find it. Pick at least one of:
- CAPTCHA - reCAPTCHA, Cloudflare Turnstile, hCaptcha. Render in
delayed.jsto keep LCP clean. - Honeypot field - an
<input>hidden with CSS that humans never fill but bots often do. - Time check - record the load timestamp; reject submissions submitted faster than a human plausibly could.
- Rate limit at the CDN - limit requests per IP at the edge.
Accessibility
Keep these invariants when customising a form block:
- Every input has a programmatically associated
<label>(usefor/idor wrap) - Required fields use both
requiredandaria-required="true" - Errors use
aria-describedbyandrole="alert" - Submit buttons have a clear, unique label
- Focus is visible (don't strip
:focus-visibleoutlines)
Common patterns
Multi-step form
Group fields into fieldsets or panels in the definition and show one group at a time:
| Name | Type | Label | Fieldset |
|-------------|----------|------------------|-------------|
| personal | fieldset | Personal details | |
| first | text | First name | personal |
| last | text | Last name | personal |
| preferences | fieldset | Preferences | |
| topic | select | Topic | preferences |
JS hides the groups after the current one and wires Next / Previous buttons. The Adaptive Forms block ships a wizard layout for this, so prefer it over a hand-rolled version.
File upload to AEM Assets
For document upload forms, post the file to a backend that then forwards to AEM Assets via direct binary upload (AEM's own direct-binary upload flow is meant for authenticated author-side clients, not anonymous public forms). Never expose your Assets API credentials in the browser - have the backend either proxy the upload itself, or issue an application-owned upload URL that is authenticated, authorized, single-use, restricted to an allow-listed asset root, bound to the authenticated principal and the specific target asset/upload constraints (size, MIME type) it was issued for, and short-lived (a TTL of minutes, not hours). Either way, the backend must independently validate file size, MIME type, actual content, and filename/path before forwarding to AEM, apply rate limiting and malware scanning, and use an AEM service credential scoped to the minimum permissions needed (write access to the target DAM folder only).
Save draft
For long forms, persist values to localStorage after each change so a refresh
doesn't lose work:
form.addEventListener('input', () => {
const data = Object.fromEntries(new FormData(form).entries());
localStorage.setItem(`form-draft:${form.dataset.id}`, JSON.stringify(data));
});
Clear the draft on successful submit.
See also
- Blocks - the form block follows normal block conventions
- Customizing - response headers (CSP) for form submissions
- aem.live: forms documentation
- Adobe docs: EDS for Forms