---
title: Render a form with Optimizely Graph
slug: cms-saas/docs/render-a-form-with-optimizely-graph
docTags: 
createdAt: 2026-07-30T20:18:10.000Z
---

:::hint{type="info"}
**Note**
This topic is for front-end developers. See [episerver/cms-saas-vercel-demo](https://github.com/episerver/cms-saas-vercel-demo) for a complete demo site for Optimizely Content Management System (SaaS), taking all content from Optimizely Graph.

Also, on CMS (SaaS), forms work with experiences only. If you drag and drop a form to a `ContentArea` in a block or page, or use a data type like `ContentReference`, the form does not work.
:::

The repository contains a *Hello World* example of the Optimizely Visual Builder Forms. Ensure you have an Optimizely Content Management System (SaaS) instance running.

## Configure a form element

1. Go to **Settings > Forms Settings**.
2. Click **Activate** to activate the form elements.

:::hint{type="warning"}
**Important**
When you activate form elements, you cannot deactivate them because that would break the forms that use those elements.
:::

3. Go to **Settings > Content Types**.
4. Click **Create New** and select **Block Type** from the drop-down list.
5. Enter `ParagraphElement` for the **Name** and **Display** name fields.
6. Click **Create**.
7. Click **Add Property** and select **Text** from the drop-down list.
8. Enter **Text** for the **Name** in the **Configure Property** page.
9. Select **Text Type** from the drop-down list and select **XHTML string (>255)**.
10. Click **Save**.
11. Go to **Settings**.
12. Select **Available for composition in Visual Builder** and **Display as Element**.
13. Click **Save**.

## Run the sample

1. Clone [the cms-visual-builder-forms repository](https://github.com/linhhoangOpti/cms-visual-builder-forms).
2. Create a file named `.env.local`.
3. Copy the **Single key** from the **Render Content** section from the Optimizely CMS (SaaS) dashboard.
4. Enter `"GRAPH_SINGLE_KEY="` and paste your **Single key** from step 3 in the `.env.local` file.
5. Enter `"CMS_URL="` and paste your CMS URL, such as `app-mysuperapp.cms.optimizely.com`.
6. Run `yarn install`.
7. Run `yarn codegen` to generate GraphQL queries.
8. Run `yarn dev` to start the site. It runs on `https://localhost`.
9. Configure the site preview in **Settings > Applications** to see it in Edit view of your CMS (SaaS) instance.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/_lYl8YKqbDXAAow2-kfNk_8c4addd7db617a5c3344ece01b8c8aaa05be981dd44a178f1ec44f68e1fca192-image.png)

10. Click **Create Website Application**.
11. Add an application website pointing to your local `Next.js` application running on `https://localhost:3000`. It should look similar to the following example. Click **Create Website**.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/l5xcvRLbhjChJPTHISd90-20260722-181222.png)

:::hint{type="info"}
**Note**
See [episerver / cms-saas-netlify-demo](https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/create-a-demo-site-using-cms-saas-and-netlify#cms-saas-ui-configuration) for information.
:::

12. Go to **Edit** view and select **Add > Create Shared Block**.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/reona6tr04jYKoVuO4NJh_522fa0b81651d1b8b99486949d3f69c228d2f985b6c346baef8e854a4d69f2e3-image.png)

13. Select **Form Container** type.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/4TIh7csGehmR3VRWf-7f8_041e0f5ee0c2f119333a38c7783fa8fe4af6a81ee8a0d2d5d64e805097a9b93d-image.png)

14. Add a form step, row, column, and some form elements (such as **Textbox** and **Submit**).

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/Ynrz2OE5GYOytSYU03Koz_4ccf54d03fe7fbef4509c3bf45332518cc89d034b984dd527a8abf5607909d1b-image.png)

15. Select **More > Create Experience** from the **Edit** view.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/P8cEFCQ1b5LLGjKEjzRwO_c4a03c417fd67976e7cdd77b82e0feb4a30d9490c12f3e4407de2c1a65e47070-image.png)

16. Enter a name and select **Blank Experience**.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/KPfyv9KTmOlP9fssrga67_e287c39b57ee308ac519dbb00c66c5f72d724adcbdb8f7b0b2605d14c28069e7-image.png)

17. Drag the form container to the experience.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/SZwO_AT4PEwNzYWRz3B4G_562827a6e0f9887edcdb0d4e41a0423ac0c4b3ff58746f439f90c449e7c4f127-image.png)

18. Add a section, row, column, and an element of **ParagraphElement** type. Fill in the text `Hello world` and it displays in the preview.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/YP5qo51SuZ9fM-5QLLL34_e51c9575cb5e0bc079ed8ab6cc524009f9b79384e2ea816aa541de58e1a7bf96-image.png)

