Developer Demo
Forms & Data Capture
Native Optimizely Forms - activate in CMS settings, build forms in the form builder, drag them into any Visual Builder experience. Submissions post to your webhook endpoint and feed the personalization loop: capture to ODP profile to FX audience to targeted content.
Live Demo #
The form below is rendered directly by the five OptiFormsXxx components with static mock data - the same props the SDK passes when serving a real form from Visual Builder. Fill it in and submit: the button collects all fields via DOM query, POSTs to /api/form-submit, and shows the exact payload sent and the API response received.
Contact Support
Send us a message and we'll get back to you within one business day.
1. Activate Forms in the CMS #
Before creating forms, go to Settings > Forms Settings > Activate in the CMS admin. This is a one-time, irreversible step that enables the native form content types (OptiFormsContainerData, OptiFormsTextboxElement, and others) in the GraphQL schema and in Visual Builder's block picker. After activation, build forms using the CMS form builder and drag them into any DynamicExperience page.
Important constraints
- Native forms only work inside DynamicExperience (Visual Builder). Dragging a form onto a ContentArea in a traditional page has no effect.
- Do not run
opti:pushfor native form types - they are already in the CMS. The SDK schema hints for fragment generation live incomponentRegistry.ts, not insrc/components/**/*.tsx. - OptiFormsSelectionElement field names are not what you expect. The Graph schema uses
Options(a JSON scalar, not anItemsarray) andAllowMultiSelect(notAllowMultipleChoices). Registering the wrong field names incomponentRegistry.tsbreaks the SDK's auto-generated composition fragment and causes every page to return 404 - Graph rejects the unknown fields at schema validation time.
2. How It Works #
Native form elements render as flat siblings in a Visual Builder experience. The submit element uses DOM-scoped field collection (the same approach as before) - no React context or prop-drilling needed.
1. OptiFormsContainerData
The form container. Renders the title and description. Sets data-form-submit-url and data-form-success-message from SubmitUrl.default and SubmitConfirmationMessage.
2. Form element components
Each native element type renders the appropriate HTML element. OptiFormsTextboxElement - input, OptiFormsTextareaElement - textarea, OptiFormsSelectionElement - select. The name attribute is derived from Label (slugified). Required state comes from the Validators array.
3. OptiFormsSubmitElement
A "use client" component. On click: reads data-form-submit-url, collects all inputs in the page scope via DOM query, validates required fields, POSTs JSON, shows success or error state.
4. /api/form-submit
Receives the JSON payload (keys are slugified Label values). In production: forward to your CRM or Optimizely Data Platform. The demo logs to console and returns { success: true }.
Submit element - DOM-scoped collection
// OptiFormsSubmit - "use client"
async function handleClick() {
const scope = ref.current?.closest("main") ?? document.body;
const configEl = scope.querySelector("[data-form-submit-url]");
const submitUrl = configEl?.getAttribute("data-form-submit-url") ?? "/api/form-submit";
const msg = configEl?.getAttribute("data-form-success-message");
// Collect every input, textarea, and select within the same page scope.
// Works because Visual Builder renders form elements as flat siblings.
const inputs = scope.querySelectorAll("input, textarea, select");
const payload: Record<string, string> = {};
let valid = true;
inputs.forEach((el) => {
if (el.name) payload[el.name] = el.value;
if (el.required && !el.value) valid = false;
});
if (!valid) { inputs.forEach((el) => el.required && !el.value && el.reportValidity()); return; }
const res = await fetch(submitUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) { setStatus("success"); if (msg) setSuccessMessage(msg); }
}3. Component Registration #
Native form types are already registered in the CMS after activation - no opti:push needed for them. We provide schema hints to the SDK in componentRegistry.ts so it includes the correct properties in its auto-generated composition GraphQL fragments, then register the React rendering components under each native type key. SDK docs ↗
// src/lib/optimizely/componentRegistry.ts
//
// Native form type schemas live here - NOT in src/components/**/*.tsx -
// so opti:push (which globs src/components/**/*.tsx) does not try to push
// them. They are already in the CMS after activation.
const OptiFormsContainerDataType = contentType({
key: "OptiFormsContainerData",
baseType: "_component",
properties: {
Title: { type: "string" }, Description: { type: "string" },
SubmitUrl: { type: "url" }, SubmitConfirmationMessage: { type: "string" },
},
});
// OptiFormsSelectionElement - field names differ from what you might guess:
// Options (JSON scalar, not Items[]) AllowMultiSelect (not AllowMultipleChoices)
// Getting these wrong causes ALL pages to 404 - the SDK includes these fields in its
// auto-generated composition fragment, and Graph returns a schema error for unknown fields.
const OptiFormsSelectionElementType = contentType({
key: "OptiFormsSelectionElement",
baseType: "_component",
properties: {
Label: { type: "string" },
AllowMultiSelect: { type: "boolean" }, // NOT AllowMultipleChoices
Options: { type: "string" }, // JSON scalar in Graph, NOT an Items array
Validators: { type: "string" },
},
});
initContentTypeRegistry([
...otherTypes,
OptiFormsContainerDataType,
OptiFormsSelectionElementType,
// OptiFormsTextboxElementType, OptiFormsTextareaElementType, OptiFormsSubmitElementType
]);
initReactComponentRegistry({ resolver: {
OptiFormsContainerData: OptiFormsContainer,
OptiFormsTextboxElement: OptiFormsTextbox,
OptiFormsTextareaElement: OptiFormsTextarea,
OptiFormsSelectionElement: OptiFormsSelection,
OptiFormsSubmitElement: OptiFormsSubmit,
}});4. Component Implementations #
Each native form type maps to a React component in src/components/blocks/OptiFormsXxx/index.tsx. Components do not call contentType() - the schema is provided in componentRegistry.ts. Property names are PascalCase to match the native CMS schema (Label, Placeholder, SubmitUrl, etc.).
// Native Optimizely Forms types - no contentType() needed, they are pre-registered
// in the CMS after activation. The SDK auto-generates GraphQL fragments from the
// schema hints in componentRegistry.ts so all properties are fetched automatically.
// src/components/blocks/OptiFormsContainer/index.tsx
export default function OptiFormsContainer(props) {
const data = props.content ?? props;
return (
<section
data-form-submit-url={data.SubmitUrl?.default ?? "/api/form-submit"}
data-form-success-message={data.SubmitConfirmationMessage}
>
<h2>{data.Title}</h2>
<p>{data.Description}</p>
</section>
);
}
// src/components/blocks/OptiFormsTextbox/index.tsx
export default function OptiFormsTextbox(props) {
const data = props.content ?? props;
const name = data.Label?.toLowerCase().replace(/\s+/g, "_") ?? "field";
const required = isRequired(data.Validators); // checks for RequiredValidator in JSON array
return (
<div>
<label htmlFor={name}>{data.Label}{required && " *"}</label>
<input id={name} name={name} type="text" placeholder={data.Placeholder} required={required} />
</div>
);
}
// src/components/blocks/OptiFormsSelection/index.tsx
// Options is a JSON scalar from Graph - parse it to get the choice array.
// AllowMultiSelect (boolean) controls single vs. multi-select.
export default function OptiFormsSelection(props) {
const data = props.content ?? props;
const items = data.Options ? JSON.parse(data.Options) : [];
return (
<select multiple={data.AllowMultiSelect ?? false}>
{items.map((item, i) => (
<option key={i} value={item.value ?? item.label}>{item.label}</option>
))}
</select>
);
}
// src/components/blocks/OptiFormsSubmit/index.tsx - "use client"
// Same DOM-scoped collection as before: scans closest("main") for all inputs,
// reads data-form-submit-url, validates required fields, POSTs JSON payload.5. The Submit Handler #
The route receives a flat JSON object keyed by slugified Label value (the native forms' field identifier). The Submit URL on the form container is set to /api/form-submit in the CMS form builder. Swap the console log for any integration - CRM, email service, or Optimizely Data Platform.
// src/app/api/form-submit/route.ts
export async function POST(request: NextRequest) {
const body = await request.json();
// body = { "Full Name": "Jane", "Email Address": "jane@...", "Message": "..." }
// Keys are the Label values of each form element (slugified to snake_case).
// Log the submission (swap for your CRM / ODP integration here)
console.log("[Form Submission]", body);
// To send to Optimizely Data Platform as a customer event:
// await fetch("https://api.zaius.com/v3/events", {
// method: "POST",
// headers: { "x-api-key": process.env.ODP_API_KEY },
// body: JSON.stringify({ type: "form_submit", identifiers: { email: body.email }, data: body }),
// });
return NextResponse.json({ success: true });
}6. Closing the Personalization Loop #
A form submission is the beginning of a customer profile, not the end. Connect the submit handler to Optimizely Data Platform (ODP) and the submission feeds straight into Feature Experimentation audience conditions - which the CMS page route already reads to serve targeted content variations.
User submits form (email captured)
|
+-> POST /api/form-submit
+-> POST to ODP: { type: "form_submit", identifiers: { email }, data: payload }
+-> ODP builds customer profile: { email, logged_in: true, ... }
Next page request (same user, identified by cookie)
+-> FX evaluates "cms_personalization" flag
Audience: logged_in = true -> variation "returning_users"
+-> Graph returns the CMS variation built for returning users
+-> OptimizelyComponent renders it - zero extra code// The submit to ODP to FX loop:
// 1. User submits the form (email captured in body.email)
// 2. /api/form-submit POSTs to ODP as a customer event
// ODP builds a customer profile: { email, logged_in: true, ... }
// 3. Next request: FX evaluates "cms_personalization" flag for this user
// Audience condition: logged_in = true to variation "returning_users"
// 4. [[...slug]]/page.tsx passes variation key to Graph
const [page] = await client.getContentByPath(url, {
variation: {
include: "SOME",
value: ["returning_users"],
includeOriginal: true,
},
});
// 5. Graph returns the CMS variation an editor built in Visual Builder
// specifically for logged-in / returning users
return <OptimizelyComponent content={page} />;