Visual Builder
Outline
<> Step Code
GitHub - szymonuryga/optimizely-masterclass-step-by-step at 14-visual-builder
Contribute to szymonuryga/optimizely-masterclass-step-by-step development by creating an account on GitHub.

Introduction
Visual Builder is an intuitive editor interface in Optimizely Content Management System (SaaS) that empowers non-technical users to create and manage content without developer involvement. It allows for designing, modifying, and reusing blueprints (layout templates) directly within the CMS interface, enabling content creators to build adaptable experiences across multiple channels.
While Visual Builder simplifies content creation for non-technical users, integrating it into a project requires development effort. This guide outlines the necessary steps for effective integration.
Visual Builder Structure
Hierarchy in Optimizely Graph for SaaS CMS:

The key components in this structure are:
- Experience - Contains all properties like Page Types and includes a built-in ContentArea for blocks. In Graph Schema, these blocks are named
composition
. - Section - Functions like a Block in Content Area. You can specify if a block can be a section in the Admin UI. The built-in "Blank Section" includes rows and columns.

- Element - Used in "Blank Section" and displayed in a row structure:
Section
→Row
→Column
→Element
- Blueprints - Reusable layout templates that include shared blocks. For example, if all News pages have the same structure (HeroBlock, InfoBlock, ContactBlock), you can create a blueprint with these blocks and simply change the content for each new page.
Integration Steps
1. Define Custom Types
The types generated by the SDK are not sufficient, so we need to create custom types for handling Visual Builder experiences.
// lib/optimizely/types/experience.ts
import type { SeoExperience } from '@/lib/optimizely/types/generated'
export interface Row {
key: string
columns?: Column[]
}
export interface Column {
key: string
elements?: ExperienceElement[]
}
export interface ExperienceElement {
key: string
displaySettings?: {
value: string
key: string
}[]
component?: any
}
export interface VisualBuilderNode {
nodeType: 'section' | 'component'
key: string
component?: any
rows?: Row[]
}
export type SafeVisualBuilderExperience = {
composition?: {
nodes?: VisualBuilderNode[]
}
} & SeoExperience
Create a type that serves as the foundation for all blocks, which will be passed into the block factory mapper.
// lib/optimizely/types/block.ts
export interface BlockBase {
isFirst: boolean
preview: boolean
displaySettings?: {
value: string
key: string
}[]
}
2.Create a Wrapper Component
This component acts as the layout manager and integrates a Content Area Mapper to handle block rendering.
// components/visual-builder/wrapper.tsx
import ContentAreaMapper from '/content-area/mapper'
import type {
Column,
Row,
VisualBuilderNode,
SafeVisualBuilderExperience,
} from '@/lib/optimizely/types/experience'
export default function VisualBuilderExperienceWrapper({
experience,
}: {
experience?: SafeVisualBuilderExperience
}) {
if (!experience?.composition?.nodes) {
return null
}
const { nodes } = experience.composition
return (
<div className="vb:outline relative w-full flex-1">
<div className="vb:outline relative w-full flex-1">
{nodes.map((node: VisualBuilderNode) => {
if (node.nodeType === 'section') {
return (
<div
key={node.key}
className="vb:grid relative flex w-full flex-col flex-wrap"
data-epi-block-id={node.key}
>
{node.rows?.map((row: Row) => (
<div
key={row.key}
className="vb:row flex flex-1 flex-col flex-nowrap md:flex-row"
>
{row.columns?.map((column: Column) => (
<div
className="vb:col flex flex-1 flex-col flex-nowrap justify-start"
key={column.key}
>
<ContentAreaMapper
experienceElements={column.elements}
isVisualBuilder
/>
</div>
))}
</div>
))}
</div>
)
}
if (node.nodeType === 'component' && node.component) {
return (
<div
key={node.key}
className="vb:node relative w-full"
data-epi-block-id={node.key}
>
<ContentAreaMapper blocks={[node.component]} />
</div>
)
}
return null
})}
</div>
</div>
)
}
3. Update the Block Factory Mapper
Modify the block factory mapper to accommodate blocks that use a different format from standard Optimizely page types. (diffrent GraphQL schema)
// components/content-area/mapper.tsx
import { ExperienceElement } from '@/lib/optimizely/types/experience'
import Block from './block'
function ContentAreaMapper({
blocks,
preview = false,
isVisualBuilder = false,
experienceElements,
}: {
blocks?: any[] | null
preview?: boolean
isVisualBuilder?: boolean
experienceElements?: ExperienceElement[] | null
}) {
if (isVisualBuilder) {
if (!experienceElements || experienceElements.length === 0) return null
return (
<>
{experienceElements?.map(
({ displaySettings, component, key }, index) => (
<div
data-epi-block-id={key}
key={`${component?.__typename satisfies string}--${index}`}
>
<Block
typeName={component?.__typename}
props={{
...component,
displaySettings,
isFirst: index === 0,
preview,
}}
/>
</div>
)
)}
</>
)
}
if (!blocks || blocks.length === 0) return null
return (
<>
{blocks?.map(({ __typename, ...props }, index) => (
<Block
key={`${__typename satisfies string}--${index}`}
typeName={__typename}
props={{
...props,
isFirst: index === 0,
preview,
}}
/>
))}
</>
)
}
export default ContentAreaMapper
4. Modify the CMS Page to Fetch Visual Experiences
Adjust the CMS page logic to retrieve Visual Builder experiences when a standard page is not found. We need to also modify generating proper Metadata and generateStaticParams
to include generating static pages for Visual Builder during production build.
// app/(site)/[locale]/[slug]/page.tsx
export async function generateMetadata(props: {
params: Promise<{ locale: string; slug?: string }>
}): Promise<Metadata> {
const { locale, slug = '' } = await props.params
const locales = getValidLocale(locale)
const formattedSlug = `/${slug}`
const { data, errors } = await optimizely.getPageByURL({
locales: [locales],
slug: formattedSlug,
})
if (errors) {
return {}
}
const page = data?.CMSPage?.items?.[0]
if (!page) {
const experienceData = await optimizely.GetVisualBuilderBySlug({
locales: [locales],
slug: formattedSlug,
})
const experience = experienceData.data?.SEOExperience?.items?.[0]
if (experience) {
return {
title: experience?.title,
description: experience?.shortDescription || '',
keywords: experience?.keywords ?? '',
alternates: generateAlternates(locale, formattedSlug),
}
}
return {}
}
return {
title: page.title,
description: page.shortDescription || '',
keywords: page.keywords ?? '',
alternates: generateAlternates(locale, formattedSlug),
}
}
export async function generateStaticParams() {
try {
const pageTypes = ['CMSPage', 'SEOExperience']
const pathsResp = await optimizely.AllPages({ pageType: pageTypes })
}
}
export default async function CmsPage(props: {
params: Promise<{ locale: string; slug?: string }>
}) {
const { locale, slug = '' } = await props.params
const locales = getValidLocale(locale)
const formattedSlug = `/${slug}`
const { data, errors } = await optimizely.getPageByURL({
locales: [locales],
slug: formattedSlug,
})
if (errors || !data?.CMSPage?.items?.[0]) {
const experienceData = await optimizely.GetVisualBuilderBySlug({
locales: [locales],
slug: formattedSlug,
})
const experience = experienceData.data?.SEOExperience?.items?.[0] as
| SafeVisualBuilderExperience
| undefined
if (experience) {
return (
<Suspense>
<VisualBuilderExperienceWrapper experience={experience} />
</Suspense>
)
}
return notFound()
}
const page = data.CMSPage.items[0]
const blocks = (page?.blocks ?? []).filter(
(block) => block !== null && block !== undefined
)
return (
<>
<Suspense>
<ContentAreaMapper blocks={blocks} />
</Suspense>
</>
)
}
5. GraphQL Query to Fetch Visual Builder Data
# lib/optimizely/queries/GetVisualBuilderBySlug.graphql
query GetVisualBuilderBySlug($locales: [Locales], $slug: String) {
SEOExperience(
locale: $locales
where: { _metadata: { url: { default: { eq: $slug } } } }
) {
items {
title
shortDescription
keywords
composition {
nodes {
nodeType
key
displaySettings {
value
key
}
... on CompositionComponentNode {
component {
...ItemsInContentArea
}
}
... on CompositionStructureNode {
key
rows: nodes {
... on CompositionStructureNode {
key
columns: nodes {
... on CompositionStructureNode {
key
elements: nodes {
key
displaySettings {
value
key
}
... on CompositionComponentNode {
component {
...ItemsInContentArea
}
}
}
}
}
}
}
}
}
}
}
}
}
6. Create Display Templates via API
Visual Bulder introduces new way for adding styles. For exaxmple styles for block, way of displaying the block. Place for adding content is on another page, and if button should be primary, secondary etc is now added in Styles place. Currently there is no UI for adding this values, so we need use API to add those styles
Example CURL for that: Change URL and add Bearer token
curl --location 'https://app-test.cms.optimizely.com/_cms/preview2/displaytemplates' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json' \
--data '{
"key": "ProfileBlock",
"displayName": "Profile Block",
"baseType": "component",
"isDefault": true,
"settings": {
"colorScheme": {
"displayName": "Color scheme",
"editor": "select",
"sortOrder": 10,
"choices": {
"default": {
"displayName": "Default",
"sortOrder": 10
},
"primary": {
"displayName": "Primary",
"sortOrder": 20
},
"secondary": {
"displayName": "Secondary",
"sortOrder": 30
}
}
}
}
}'
7. Handle Styles tab in code, from GraphQL this settings will be passed as displaySettings
Note: Best practice for handling that in taiwlind is to use class-variance-authority
We can easily define new variants and it is easy to menage this
import Image from 'next/image'
import { Card, CardContent } from '@/components/ui/card'
import { ProfileBlock as ProfileBlockProps } from '@/lib/optimizely/types/generated'
import { BlockBase } from '@/lib/optimizely/types/block'
import { cva } from 'class-variance-authority'
type ProfileBlockPropsV2 = ProfileBlockProps & BlockBase
const backgroundVariants = cva('container mx-auto px-4 py-16', {
variants: {
colorScheme: {
default: 'border-none bg-[#f9e6f0] text-[#2d2d2d]',
primary: 'border-none bg-primary text-white',
secondary: 'border-none bg-secondary text-secondary-foreground',
},
},
defaultVariants: {
colorScheme: 'default',
},
})
export default function ProfileBlock({
imageSrc,
name,
title,
bio,
isFirst,
displaySettings,
}: ProfileBlockPropsV2) {
const colorScheme =
displaySettings?.find((setting) => setting.key === 'colorScheme')?.value ||
'default'
return (
<section className="container mx-auto px-4 py-16">
<Card
className={backgroundVariants({
colorScheme: colorScheme as 'default' | 'primary' | 'secondary',
})}
>
<CardContent className="p-8">
<div className="grid items-start gap-12 md:grid-cols-2">
<div className="relative mx-auto aspect-square w-full max-w-md">
<Image
src={imageSrc || '/placeholder.svg'}
alt={title ?? ''}
fill
className="rounded-lg object-cover"
priority={isFirst}
/>
</div>
<div className="space-y-4">
<h1 className="text-3xl font-bold" data-epi-edit="name">
{name}
</h1>
<p className="text-xl" data-epi-edit="title">
{title}
</p>
<div className="mt-6">
<h2 className="mb-2 text-lg font-semibold">Bio:</h2>
<p className="leading-relaxed" data-epi-edit="bio">
{bio}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</section>
)
}
Preview Mode
Visual Builder is much more powerfull in preview mode than normal pages, because of user expiernce, how easy to manage the content.
1. Add Page
In order to handle preview mode let add new page in (draft) route group.
// app/(draft)/[locale]/draft/[version]/experience/[key]/page.tsx
import OnPageEdit from '@/components/draft/on-page-edit'
import VisualBuilderExperienceWrapper from '@/components/visual-builder/wrapper'
import { optimizely } from '@/lib/optimizely/fetch'
import { SafeVisualBuilderExperience } from '@/lib/optimizely/types/experience'
import { getValidLocale } from '@/lib/optimizely/utils/language'
import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
export const revalidate = 0
export const dynamic = 'force-dynamic'
export default async function Page(props: {
params: Promise<{ key: string; locale: string; version: string }>
}) {
const { isEnabled: isDraftModeEnabled } = await draftMode()
if (!isDraftModeEnabled) {
return notFound()
}
const { locale, version, key } = await props.params
const locales = getValidLocale(locale)
const experienceData = await optimizely.VisualBuilder(
{ key, version, locales },
{ preview: true }
)
const experience = experienceData.data?.SEOExperience?.items?.[0] as
| SafeVisualBuilderExperience
| undefined
if (!experience) {
return notFound()
}
return (
<Suspense>
<OnPageEdit
version={version}
currentRoute={`/${locale}/draft/${version}/experience/${key}`}
/>
<VisualBuilderExperienceWrapper experience={experience} />
</Suspense>
)
}
2. Create GraphQL query
query VisualBuilder($locales: [Locales], $key: String, $version: String) {
SEOExperience(
locale: $locales
where: {
_metadata: { key: { eq: $key } }
_or: { _metadata: { version: { eq: $version } } }
}
) {
items {
composition {
nodes {
nodeType
key
displaySettings {
value
key
}
... on CompositionComponentNode {
component {
...ItemsInContentArea
}
}
... on CompositionStructureNode {
key
rows: nodes {
... on CompositionStructureNode {
key
columns: nodes {
... on CompositionStructureNode {
key
elements: nodes {
key
displaySettings {
value
key
}
... on CompositionComponentNode {
component {
...ItemsInContentArea
}
}
}
}
}
}
}
}
}
}
_metadata {
key
version
}
}
}
}
Summary
The key components of Visual Builder (Experience, Section, Element, and Blueprints) work together to provide a flexible and intuitive content creation experience. With proper implementation of custom types, wrapper components, and GraphQL queries, developers can unlock the full potential of Visual Builder for their Optimizely CMS projects.