19. Click **Outline** to go back to the experience preview.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/S_y-G9QM6YpHz8OH_UR96_3f9d8da2726c25ac0c3d04f8a276d65f357eedf3f19c5ddc94869188de028cd8-image.png)

See also [Create a form](docId\:IOgFZSV0yjKFDgQeytNhm).

## Hello World app details for developers

### Prerequisites

1. Create a simple `Next.js` app based on the *Hello World* example template to consume data from the instance.
   `npx create-next-app@latest vb-test --use-yarn --example hello-world vb-test`
2. Add GraphQL support by installing the following dependencies:
   `yarn add @apollo/client graphql`
3. Install development tools to generate objects based on your GraphQL schema.
   `yarn add --dev @graphql-codegen/cli @graphql-codegen/client-preset @parcel/watcher`
4. Add a `codegen.ts` configuration file for the **codegen** plugin in the root folder and paste the following code:

```typescript
import { CodegenConfig } from "@graphql-codegen/cli";  
import { loadEnvConfig } from "@next/env";

loadEnvConfig(process.cwd());

const graphUrl = process.env.GRAPH_URL  
const graphSingleKey = process.env.GRAPH_SINGLE_KEY

const config : CodegenConfig = {  
    schema: `https://${graphUrl}/content/v2?auth=${graphSingleKey}`,  
    documents: ["src/**/*.{ts,tsx}"],  
    ignoreNoDocuments: true,  
    generates: {  
        './src/graphql/': {  
            preset: 'client',  
            plugins: \[],  
        }  
    }  
}

export default config
```

5. Add a script to `package.json` to generate types based on your GraphQL schema.
   `"codegen": "graphql-codegen"`

### Activate forms

Before you run the `codegen`, activate forms in your CMS (SaaS) instance.

1. Go to **Settings > Forms Settings**.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/l0RtBphKTEjzc3Yo00Jv1_9f99578061973de5224100ab3fa495106dcec5916e220a405dc333772f26d12f-image.png)

2. Click **Activate** to activate forms.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/trniXfo4DpBlEa-9EThtY_7601210804dfa3590a522f4087a6b0f266ce1b6e4b2fc79b6a46d7922caf7e8a-image.png)

:::hint{type="warning"}
**Important**
When you activate form elements, you cannot deactivate them because that would break the forms that use those elements.
:::

### Add a paragraph element component

Create a React component to display a **ParagraphElement** with the following code (or similar).

```typescript
import { FragmentType, useFragment } from '../../graphql/fragment-masking'
import { graphql } from '@/graphql'

export const ParagraphElementFragment = graphql(/* GraphQL */ `
    fragment paragraphElement on ParagraphElement {
        Text {
            html
        }
    }
`)

const ParagraphElementComponent = (props: {
    paragraphElement: FragmentType<typeof ParagraphElementFragment>
}) => {
    const paragraphElement = useFragment(ParagraphElementFragment, props.paragraphElement)
    // @ts-ignore
    return <div dangerouslySetInnerHTML={{ __html: paragraphElement.Text?.html }}></div>
}

export default ParagraphElementComponent
```

### Add a Textbox form element

Create a React component to display a **TextboxElementComponent** with the following code (or similar).

```typescript
import { FragmentType, useFragment } from '../../graphql/fragment-masking'
import { graphql } from '@/graphql'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { isRequiredValidator } from '@/helpers/validatorHelper'

export const TextboxComponentNodeFragment = graphql(/* GraphQL */ `
fragment textboxElement on OptiFormsTextboxElement {
  Label
  Tooltip
  Placeholder
  AutoComplete
  PredefinedValue
  Validators
}
`)

const TextboxElementComponent = (props: {
    textboxElement: FragmentType<typeof TextboxComponentNodeFragment>,
    formState?: any
}) => {
    const node = useFragment(TextboxComponentNodeFragment, props.textboxElement)
    
    return (
        <div>
            <Label>{node.Label} <span className='form-element-required'>{isRequiredValidator(node.Validators) ? "*" : ""}</span></Label>
            <Input
                type='text'
                autoComplete={node.AutoComplete ? 'on' : 'off'}
                placeholder={node.Placeholder ?? ''}
                onChange={(e) => props.formState[node.Label!] = e.target.value }
            />
        </div>
    )
}

export default TextboxElementComponent
```

### Add a Submit form element

Create a React component to display a **SubmitElementComponent** with the following code (or similar).

```typescript
import { FragmentType, useFragment } from '../../graphql/fragment-masking'
import { graphql } from '@/graphql'
import { Input } from '../ui/input'
import { Button } from '../ui/button'
import axios from 'axios'

export const SubmitElementComponentNodeFragment = graphql(/* GraphQL */ `
fragment submitElement on OptiFormsSubmitElement {
  Label
  Tooltip
}
`)

