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. Rendered by the SDK's own form runtime: validation, multi-step and conditional fields come with it. Submissions post to your endpoint and feed the personalization loop: capture to ODP profile to FX audience to targeted content.
OptiFormsContainerData · OptiFormsTextboxElement · OptiFormsTextareaElement · OptiFormsSelectionElement · OptiFormsSubmitElement@optimizely/cms-sdk/forms/react/api/form-submitODP · FX audience to CMS variation

Live Demo #

Two ways to put a form on a page, side by side and both live. On the left, a native Optimizely Form rendered by the SDK's form runtime from the same props Visual Builder passes. On the right, a hand-built block whose fields live in React, not in the CMS. Submit either one: both POST to /api/form-submit, and the native one prints the exact request and response.

Try submitting the native form empty, or with not-an-email in the email field. The messages you get back are the editor's own ErrorMessage strings from the CMS, enforced by validateField().

Native form - CMS authored, SDK rendered

Contact Support

Send us a message and we'll get back to you within one business day.

Hand-built block - fields fixed in React

Contact Support

The same three fields, written by hand. An editor owns the copy and the endpoint, not the field list.

 Native formHand-built block
Who owns the fieldsAn editor, in the CMS form builderA developer, in JSX
ValidationSeven validator types, editor-authored messagesWhatever the markup declares
Multi-step, conditional fieldsBuilt inWrite it yourself
Where it can goDynamicExperience only (Visual Builder)Any content area or composition
Posted body{ targetUrl, payload, formKey }Flat JSON object
Reach for it whenMarketing needs to change the form without a deployThe form is part of a product flow with bespoke behaviour

Both also appear together on a real CMS page: /contact-form is a DynamicExperience carrying the hand-built block as a component node and the shared Form Container as a form section, seeded by scripts/seed-contact-form.ts.

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.
  • The Management API cannot create an OptiFormsContainerData block. Author it once by hand in Visual Builder; its composition (the steps, rows and elements) can then be PATCHed by a script, which is what scripts/seed-form-block.ts does.
  • Do not run opti:push for native form types - they are already in the CMS. Since cms-sdk 3.0.0 the SDK owns their schemas, so initForms() is the only registration you write.
  • The SDK registers all ten element types, not just the ones you implement. An element with no component renders a visible "No component found for content type X" box rather than nothing. Map every one.
  • Field names are not what you would guess - Options is JSON, not an Items array, and the flag is AllowMultiSelect, not AllowMultipleChoices. This used to be a footgun: a hand-declared schema with a wrong field name made the SDK ask Graph for unknown fields, and every page 404'd. The SDK now supplies these names, so the risk is gone - but Validators and Options are typed json, so Graph hands them back already parsed, not as strings to JSON.parse.
  • Forms nest one level deeper than an ordinary composition (section, step, row, column, element). The SDK probes whether the page holds a form and uses depth 8 when it does, leaving ordinary compositions at 4. Nothing to configure, and the hand-patched depth rewrite this app used before 3.0.0 must not come back - it string-matched the SDK's generated fragment, and 3.0.0 changed that text, so it would silently no-op.

2. How It Works #

The container is a server component, so it keeps its Graph fetch and its cache. Everything that needs state - validation, steps, submission - lives in a client shell it renders around the fields. The fields themselves are client components bound by useFormField.

1. OptiFormsContainerData (server)

Renders the title and description, and resolves its own scalar properties from Graph by display name - a shared Form Container referenced in a page composition arrives with only the section's structural fields. Splits the composition into steps and buttons, then hands both to the client shell.

2. FormShell (client)

FormSubmissionProvider wrapping FormWrapper. FormWrapper owns the <form>, the validation and rules contexts, the step machine and the submit lifecycle.

3. Field components (client)

Each element type renders its control and spreads fieldProps from useFormField: name, value, required, aria-invalid and aria-describedby all come from the hook, wired to the element's own Validators.

4. /api/form-submit

Unwraps the SDK envelope, logs the payload, and forwards it to Optimizely Data Platform as a form_submit event keyed on email and fs_user_id.

The client shell

