Step 15 of 21 (71% complete)
Visual Builder
Step Code
The code for this specific step can be found on the following branch:
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:
Source
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.
Have questions? I'm here to help!