const SubmitElementComponent = (props: {
    submitElement: FragmentType<typeof SubmitElementComponentNodeFragment>,
    formState?: any
}) => {
    const node = useFragment(SubmitElementComponentNodeFragment, props.submitElement)

    return (
        <>
            <div><br /></div>
            <Button onClick={(e) => {
                console.log(props.formState)
                axios.post((window as any).submitUrl, props.formState)
                .then(response => {
                    alert('Form submitted successfully!');
                })
                .catch(error => {
                    console.error('Error submitting form:', error);
                });
            }}>
                {node.Label}
            </Button>
        </>
    )
}

export default SubmitElementComponent
```

### Add the layout component

Add the master component to render the layout (section, row, and columns):

```typescript
import React, { FC, useEffect, useState } from 'react'
import { useQuery } from '@apollo/client'

import { graphql } from '@/graphql'
import CompositionNodeComponent from './CompositionNodeComponent'
import { onContentSaved } from "@/helpers/onContentSaved";
import FormsComponent from './FormsComponent';

export const VisualBuilder = graphql(/* GraphQL */ `
query VisualBuilder($key: String, $version: String) {
  _Experience(where: {
      _metadata: { key: { eq: $key } }
      _or: { _metadata: { version: { eq: $version } } }
    }) {
    items {      
      composition {
        grids: nodes {
          ... on CompositionStructureNode {
            key
            __typename
            displayName
            nodeType
            layoutType
            component {
              	..._IComponent
            }
            nodes: nodes {
              ... on CompositionStructureNode {
                key
                __typename
                displayName
                nodeType
                layoutType
                nodes: nodes {
                  ... on CompositionStructureNode {
                    key
                    __typename
                    displayName
                    nodeType
                    layoutType
                    nodes: nodes {
                      ...compositionComponentNode
                      ... on CompositionStructureNode {
                        key
                        __typename
                        displayName
                        nodeType
                        layoutType
                        nodes: nodes {
                          ...compositionComponentNode
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
      _metadata {
        key
        version,        
      }
    }
  }
}

fragment _IComponent on _IComponent {
  __typename
  ...FormContainerData
}

fragment FormContainerData on OptiFormsContainerData {
    SubmitConfirmationMessage
    ResetConfirmationMessage
    SubmitUrl {
        type
        default
        hierarchical
        internal
        graph
        base
    }
    Title
    Description
    ShowSummaryMessageAfterSubmission
}
`)

interface VisualBuilderProps {
    contentKey?: string;
    version?: string;
}

const VisualBuilderComponent: FC<VisualBuilderProps> = ({ version, contentKey }) => {
    const formState: any = {};
    const variables: Record<string, unknown> = {};
    if (version) {
        variables.version = version;
    }

    if (contentKey) {
        variables.key = contentKey;
    }

    const { data, refetch } = useQuery(VisualBuilder, {
        variables: variables,
        notifyOnNetworkStatusChange: true,
    });

    useEffect(() => {
        onContentSaved(_ => {
            const contentIdArray = _.contentLink.split('_')
            if (contentIdArray.length > 1) {
                version = contentIdArray[contentIdArray.length - 1]
                variables.version = version;
            }
            refetch(variables);
        })
    }, []);

    const experiences = data?._Experience?.items;
    if (!experiences) {
        return null;
    }

    if (data?._Experience?.items?.length === 0) {
        return <FormsComponent version={version} contentKey={contentKey} />;
    }

    const experience: any = experiences[experiences.length - 1];

    if (!experience) {
        return null;
    }

    return (
        <div className="relative w-lg flex-1 vb:outline">
            <div className="relative w-lg flex-1 vb:outline">
                {experience?.composition?.grids?.map((grid: any) =>
                    <div key={grid.key} className="relative w-lg flex flex-col flex-nowrap justify-start vb:grid"
                        data-epi-block-id={grid.key}>
                        {RenderCompositionNode(grid, formState)}
                    </div>
                )}
            </div>
        </div>
    )
}

export default VisualBuilderComponent