// src/components/blocks/OptiFormsContainer/FormShell.tsx - "use client"
//
// THE TRAP: FormWrapper calls useFormSubmission() but does NOT render
// FormSubmissionProvider itself. It only wraps its children in the validation and
// rules providers. Leave the provider out and the form throws on first render.

export default function FormShell(props) {
  return (
    <FormSubmissionProvider>      {/* required, and not supplied by FormWrapper */}
      <FormBody {...props} />
    </FormSubmissionProvider>
  );
}

function FormBody({ action, successMessage, steps, rules, children }) {
  const { formSuccess, errorMessage } = useFormSubmission();

  // Resolving means success, throwing means failure. That contract is the whole
  // interface: FormWrapper resets the fields, returns to step 0 and scrolls to
  // #form-alert on success, and surfaces an Error's message on failure.
  const submitHandler = async (formData, context) => {
    const res = await fetch(action, { method: "POST", body: json(formData) });
    if (!res.ok) throw new Error(`Submission failed with status ${res.status}`);
    trackEvent("mb_form_submit", { ... });
    identifyCustomer({ email: payload.email });
  };

  return (
    <FormWrapper action={action} submitHandler={submitHandler} steps={steps} rules={rules}>
      {formSuccess ? <SuccessPanel id="form-alert" /> : children}
      {errorMessage && <p role="alert">{errorMessage}</p>}
    </FormWrapper>
  );
}

3. Component Registration #

Native form types are already registered in the CMS after activation - no opti:push needed for them. Until cms-sdk 3.0.0 you also had to hand-write each type's property schema so the SDK would include the right fields in its auto-generated composition fragments. The SDK now ships those schemas, so initForms() is the whole registration: it registers the types and maps your components onto them. SDK docs ↗

// src/lib/optimizely/componentRegistry.ts
//
// initForms() is the whole registration: it registers the SDK's own OptiForms type
// schemas AND maps your components onto them. Nothing here is ever pushed - the
// types are already in the CMS after activation, and opti:push only globs
// src/components/**/*.tsx anyway.
//
// Import from react/server, NOT the package root. 3.0.0 exported it from both,
// which made the root entry pull in react (a peer dependency) and broke every
// standalone npx @optimizely/cms-cli call. 3.0.1 reverted that re-export.
import { initForms } from "@optimizely/cms-sdk/react/server";

initForms({
  container: OptiFormsContainer,
  textbox:   OptiFormsTextbox,
  textarea:  OptiFormsTextarea,
  selection: OptiFormsSelection,
  submit:    OptiFormsSubmit,
  // The SDK registers all ten element types whether or not you map one, and an
  // unmapped type renders a visible "No component found" box rather than nothing.
  // So map every one. Range shares the number component: the CMS models both as a
  // numeric field, and a slider with no configured bounds is worse than an input.
  number: OptiFormsNumber, range: OptiFormsNumber, url: OptiFormsUrl,
  choice: OptiFormsChoice, reset: OptiFormsReset,
});

// Order does not matter: initForms writes to lists the SDK keeps separate from
// initContentTypeRegistry / initReactComponentRegistry, so neither wipes the other.
initContentTypeRegistry([...yourOwnTypes]);
initReactComponentRegistry({ resolver: yourOwnResolver });

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 SDK ships the schemas. Property names are PascalCase to match the native CMS schema (Label, Placeholder, SubmitUrl, etc.).

// Native Optimizely Forms types - no contentType() needed. The SDK ships their
// schemas and initForms() registers them, so a field component is only ever the
// markup: useFormField does the naming, validation and rule visibility.

// src/components/blocks/OptiFormsTextbox/index.tsx - "use client"
export default function OptiFormsTextbox(props) {
  const data = props.content ?? props;
  const validators = toValidators(data.Validators);   // Graph returns JSON, not a string

  const { fieldProps, isRequired, errors, showErrors, errorProps } = useFormField({
    content: asFieldContent(data),
    name: fieldName(data),                            // slugify(getFieldName(data))
    validators,
  });

  // EmailValidator -> type="email", RegularExpressionValidator -> pattern="..."
  const { type, ...validationAttrs } = getHtmlValidationAttributes(validators);

  return (
    <FormElement content={data}>                      {/* dependency rules can hide it */}
      <label htmlFor={fieldProps.id}>{data.Label}{isRequired && " *"}</label>
      <input {...fieldProps} {...validationAttrs} type={type ?? "text"} />
      {showErrors && <p {...errorProps}>{errors[0]}</p>}
    </FormElement>
  );
}

