N
Naveenr.dev
Chapter 27
38 min read•2026-09-08
📖 Edge Delivery Services SeriesChapter 27 · 27 chapters

Forms and API Integration in EDS

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.

Content Objective

This chapter covers:

  • How the AEM Form Container component works today, end to end, layer by layer
  • Where and how AEM stores configuration and secrets (OSGi), and why
  • How a form submission travels from the browser to the enterprise API — and back
  • What of that whole stack can be rebuilt in EDS, and what cannot
  • The single biggest gap — the API secret and the proxy — and the correct fix
  • A step-by-step blueprint to build the same form as an EDS block
  • An honest, side-by-side "possible vs not possible" comparison
  • A verified, working build (Part 6): the proxy code, both formType approaches, requestType, dynamic dropdowns, exact run steps, and the real challenges we hit

This is a companion to chapter 25 (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.


The Mental Model Shift

Before any code, hold these two sentences in your head:

  1. AEM Form Container is a server-side component. When a page renders, AEM runs Java (a Sling Model), reads server-only configuration (OSGi), and when the form submits, AEM runs more Java (a servlet) that attaches a secret and calls the enterprise API. The browser never sees the secret and never talks to the API directly.
  2. EDS has no server runtime. There is no Java, no OSGi, no Sling servlet. An EDS page is a static HTML document plus JavaScript that runs on the CDN edge and in the browser. Anything that must run on a trusted server has to live outside EDS — in a small serverless function.

Everything in this chapter is a consequence of those two facts.

mermaid
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.


Part 1 — How the AEM Form Container Works Today

The component is assembled from five layers. Understanding each is what lets us map it cleanly to EDS.

LayerFile / artifactResponsibility
Definition.content.xmlDeclares the component, group, container flag
Author config_cq_dialogFields the author fills (endpoint key, messages, reCAPTCHA)
Server logicFormContainerImpl.java (Sling Model)Exposes authored config + resolves inherited values
Config storeOSGi .cfg.jsonEndpoints, domain, secret, proxy paths
ServiceAPILookupServiceBuilds the URL + headers, calls the API server-side
Markupformcontainer.html (HTL)Renders data-* attributes for the frontend
FrontendClientLib JS/CSSValidation, reCAPTCHA, submit, success/error UX

1a. Where AEM stores values — OSGi configuration

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:

java
@ObjectClassDefinition(name = "Abbott Enterprise Service API")
public @interface Config {
    @AttributeDefinition(name = "API Endpoint HTTP Domain")
    String getDomainName() default "https://dev2.services.abbott";

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

  • Runmode folders (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.
  • Secrets are encrypted and injected as $[secret:...], never committed in plaintext.
  • Hot reload — the service re-reads config via @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.

1b. The service — how AEM calls the API

APILookupService is a shared OSGi service that any component can inject. It does three jobs:

  1. Resolve a logical key to a full URL. Callers ask for "siteSearch", not a hardcoded URL. Change the config and every caller follows.

  2. Build the request headers from inherited page properties:

    HeaderSource
    X-Application-Idinherited page property siteName
    X-Preferred-Languagepage locale language
    X-Country-Codeinherited page property countryCode (or locale)
    X-Origin-Secretthe 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 25.

  3. Execute the HTTPS call, check the status, and return the body.

1c. The two moments the service is used

This is the part most people miss — the service runs at two different times:

  • Author time — filling dropdowns. A datasource servlet (bound by 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.
  • Runtime — the form config. The Sling Model exposes the authored settings (endpoint, messages, callbacks, proxy path, reCAPTCHA) to the HTL, and — because it is a Sling Model Exporter — also as JSON.

1d. The runtime submission — where the secret stays safe

The HTL renders the config as data-* attributes:

html
<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.

mermaid
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.


Part 2 — What Maps to EDS, Piece by Piece

Here is every piece of the AEM component and its EDS fate.

AEM pieceEDS equivalentFeasible?
_cq_dialog (author config)Block table rows in DA / UE fields (component-models.json)Yes
HTL markupBlock JS builds the DOMYes
ClientLib CSS/JSadc-form.css + adc-form.jsYes
Client-side validationBlock JSYes
reCAPTCHABlock JS (site key is public)Yes
Success/failure message, thank-you redirectBlock JS / DOMYes
Analytics event trackingdata-* + delayed.jsYes
OSGi config (endpoints, domain)Published config sheet or block cellPartial
APILookupService (server call + headers)A serverless functionNot in EDS itself
Secret key (X-Origin-Secret)Cannot live in the browserNeeds a backend
Proxy servlet (/bin/adc/form-submit)A serverless endpointNeeds a backend
Datasource servlet (live dropdowns in dialog)Published sheet or client fetchRework
Inherited page props to headersMetadata / config sheet lookupPartial

Three of those rows — the secret, the proxy, and the datasource — are the whole challenge. Everything else is straightforward front-end work.


Part 3 — The Core Problem: the Secret and the Proxy

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.

code
browser → fetch('https://<your-function>/api/form-submit')
        → [function adds X-Origin-Secret + headers]
        → Enterprise API
  • What moves: the processRequest + prepareRequestHeader logic — about forty lines of JavaScript inside the function.
  • The secret lives in the function's environment variables and is never shipped to the browser. This is the correct security model, identical in intent to AEM's server-side proxy.
  • "OSGi config" becomes the function's environment variables plus a published config sheet for the non-secret values.

Option B — The API accepts browser calls directly

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.

Option C — Keep AEM as the form backend, EDS as the frontend (hybrid)

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.


Part 4 — Step-by-Step: Building the Form as an EDS Block

This is the concrete blueprint. Files live under blocks/adc-form/.

Step 1 — Author model (what the author fills)

Model the form as a block table so authors edit it in DA / the Universal Editor. The current block uses this row structure:

RowMeaning
1formType — the logical API endpoint key (e.g. contactUs)
2successMessage
3failureMessage
4recaptcha — 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 25).

Step 2 — Build the form DOM (block JS)

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:

  • Nested field names via dot notation — address.city serializes to { address: { city: value } }.
  • Consent checkbox groups — value|version becomes { consentName, consentValue, consentVersion }.
  • Header fields — an input flagged data-header="true" is sent as an HTTP header instead of a body field.

Step 3 — Client-side validation

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.

Step 4 — reCAPTCHA

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).

Step 5 — Configuration without OSGi

Replace .cfg.json with EDS-native config:

  • Non-secret values (endpoint URLs, per-country app IDs) go in a published config sheet (a spreadsheet the block fetches at runtime) or in block cells.
  • Per-country / per-site headers (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 25 reused for form headers.

Step 6 — The serverless proxy (the secret's new home)

Create a serverless function that:

  1. Receives the POST from the browser.
  2. Reads the secret from its environment (never from the request).
  3. Adds X-Origin-Secret and the country/language/app headers.
  4. Calls the enterprise API and returns the response.

Then point the block at it:

js
// 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';

Step 7 — Submit, then success or error UX

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.

Step 8 — Analytics and accessibility

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.

The resulting EDS architecture

mermaid
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"]

Part 5 — What Is Possible vs Not, Honestly

Fully possible in EDS

  • The entire form UI — fields, labels, required markers, nested names, consent checkbox groups.
  • Client-side validation with regex and custom messages.
  • reCAPTCHA (public site key).
  • Author configuration via block tables / UE fields.
  • Success/error UX and thank-you redirect.
  • Analytics tracking.
  • Non-secret configuration via a published config sheet.

Partially possible (modeled differently)

  • Per-country/site headers. In AEM these come from inherited page properties; in EDS they come from metadata or a config sheet. Doable, different mechanism.
  • OSGi config values. Become a config sheet or block cells — except secrets, which stay in the serverless function.

Not possible in EDS itself (needs a backend)

  • Holding the API secret — no server, so no X-Origin-Secret. It must live in a serverless function.
  • The proxy servlet — /bin/adc/form-submit does not exist; it becomes a serverless endpoint.
  • Author-time datasource dropdowns — EDS authoring cannot call your API while editing. Replace with a published options sheet or a client-side fetch at page load.
  • Server-side response transforms — move to the serverless function or client JS.
  • The proxy-path indirection (ProxyComponentService / ProxyPaths) — an AEM-internal routing concept that simply does not apply; endpoints become plain URLs in config.

Side-by-side summary

ConcernAEMEDS
Form UI, validation, reCAPTCHA, UXYesYes (mostly ported)
Config storageOSGi .cfg.jsonConfig sheet / metadata
Secret + API proxyOSGi service + Sling servletExternal serverless function (required)
Author-time live dropdownsDatasource servletPublished sheet or client fetch
Runtime inheritance for headersPage property inheritanceMetadata / sheet

Part 6 — Verified on This POC: the Working Build

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.

6.1 The serverless proxy — the whole point in ~40 lines

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.

js
// 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.

6.2 Two approaches for formType — key vs direct path

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

code
author sets:  formType = newsletter
proxy env:    API_ENDPOINTS = {"newsletter":"/api/v2/public/profile/subscriptions"}
  • The relative path never appears in content; the proxy allow-lists every endpoint.
  • Cost: every new endpoint needs a proxy config change.

Approach B — formType is the relative path itself (what the live AEM formcontainer node actually stores):

code
author sets:  formType = /api/v2/public/profile/subscriptions
  • 1:1 with existing AEM authoring; no per-endpoint proxy change.
  • The proxy accepts a raw path only when it is not a known key, so the allow-list still governs mapped keys.

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.

6.3 requestType — a body field, not a header

The real AEM node carries requestType = newsletter_subscription. That is part of the JSON payload, not a header. The block injects it right before submit:

js
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.

6.4 Dynamic dropdowns (the datasource replacement)

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).

code
<select> options = "lookup:states"  →  GET /api/form-lookup?type=states
                                     →  [{ value:"CA", label:"California" }, ...]

6.5 Clear steps to try it yourself

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.

  1. Deploy the block + model (git push to main; served via aem-code-sync).
  2. In the Universal Editor, add the ADC Form block. Hard-reload the canvas (the service worker caches block JS).
  3. Leave Proxy Endpoint URL empty (demo mode), set Form Type = /api/v2/public/profile/subscriptions, Request Type = newsletter_subscription.
  4. Add child fields (at minimum a required email).
  5. Preview → submit → the block shows the success message; the simulated submit is logged in DevTools → Console.

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.

6.6 Challenges we actually hit (and the fixes)

These are the real friction points — worth knowing before you start:

ChallengeWhy it happensFix
Form submits in "demo mode", no network callNo proxy URL wired into content → block falls back to simulated successSet Proxy Endpoint URL (or form-endpoint metadata)
localhost proxy fails from UEUE is HTTPS; browsers block mixed-content calls to http://localhostDeploy the proxy over HTTPS
CORS error on submitThe canvas iframe origin is not allowedSet proxy ALLOWED_ORIGINS (defaults to * for the POC; lock down in prod)
Fields/config disappear after editing in UEContainer decorate() rebuilds DOM and drops data-aue-* instrumentationUse moveInstrumentation(sourceRow, builtEl) for each child
New model fields don't appear in UEBlock JS + component-models.json are served from main, not localCommit and push before testing
Old JS runs after a deploysw.js service worker serves stale block JSHard-reload (Cmd+Shift+R)
Secret leaked riskAny attempt to call the API from the browserKeep the secret only in the proxy's env vars

6.7 Production vs POC

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:

  • Adobe I/O Runtime — closest to the AEM stack.
  • or a Cloudflare Worker / Vercel / AWS Lambda.

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.


Where EDS Is Actually Better

It is not all trade-offs. Moving the form to EDS buys real advantages:

  • Performance — the form ships as static HTML from the CDN edge, no AEM publish render on every page view.
  • A clean security boundary — the secret lives in exactly one small function with one job, instead of being threaded through a component stack.
  • Decoupling — the form frontend and the API backend evolve independently; the serverless function is the only contract between them.
  • Simplicity — no OSGi, no proxy-path indirection, no datasource servlets; just a block, a config sheet, and one function.

Key Takeaways

  • AEM Form Container is server-side: a Sling Model reads OSGi config, and a proxy servlet attaches a secret before calling the enterprise API. The browser never sees the secret.
  • EDS has no server runtime, so anything requiring a trusted server — the secret and the proxy — must move to a serverless function.
  • About 80% of the component (UI, validation, reCAPTCHA, UX, analytics) ports to an EDS block cleanly and is largely already done.
  • The remaining 20% — the secret, the proxy, and author-time server calls — cannot live in EDS. That is not a limitation to fight; it is the correct EDS security model.
  • Configuration moves from OSGi .cfg.json to a config sheet + metadata, with secrets held only in the serverless function.
  • Per-country headers use the same metadata-driven pattern as theming in chapter 25, because EDS has no runtime property inheritance.

What's Next

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.

Enjoyed this chapter?

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.