export const RenderCompositionNode = (node: any, formState?: any): JSX.Element | null => {
    if (!node || !node.__typename) {
        return null;
    }
    const { layoutType } = node;
    if (layoutType === "form") {
        const w = window as any;
        w.submitUrl = node.component.SubmitUrl.default;
    }

    // Handle CompositionStructureNode with different nodeTypes
    if (node.__typename === "CompositionStructureNode") {
        const { key, nodeType, nodes } = node;

        // Switch based on nodeType
        switch (nodeType) {
            case "section":
                return (
                    <div key={key} className="flex flex-col vb:section" data-epi-block-id={key}>
                        {nodes?.map((childNode: any) => RenderCompositionNode(childNode, formState))}
                    </div>
                );

            case "step":
                return (
                    <div key={key} className="flex flex-col vb:step" data-epi-block-id={key}>
                        {nodes?.map((childNode: any) => RenderCompositionNode(childNode, formState))}
                    </div>
                );

            case "row":
                return (
                    <div key={key} className="flex flex-row flex-wrap justify-start vb:row gap-4" data-epi-block-id={key}>
                        {nodes?.map((childNode: any) => RenderCompositionNode(childNode, formState))}
                    </div>
                );

            case "column":
                return (
                    <div key={key} className="flex-1 flex flex-col flex-nowrap justify-start vb:col" data-epi-block-id={key}>
                        {nodes?.map((childNode: any) => RenderCompositionNode(childNode, formState))}
                    </div>
                );

            default:
                // Handle any other nodeType or fallback to generic structure
                return (
                    <div key={key} className="flex flex-col vb:generic" data-epi-block-id={key}>
                        {nodes?.map((childNode: any) => RenderCompositionNode(childNode, formState))}
                    </div>
                );
        }
    }

    // Handle CompositionComponentNode (leaf elements)
    if (node.__typename === "CompositionComponentNode" || node.component) {
        return (
            <div key={node.key} data-epi-block-id={node.key}>
                <CompositionNodeComponent compositionComponentNode={node} formState={formState}/>
            </div>
        );
    }

    // Fallback for unknown node types
    return null;
};
```

Optimizely supports rendering both forms and sections. For sections, you first iterate over sections, then rows, then columns, and finally elements. For forms, you iterate over steps, then rows, then columns, and finally form elements. You wrap each of these layout items into basic *Tailwind* grid classes.

The following example shows just one element type. You do not want to hard-code anything, so this has a pattern that you can use for a different element component per `nodeType`.

```typescript
import { FragmentType, useFragment } from '../../graphql/fragment-masking'
import { graphql } from '@/graphql'
import TextboxElementComponent from '../elements/TextboxElementComponent'
import SubmitElementComponent from '../elements/SubmitElementComponent'
import ParagraphElementComponent from '../elements/ParagraphElementComponent'

export const CompositionComponentNodeFragment = graphql(/* GraphQL */ `
fragment compositionComponentNode on CompositionComponentNode {
    key
    component {
        _metadata {
            types
        }
      	...textboxElement
        ...submitElement
        ...paragraphElement
    }
}
`)

const CompositionComponentNodeComponent = (props: {
    compositionComponentNode: FragmentType<typeof CompositionComponentNodeFragment>,
    formState?: any
}) => {
    const compositionComponentNode = useFragment(CompositionComponentNodeFragment, props.compositionComponentNode)
    const component = compositionComponentNode.component

    switch (component?.__typename) {
        case "OptiFormsTextboxElement":
            return <TextboxElementComponent textboxElement={component} formState={props.formState}/>
        case "OptiFormsSubmitElement":
            return <SubmitElementComponent submitElement={component} formState={props.formState}/>
        case "ParagraphElement":
            return <ParagraphElementComponent paragraphElement={component} />
        default:
            console.log(`Unknown component type: ${component?.__typename}`);
            return <>NotImplementedException</>
    }
}

export default CompositionComponentNodeComponent
```

Based on `component.__typename`, you can use different components such as `OptiFormsTextboxElement` and `OptiFormsSubmitElement`.

### Generate GraphQL

1. Go to your `Next.js` application and fill in your `GRAPH_SINGLE_KEY` and `GRAPH_URL` into `.env.local` file. `CMS_URL` is also required to communicate with Visual Builder.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/ZUBc4zmvA1dnBkSnYnhSc-20260722-181222.png)

2. Run the script `yarn codegen`. It generates the schema in the `src/graphql` folder.

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/Z5dA2JO4VoOqfFekP8Tr--20260722-181222.png)

After that, `src/graphql` contains files that let you write GraphQL queries.

:::hint{type="info"}
**Note**
Every time you change the query inside the `graphql()` function, you need to run `yarn codegen` again.
:::

## Subscribe to content changes

Subscribe to a special event to know when content is updated. In this repo, the subscription is already done in `onContentSaved.ts`.

```typescript
window.addEventListener("optimizely:cms:contentSaved", (event: any) => {  
	const message = event.detail as ContentSavedEventArgs;  
});
```

It is defined as follows:

```typescript
interface ContentSavedEventArgs {  
    contentLink: string;  
    previewUrl: string;  
    isIndexed: boolean;  
    properties: PropertySaved\[];  
    parentId?: string;  
    sectionId?: string;  
}
```

See also [Refresh the application's view when content has changed](docId\:UCf2VD_0Jqh32jliPbPzm).