The container

// src/components/blocks/OptiFormsContainer/index.tsx - a SERVER component.
// It keeps its "use cache" Graph self-fetch (a referenced shared Form Container
// arrives without its scalar props) and renders the client shell around
// server-rendered children.

// Editors put Next / Previous / Submit wherever they like, often each in its own
// row. partitionFormNodes pulls them out at any depth and drops the rows left
// empty behind them, so they lay out as one footer however the form was authored.
const { content, buttons } = partitionFormNodes(nodes);
const steps = content.filter(n => n.nodeType === "step");

<FormShell action={...} steps={steps} rules={data.DependencyRules}>
  {steps.map((step, index) => (
    // OptimizelyGridSection has NO handler for nodeType "step" - it renders one as
    // a bare fragment, which is why every step used to show at once. Wrapping is
    // the consumer's job.
    <FormStep key={step.key} index={index} node={step}>
      <OptimizelyGridSection nodes={step.nodes} row={NodeWrapper} column={NodeWrapper} />
    </FormStep>
  ))}
  <OptimizelyGridSection nodes={buttons} row={NodeWrapper} column={NodeWrapper} />
</FormShell>

5. Validation #

@optimizely/cms-sdk/forms/validation carries no React, so a server component can use it too. Validation runs on every keystroke but only shows once a field has been touched or a submit has been attempted, and a failed submit moves focus to the first invalid field - across steps, if it has to.

// @optimizely/cms-sdk/forms/validation - framework-free, usable on the server too.

isFieldRequired(validators)          // folds "requirevalidator" / "requiredvalidator"
validateField(value, validators)     // -> FormFieldError[], all seven validator types
getErrorMessages(errors)             // -> the editor's own ErrorMessage strings
getHtmlValidationAttributes(v)       // -> { required, type: "email", pattern, ... }
getFieldName(field)                  // SubmissionFieldName || Label - see the note below
getSelectionOptions(field)           // Options as an array, string form accepted too
toValidators(value)                  // Validators is unknown JSON; [] for a non-array

// extractValidatorType() lowercases and folds the naming variants, so you never
// compare raw CMS casing by hand. Verified against live Graph, a seeded form's
// Validators come back as:
//   [{ "type": "RequiredValidator", "errorMessage": "This field is required." },
//    { "type": "EmailValidator",    "errorMessage": "Please enter a valid email address." }]

getFieldName() returns the RAW label, not a slug

getFieldName(field) is SubmissionFieldName || Label and nothing else, so a field labelled "Full Name" posts under the key "Full Name", spaces and all. This app wraps it as slugify(getFieldName(field)) in _shared/formFields.ts: the editor-settable Submission Field Name is honoured, but the payload keys stay snake_case, which is what /api/form-submit and the ODP email identifier are built on. Anything reading the posted payload downstream depends on that choice, so dropping the wrapper is a data-shape change, not a refactor.

One more thing the hook decides for you

fieldProps.id is the field name, not a generated id. That is what lets aria-describedby point at the right error message, but it also means two fields sharing a label on one page collide. Give one of them a Submission Field Name in the CMS.

6. Multi-step and Conditional Fields #

These are the two things a hand-rolled form never gets around to. Both are authored in the CMS form builder and need no code beyond wrapping each step and each field.

// Multi-step forms. The composition nests one level deeper than a grid section:
//   section(layoutType:"form") > step > row > column > element
// The SDK probes whether a page holds a form and expands the composition to depth
// 8 when it does, leaving ordinary compositions at 4. Nothing to configure.

<FormWrapper steps={steps}>        // steps let rules resolve their jump targets
  <FormStep index={0} node={step}> // inactive steps stay mounted under display:none,
    ...fields                      // so values survive stepping back and submitting
  </FormStep>                      // validates every step, not just the visible one
</FormWrapper>

