Edge Delivery Services · 29 of 29
How the AEM Form Container works end to end (OSGi config, Sling Model, proxy servlet, ClientLib) and exactly how to rebuild the same capability in EDS. Covers the secret/proxy problem, the serverless function pattern, config via spreadsheets, per-country headers, validation, reCAPTCHA, and an honest what-is-possible-vs-not comparison.
This chapter covers:
formType
approaches, requestType, dynamic dropdowns, exact run steps, and the real
challenges we hitThis is a companion to chapter 27 (theming and inheritance). The same core idea returns here: AEM does work on the server at request time; EDS does not, so that work has to move somewhere else. Forms make that shift concrete because a form has a server-side secret that must never reach the browser.
Before any code, hold these two sentences in your head:
Everything in this chapter is a consequence of those two facts.
flowchart LR
subgraph AEM["AEM — server-side runtime"]
A1[Sling Model reads OSGi config]
A2[Proxy servlet attaches secret]
A3[Datasource servlet fills dropdowns]
A1 --> A2 --> A3
end
subgraph EDS["EDS — edge + browser only"]
E1[Block JS builds the form]
E2["who holds the secret?"]
E3["who fills the dropdowns?"]
end
AEM -. convert .-> EDS
The three questions on the EDS side are the entire migration story.
The component is assembled from five layers. Understanding each is what lets us map it cleanly to EDS.
| Layer | File / artifact | Responsibility |
|---|---|---|
| Definition | .content.xml | Declares the component, group, container flag |
| Author config | _cq_dialog | Fields the author fills (endpoint key, messages, reCAPTCHA) |
| Server logic | FormContainerImpl.java (Sling Model) | Exposes authored config + resolves inherited values |
| Config store | OSGi .cfg.json | Endpoints, domain, secret, proxy paths |
| Service | APILookupService | Builds the URL + headers, calls the API server-side |
| Markup | formcontainer.html (HTL) | Renders data-* attributes for the frontend |
| Frontend | ClientLib JS/CSS | Validation, reCAPTCHA, submit, success/error UX |
AEM does not scatter endpoint URLs and secrets through the code. It stores them in OSGi configuration, which is a typed key/value store with per-environment overrides. It works as a pair:
The schema — an @ObjectClassDefinition declares what can be configured and
the default values:
@ObjectClassDefinition(name = "abc Enterprise Service API")
public @interface Config {
@AttributeDefinition(name = "API Endpoint HTTP Domain")
String getDomainName() default "https://dev2.services.abc";
@AttributeDefinition(name = "API Secret Key") // encrypted
String getSecretKey() default "";
@AttributeDefinition(name = "API Endpoints") // "key::relativeUrl"
String[] getEnterpriseServiceApiEndpoints() default {
"siteSearch::/api/public/search/sitesearch",
"geolocation::/api/public/lookup/geolocation"
};
}
The values — a .cfg.json file in the ui.config module supplies the actual
values, and the filename is the fully qualified class name so AEM can match it to
the service. Three things make this powerful:
config.author, config.publish, config.prod) let the
same key hold different values per environment — dev domain in dev, real
domain in prod, with no code change.$[secret:...], never committed in
plaintext.@Modified with no restart.The critical takeaway: the secret lives on the server, in encrypted config, and is only ever read by server-side Java. This is the one guarantee EDS cannot make on its own.
APILookupService is a shared OSGi service that any component can inject. It does
three jobs:
Resolve a logical key to a full URL. Callers ask for "siteSearch", not a
hardcoded URL. Change the config and every caller follows.
Build the request headers from inherited page properties:
| Header | Source |
|---|---|
X-Application-Id | inherited page property siteName |
X-Preferred-Language | page locale language |
X-Country-Code | inherited page property countryCode (or locale) |
X-Origin-Secret | the OSGi secret key |
Note the pattern: one API serves many country sites, and the page tree decides which app/country/language headers go out. This is the same property inheritance idea as theming in chapter 27.
Execute the HTTPS call, check the status, and return the body.
This is the part most people miss — the service runs at two different times:
resourceType, GET only) calls the live API while an author is editing a dialog,
so a country dropdown shows real data. This only works because AEM can run a
server call during authoring.The HTL renders the config as data-* attributes:
<div data-js-component="${formcontainer.formMode}"
data-recaptcha="..." data-site-key="..."
data-form-type="..." data-form-name="...">
The ClientLib JS reads those attributes, validates, runs reCAPTCHA, and submits.
The crucial detail is where it submits to: not to the enterprise API directly,
but to an AEM proxy resource (a "proxy path"). AEM handles that request
server-side, attaches X-Origin-Secret, and forwards to the real API.
sequenceDiagram
participant B as Browser (form JS)
participant AEM as AEM proxy servlet
participant SVC as APILookupService
participant API as Enterprise API
B->>B: validate + reCAPTCHA
B->>AEM: POST form data (to proxy path)
AEM->>SVC: processRequest(page, endpoint, POST, body)
SVC->>SVC: build URL + headers (+ secret)
SVC->>API: HTTPS POST with X-Origin-Secret
API-->>SVC: JSON response
SVC-->>AEM: response body
AEM-->>B: success / error
B->>B: show message OR redirect to thank-you page
Why the proxy exists at all: if the browser called the API directly, the secret would have to be in browser code (a leak) and you would hit CORS. The proxy keeps the secret server-side. Remember this — it is the exact thing EDS has to solve.
Here is every piece of the AEM component and its EDS fate.
| AEM piece | EDS equivalent | Feasible? |
|---|---|---|
_cq_dialog (author config) | Block table rows in DA / UE fields (component-models.json) | Yes |
| HTL markup | Block JS builds the DOM | Yes |
| ClientLib CSS/JS | adc-form.css + adc-form.js | Yes |
| Client-side validation | Block JS | Yes |
| reCAPTCHA | Block JS (site key is public) | Yes |
| Success/failure message, thank-you redirect | Block JS / DOM | Yes |
| Analytics event tracking | data-* + delayed.js | Yes |
| OSGi config (endpoints, domain) | Published config sheet or block cell | Partial |
APILookupService (server call + headers) | A serverless function | Not in EDS itself |
Secret key (X-Origin-Secret) | Cannot live in the browser | Needs a backend |
Proxy servlet (/bin/adc/form-submit) | A serverless endpoint | Needs a backend |
| Datasource servlet (live dropdowns in dialog) | Published sheet or client fetch | Rework |
| Inherited page props to headers | Metadata / config sheet lookup | Partial |
Three of those rows — the secret, the proxy, and the datasource — are the whole challenge. Everything else is straightforward front-end work.
In AEM the browser POSTs to a proxy path and AEM attaches the secret
server-side. On an EDS site there is no /bin/... servlet — there is no server at
all. So the secret and the proxy have to move. There are three options, ordered
from most EDS-native to least.
A small serverless action (Adobe I/O Runtime, Cloudflare Worker, AWS Lambda, etc.) becomes the new proxy. It holds the secret in its environment, attaches the headers, calls the enterprise API, and returns the result.
browser → fetch('https://<your-function>/api/form-submit')
→ [function adds X-Origin-Secret + headers]
→ Enterprise API
processRequest + prepareRequestHeader logic — about forty
lines of JavaScript inside the function.If the enterprise API can be configured with CORS plus a browser-safe auth
(per-origin key, captcha-gated), the browser can call it directly and the proxy
disappears. This is only viable if the security team allows a browser-exposable
credential. The current X-Origin-Secret model says no — that secret is not
browser-safe — so this option usually does not apply here.
EDS renders the form and POSTs to your existing AEM publish proxy. It works on day one, but it couples the new EDS site back to AEM and needs CORS between the two domains. Useful as a transitional step, not a target architecture.
This is the concrete blueprint. Files live under blocks/adc-form/.
Model the form as a block table so authors edit it in DA / the Universal Editor. The current block uses this row structure:
| Row | Meaning |
|---|---|
| 1 | formType — the logical API endpoint key (e.g. contactUs) |
| 2 | successMessage |
| 3 | failureMessage |
| 4 | recaptcha — true to enable |
| 5+ | field rows: [type, name, label, required, placeholder, regex, errorMsg] |
Supported field types: text, email, tel, password, textarea, hidden.
For Universal Editor authoring, add the matching fields to
component-models.json (and keep models/_page.json in sync — see the sync gotcha
in chapter 27).
adc-form.js reads the table rows and builds the form: labels, required markers,
inputs/textareas, error containers, and the submit button. Key capabilities already
present in the port:
address.city serializes to
{ address: { city: value } }.value|version becomes
{ consentName, consentValue, consentVersion }.data-header="true" is sent as an HTTP
header instead of a body field.Each input validates on blur and on submit: required and an optional regex
with a custom error message, plus aria-invalid for accessibility. This is a
straight port of the AEM client validation.
Load the Google reCAPTCHA script with the public site key and request a token before submit. Because the site key is public by design, this ports with no security concern. The secret verification key stays in the serverless function (Step 6).
Replace .cfg.json with EDS-native config:
X-Application-Id, X-Country-Code) come
from page metadata (<meta> tags) or a config sheet keyed by path — because
EDS has no runtime property inheritance. This is exactly the theming pattern from
chapter 27 reused for form headers.Create a serverless function that:
X-Origin-Secret and the country/language/app headers.Then point the block at it:
// before (AEM-only, does not exist in EDS):
const PROXY_URL = '/bin/adc/form-submit';
// after (EDS):
const PROXY_URL = 'https://<your-function-host>/api/form-submit';
On submit: validate, disable the button, get the reCAPTCHA token, POST the JSON to the function, then either show the success message / redirect to the thank-you page, or show the failure message. All pure DOM, already in the port.
Fire tracking via data-* attributes read in delayed.js, and keep the
role="alert" / aria-live message regions and aria-invalid states from the
AEM component.
flowchart TD
A["adc-form block (DA/UE authored)"] --> B["adc-form.js: build + validate + reCAPTCHA"]
C["config sheet (endpoints, per-country)"] --> B
M["page metadata (appId, country)"] --> B
B -->|POST JSON| F["Serverless function (secret lives here)"]
F -->|+ X-Origin-Secret, headers| API[("Enterprise API")]
API --> F --> B
B --> R{success?}
R -->|yes| T["thank-you / success message"]
R -->|no| E["error message"]
X-Origin-Secret. It must live in a
serverless function./bin/adc/form-submit does not exist; it becomes a
serverless endpoint.ProxyComponentService / ProxyPaths) — an
AEM-internal routing concept that simply does not apply; endpoints become plain
URLs in config.| Concern | AEM | EDS |
|---|---|---|
| Form UI, validation, reCAPTCHA, UX | Yes | Yes (mostly ported) |
| Config storage | OSGi .cfg.json | Config sheet / metadata |
| Secret + API proxy | OSGi service + Sling servlet | External serverless function (required) |
| Author-time live dropdowns | Datasource servlet | Published sheet or client fetch |
| Runtime inheritance for headers | Page property inheritance | Metadata / sheet |
Everything above is the blueprint. This part is what we actually built and proved end to end — a real HTTPS submission from an authored EDS form, through a proxy that injects the secret, to the API, and back — plus the challenges we hit and how we solved them.
Result: it works. A form authored in the Universal Editor submits to the enterprise API and shows the response, with the secret never leaving the server — exactly mirroring the AEM Form Container.
The proxy is the EDS stand-in for FormSubmitServlet + APILookupService + OSGi
config. Its core is framework-agnostic so the same logic runs as a local Node
server for testing and as an Adobe I/O Runtime action in production.
// proxy-core.mjs — the essence (secret NEVER reaches the browser)
export async function handleSubmit(input, config) {
const { formType, context, body } = input;
if (!formType) return { status: 400, body: { error: 'Missing formType' } };
// formType may be a mapped KEY or the relative API PATH itself (see 6.2)
const isPath = formType.startsWith('/') || formType.startsWith('http');
const relEndpoint = config.endpoints[formType] || (isPath ? formType : undefined);
if (!relEndpoint) return { status: 400, body: { error: `Unknown formType` } };
const headers = {
'Content-Type': 'application/json',
'X-Application-Id': context.applicationId, // from page metadata
'X-Country-Code': context.countryCode, // from page metadata
'X-Preferred-Language': context.language, // from <html lang>
};
if (config.secretKey) headers['X-Origin-Secret'] = config.secretKey; // env only
const res = await fetch(config.domain + relEndpoint, {
method: 'POST', headers, body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
Map that back to AEM line by line: config.endpoints/config.domain = OSGi
.cfg.json; config.secretKey = the encrypted OSGi secret; the header block =
APILookupServiceImpl.prepareRequestHeader; the fetch = processRequest.
formType — key vs direct pathThis is the one design decision worth calling out, because both are valid and the real AEM content uses the second.
Approach A — formType is a key the proxy maps to a URL:
author sets: formType = newsletter
proxy env: API_ENDPOINTS = {"newsletter":"/api/v2/public/profile/subscriptions"}
Approach B — formType is the relative path itself (what the live AEM
formcontainer node actually stores):
author sets: formType = /api/v2/public/profile/subscriptions
We support both — the block sends x-form-type: <value>, and the proxy resolves
endpoints[value] || (isPath ? value : undefined). Approach B is what we used for
the /api/v2/public/profile/subscriptions example so the POC matches production
authoring exactly.
requestType — a body field, not a headerThe real AEM node carries requestType = newsletter_subscription. That is part of
the JSON payload, not a header. The block injects it right before submit:
const { body, headers } = serializeForm(form);
if (config.requestType && body.requestType === undefined) {
body.requestType = config.requestType; // → { email, consent, requestType: "newsletter_subscription" }
}
It is exposed as a Universal Editor field on the container, alongside Form Type and the success/failure messages.
AEM filled dropdowns at author time via a datasource servlet. EDS cannot call your
API during authoring, so we fill them at page load instead: a select whose
options are lookup:<key> triggers a runtime fetch to the proxy's lookup route,
which normalizes the same ESL response shapes ConvertToDropdownImpl handled
(response[0].codeValueList or response[], with an errorCode guard).
<select> options = "lookup:states" → GET /api/form-lookup?type=states
→ [{ value:"CA", label:"California" }, ...]
The block is safe to run with no backend at all. When no proxy URL is wired
into the content, adc-form runs in demo mode: it validates the fields and
simulates a successful submission (logged to the console), so you can verify the
UI and the authoring flow end to end before any API exists.
git push to main; served via aem-code-sync)./api/v2/public/profile/subscriptions, Request Type =
newsletter_subscription.email).For real submission, the browser can never hold the API secret (exactly why
AEM uses a server-side servlet). So the form POSTs to a small serverless
proxy that holds the secret + API domain in its env vars, adds the
X-Origin-Secret + context headers, and forwards the request. The blocker that
trips everyone up: the Universal Editor is HTTPS, so a localhost proxy is
refused (mixed content) — the proxy must be deployed and reachable over HTTPS.
Set the deployed HTTPS URL as the Proxy Endpoint URL (or the site-level
form-endpoint metadata) and the same authored form submits for real. The
browser request never carries the secret — that is the security guarantee.
These are the real friction points — worth knowing before you start:
| Challenge | Why it happens | Fix |
|---|---|---|
| Form submits in "demo mode", no network call | No proxy URL wired into content → block falls back to simulated success | Set Proxy Endpoint URL (or form-endpoint metadata) |
localhost proxy fails from UE | UE is HTTPS; browsers block mixed-content calls to http://localhost | Deploy the proxy over HTTPS |
| CORS error on submit | The canvas iframe origin is not allowed | Set proxy ALLOWED_ORIGINS (defaults to * for the POC; lock down in prod) |
| Fields/config disappear after editing in UE | Container decorate() rebuilds DOM and drops data-aue-* instrumentation | Use moveInstrumentation(sourceRow, builtEl) for each child |
| New model fields don't appear in UE | Block JS + component-models.json are served from main, not local | Commit and push before testing |
| Old JS runs after a deploy | sw.js service worker serves stale block JS | Hard-reload (Cmd+Shift+R) |
| Secret leaked risk | Any attempt to call the API from the browser | Keep the secret only in the proxy's env vars |
Demo mode is for authoring and UI verification only — it never calls a real API. For production, deploy the proxy logic as a real serverless function:
Then set the site-level form-endpoint metadata (the OSGi-config equivalent)
instead of authoring the URL per form, hold the secret + domain in the function's
env, and restrict ALLOWED_ORIGINS to your EDS domain.
It is not all trade-offs. Moving the form to EDS buys real advantages:
.cfg.json to a config sheet + metadata, with
secrets held only in the serverless function.The serverless-proxy pattern is now verified end to end on this POC (Part 6): an
authored EDS form submits through the proxy to the enterprise API and back, with the
secret held server-side. The remaining work is productionizing it — deploy the
proxy-core.mjs logic as an Adobe I/O Runtime action, move non-secret values to a
config sheet, and restrict CORS to the EDS domain. See
adc-form-api-integration-poc.md
for the implementation reference.
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.