// Buttons carry no role property. Optimizely Forms has ONE button element for
// Next, Previous, Submit and Reset, told apart only by the label:
useFormButton(content)             // "next" -> next, "previous"/"back" -> previous,
                                   // anything else -> submit (case-insensitive)
useFormButton(content, { role: "reset" })   // reset has to be passed explicitly

// Dependency rules (OptiFormsDependencyRule) show and hide fields:
<FormRulesProvider rules={container.DependencyRules}>   // FormWrapper does this
  <FormElement content={field}>...</FormElement>        // each field opts in
// A field hidden by a rule is unregistered from validation, so a hidden required
// field cannot block submission. In edit mode it renders anyway, or an editor
// would be left with an empty selectable block.

The one piece the SDK does not do for you: OptimizelyGridSection has no handler for nodeType: "step" and renders one as a bare fragment, so without an explicit FormStep wrapper every step of a multi-step form shows at once.

7. The Submit Handler #

The Submit URL on the form container is set to /api/form-submit in the CMS form builder, and reaches the handler as context.action. Point a submit handler at a same-origin route and forward server-side from there: a browser POST straight to an external webhook is a CORS problem, and any credential it needs would be in the page. That is exactly what the SDK's createJsonSubmitHandler is for.

// src/app/api/form-submit/route.ts
export async function POST(request: NextRequest) {
  // The SDK's createJsonSubmitHandler posts { targetUrl, payload, formKey } rather
  // than the field values on their own. The hand-built ContactFormBlock still posts
  // a flat object, so accept both and unwrap to the same shape.
  const { body, envelope } = unwrap(await request.json());
  // body = { full_name: "Jane", email: "jane@...", topic: "account", message: "..." }

  console.log("[Form Submission]", body);

  // Forwarded to Optimizely Data Platform as a customer event. Fire and forget:
  // a failure is warned and the route still returns 200, so a visitor's submission
  // never fails because ODP is down.
  await forwardToOdp(body).catch(err => console.warn(err));

  return NextResponse.json({ success: true, message: "...", received: body });
}

// forwardToOdp keys on email and fs_user_id - NOT vuid. OdpSetup stitches the FX
// visitor id into ODP under fs_user_id, so that is where the profile lives.

8. The Hand-built Alternative #

Native forms are not always the right answer. A form that is part of a product flow - with its own steps, its own server calls, or fields derived from the signed-in user - is usually better as an ordinary block. The trade is editor control: an editor can change this form's copy and its endpoint, but not its fields.

// src/components/blocks/ContactFormBlock/ContactForm.tsx - "use client"
// The hand-built alternative: a normal content type with normal properties, and a
// field list fixed in code rather than authored in the CMS.

const [values, setValues] = useState({ full_name: "", email: "", message: "" });

async function handleSubmit(e) {
  e.preventDefault();
  const res = await fetch(submitUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(values),          // flat body, no envelope
  });
  if (res.ok) { trackSubmit(...); identifyCustomer({ email: values.email }); }
}

// contentType({ key: "ContactFormBlock", properties: {
//   heading, intro, submitLabel, successMessage, submitUrl
// }})
// An editor controls the copy and the endpoint. They cannot add, remove, reorder
// or validate a field without a developer.

Both forms share one endpoint, so the ODP and analytics path downstream is identical. The route accepts the SDK envelope and a flat body alike - see section 7.

9. Closing the Personalization Loop #

A form submission is the beginning of a customer profile, not the end. The submit handler feeds Optimizely Data Platform (ODP), and the submission then reaches 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: "event", action: "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 a variation filter to Graph.
//    includeOriginal: true keeps a visitor who matches no variation served:
//    Graph returns the base version alongside any match. stored: false is
//    required with it - a Graph stored query template binds the variation
//    value of whichever request registered it, so every visitor would get
//    the first variation served.
const [page] = await client.getContentByPath(url, {
  variation: {
    include: "SOME",
    value: ["returning_users"],
    includeOriginal: true,
  },
  stored: false,
});

// 5. Graph returns the CMS variation an editor built in Visual Builder
//    specifically for logged-in / returning users
return <OptimizelyComponent content={page} />;