<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title type="text">Blog posts by Minesh Shah</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/" /><updated>2026-10-27T09:44:35.0000000Z</updated><id>https://world.optimizely.com/blogs/Minesh-Shah/</id><generator uri="https://world.optimizely.com/blogs/Minesh-Shah/" version="2.0">Optimizely World</generator><entry><title>Virtual Teammates in Optimizely Mark: a hands-on first look</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/10/virtual-teammates-in-optimizely-mark-a-hands-on-first-look/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/10/virtual-teammates-in-optimizely-mark-a-hands-on-first-look/</id><published>2026-10-27T09:44:35.0000000Z</published><updated>2026-10-27T09:44:35.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;Virtual Teammates are the most interesting thing Optimizely has shipped on its agent platform this year, and the hardest to evaluate from a demo. They are not another agent. They are persistent AI workers with their own login, mailbox, memory and job description, and they keep working when you close the tab.&lt;/p&gt;
&lt;p&gt;The Optimizely MVP organisation has just been given access, so I spent a day taking them apart: reading the docs, walking every screen in the directory, and enabling the SEO &amp;amp; AI Search Analyst against a real site with real Google Analytics 4 and Search Console data. Every screenshot below is from that session, unedited apart from cropping. This post covers what they are, how they are configured end to end, what my teammate actually found, where I think they earn their keep, and the gotchas I hit on the way.&lt;/p&gt;
&lt;p&gt;A quick note on naming, because it will trip you up. Opal is now the Optimizely Agent Platform, and the assistant and app you work in are called Mark. The UI says Mark, the docs mostly say Mark, and some help articles and the release notes page still say Opal. They are the same product. Virtual Teammates were released on 25/08/2026 with five roles. The Web Manager and Content Marketer arrived on 13/10/2026, and a Translation Specialist is now in the directory too.&lt;/p&gt;
&lt;h2&gt;Teammate, not agent&lt;/h2&gt;
&lt;p&gt;The difference is persistence and identity. An agent runs when you invoke it and forgets you when it finishes. A Virtual Teammate is hired once, given a job, and then runs that job on a schedule or in response to events, building up memory as it goes.&lt;/p&gt;
&lt;p&gt;Optimizely describes four layers, and they map neatly onto things you already know in Mark:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tools.&lt;/strong&gt; Connectors to systems like GA4, Search Console, Semrush, HubSpot or Slack. The same connectors your agents already use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agents and workflows.&lt;/strong&gt; Packaged tasks the teammate runs from Mark, with your permission.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Jobs.&lt;/strong&gt; Workflows the teammate runs on its own, on a schedule or an event. This is the bit that makes it a teammate.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Teammate layer.&lt;/strong&gt; A persistent identity, memory, personality and job description wrapped around the rest.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The identity part is more literal than I expected. Each teammate gets its own Opti ID account, login and email address, flagged as a virtual user. It does not consume a product seat. It inherits nothing from the person who hired it, so you grant it access exactly as you would a new starter. Inside Optimizely products it acts as itself. Outside them it acts through accounts a human connects on its behalf, because a teammate cannot complete an OAuth consent itself.&lt;/p&gt;
&lt;p&gt;You work with it through &lt;strong&gt;Team Messages&lt;/strong&gt;, a Slack-style messaging layer inside Mark with direct messages, groups and channels. Teammates can message each other too, and can join Teams and Slack meetings to share summaries. The default autonomy model is sensible: the teammate drafts and proposes, you approve or redirect in Team Messages, and you widen what it can do unattended as trust builds.&lt;/p&gt;
&lt;h2&gt;The directory: eight teammates&lt;/h2&gt;
&lt;p&gt;The MVP organisation's directory lists eight teammates. Each has a CV-style card ("Why hire me", "Experience", "Tools used") and a set of jobs you can switch on or off when enabling it. All jobs are on by default.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/3834f247b43f46a9986580411f2d6cf6.aspx" alt="The Virtual Teammates directory in Mark. Each card is a &amp;quot;resume&amp;quot; with the jobs it does and the tools it works with." width="1100" height="470" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;The Virtual Teammates directory in Mark. Each card is a "resume" with the jobs it does and the tools it works with.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The table below is taken from the enable screens in the MVP instance on 27/10/2026.&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 16px 0 24px 0; font-size: 0.92em;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left; vertical-align: bottom; padding: 8px 10px; background-color: #f0f2ee; border-bottom: 2px solid #c9ccc5;"&gt;Teammate&lt;/th&gt;
&lt;th style="text-align: left; vertical-align: bottom; padding: 8px 10px; background-color: #f0f2ee; border-bottom: 2px solid #c9ccc5;"&gt;Jobs&lt;/th&gt;
&lt;th style="text-align: left; vertical-align: bottom; padding: 8px 10px; background-color: #f0f2ee; border-bottom: 2px solid #c9ccc5;"&gt;Connectors it asks for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3; width: 22%; font-weight: bold; hyphens: none; word-break: normal;"&gt;SEO &amp;amp; AI Search Analyst&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;Audit AEO readiness (daily), Research industry trends, Monitor performance (SEO and GEO anomaly scan), Research keywords (daily, from Conductor and Search Console)&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;GA4, Search Console at enablement. Conductor and Semrush at hire.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3; width: 22%; font-weight: bold; hyphens: none; word-break: normal;"&gt;Marketing Analyst&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;Traffic and acquisition pulse (weekly), Content performance (weekly), Conversion summary (monthly), Anomaly detection (daily), Marketing Analyst actions, Self evaluation&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;GA4, Search Console, Tag Manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3; width: 22%; font-weight: bold; hyphens: none; word-break: normal;"&gt;CRO Manager&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;Generate experimentation roadmap (PIE scored), Write test plans, Monitor experiment portfolio (daily SRM and stat sig checks), Analyse and report results, Configure experiments&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;GA4. Works natively in Web Experimentation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3; width: 22%; font-weight: bold; hyphens: none; word-break: normal;"&gt;Personalization Strategist&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;Identify opportunities, Design campaign plan, Build campaign, Monitor performance, Impact overview&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;GA4, Search Console, Salesforce, Google Ads, Figma&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3; width: 22%; font-weight: bold; hyphens: none; word-break: normal;"&gt;Content Marketer&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;Monitor industry trends (daily), Track competitor content (weekly), Surface pipeline gaps (weekly, from Salesforce), Self evaluation&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;Salesforce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3; width: 22%; font-weight: bold; hyphens: none; word-break: normal;"&gt;Web Manager&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;Perform audit compliance, Manage site health, Manage website updates, Audit report actions&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;None. Works natively in CMS.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3; width: 22%; font-weight: bold; hyphens: none; word-break: normal;"&gt;Translation Specialist&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;Create translations, Audit translations&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;None. Works natively in CMS.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3; width: 22%; font-weight: bold; hyphens: none; word-break: normal;"&gt;Chief of Staff&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;Daily brief, Weekly brief, Meeting prep, Meeting recap, Competitive intel&lt;/td&gt;
&lt;td style="vertical-align: top; padding: 8px 10px; border-bottom: 1px solid #e3e3e3;"&gt;Teams, Outlook mail and calendar, OneDrive and SharePoint, or Gmail, Drive and Google Calendar. Plus Salesforce.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two things stand out. First, the Marketing Analyst and Content Marketer both ship a &lt;strong&gt;Self evaluation&lt;/strong&gt; job that scores each run against expected results and logs what worked. That is the memory story in practice, and it is the job I would watch most closely. Second, the Web Manager and Translation Specialist need no third-party connectors at all. For CMS-heavy clients they are the lowest-friction place to start.&lt;/p&gt;
&lt;h2&gt;Configuration, end to end&lt;/h2&gt;
&lt;p&gt;Setup happens in two halves: an admin enables a teammate for the organisation, then any permitted user hires their own copy of it. It touches four places: Mark, Connect Platform (the old ODP app directory), Opti ID, and the third-party tool itself. Here is the full path I took, in order.&lt;/p&gt;
&lt;h3&gt;1. Before you start: email domain (admin)&lt;/h3&gt;
&lt;p&gt;Check &lt;strong&gt;Settings &amp;gt; Email Domain&lt;/strong&gt; in Mark. Every teammate gets a mailbox on your Mark email domain, so this must be configured and verified first. In the MVP org it already was.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/60e8418f413c4e00b19fa3d5147e950b.aspx" alt="Settings &amp;gt; Email Domain. The verified domain is where each teammate's mailbox lives." width="1320" height="230" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Settings &amp;gt; Email Domain. The verified domain is where each teammate's mailbox lives.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;2. Enable the teammate and choose its jobs (admin)&lt;/h3&gt;
&lt;p&gt;Go to &lt;strong&gt;Directory &amp;gt; Virtual Teammates&lt;/strong&gt;, or &lt;strong&gt;Grow your team &amp;gt; Browse the directory&lt;/strong&gt; from the empty Teammates page, and select &lt;strong&gt;Details&lt;/strong&gt; on a card. This drops you straight into the enable wizard, where every job is on by default. Turn off any you don't want offered. Once a teammate is enabled, the same list lives under the cog on its card as &lt;strong&gt;Manage jobs&lt;/strong&gt;, next to &lt;strong&gt;Freeze hiring&lt;/strong&gt;.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/511c87e01bb748f1af3a64f3ee6ab839.aspx" alt="The SEO &amp;amp; AI Search Analyst's four jobs. Admins can switch any of them off for the whole organisation." width="710" height="540" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;The SEO &amp;amp; AI Search Analyst's four jobs. Admins can switch any of them off for the whole organisation.&lt;/em&gt;&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/b35d027bacec4150b4584280816f204d.aspx" alt="After enablement the card shows &amp;quot;1 hired&amp;quot; and a Hire button. The cog holds the organisation-level controls." width="730" height="595" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;After enablement the card shows "1 hired" and a Hire button. The cog holds the organisation-level controls.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;3. Install the connectors (admin)&lt;/h3&gt;
&lt;p&gt;Select &lt;strong&gt;Set up connectors&lt;/strong&gt;. The wizard lists the third-party tools the teammate needs, with a tick for anything already installed and &lt;strong&gt;Add connector&lt;/strong&gt; for anything missing. This example is the Marketing Analyst, where GA4 and Search Console were already in place and Tag Manager was not.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/cb4735184aef4e69ac01528df2269483.aspx" alt="The connector step of the enable wizard." width="680" height="630" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;The connector step of the enable wizard.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Add connector&lt;/strong&gt; opens the connector's app in Connect Platform in a new tab. GA4 showed as installed when the tab opened; Search Console needed &lt;strong&gt;Install App&lt;/strong&gt;. Installing accepts the third-party add-on terms.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/95a85a09cb164a119f5676a130ba2cb3.aspx" alt="The Search Console connector is an app in Connect Platform's App Directory." width="880" height="368" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;The Search Console connector is an app in Connect Platform's App Directory.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;4. Configure GA4 in Connect Platform (admin)&lt;/h3&gt;
&lt;p&gt;Search Console needs no org-level configuration because each user authenticates with OAuth. GA4 does. Open the GA4 app's &lt;strong&gt;Settings&lt;/strong&gt; tab and you get four sections.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/727b1cd6f416457bb19e3c50728f90e7.aspx" alt="The GA4 Mark app settings. The instance-level OAuth toggle is off by default, so each person uses their own Google login." width="1040" height="605" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;The GA4 Mark app settings. The instance-level OAuth toggle is off by default, so each person uses their own Google login.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Under &lt;strong&gt;GA4 Properties&lt;/strong&gt;, add the property ID (from GA4 under Admin &amp;gt; Property details) and an optional nickname. I added realyield.co.uk.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/7fe66656d3c441e48c3b56377a22bb20.aspx" alt="The property ID in Google Analytics, under Admin &amp;gt; Property details." width="995" height="390" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;The property ID in Google Analytics, under Admin &amp;gt; Property details.&lt;/em&gt;&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/d9197da7f72745cab0f502d64ed41e11.aspx" alt="The property registered in the connector, with its nickname." width="1040" height="785" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;The property registered in the connector, with its nickname.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Global Filter&lt;/strong&gt; lets you pin every standard report to one hostname. It is always applied and Mark cannot override it, which is useful when one GA4 property tracks several sites. It does not apply to realtime reports.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/2909088b4fab4f20a0242dd94b3dee04.aspx" alt="The optional hostname filter." width="740" height="665" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;The optional hostname filter.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Finally, under &lt;strong&gt;Add to Mark&lt;/strong&gt;, select &lt;strong&gt;Add to Mark&lt;/strong&gt; to register the tools with your Mark account. The spinner gave no feedback and reverted, but it had worked: about a minute later the row showed a tick with Remove from Mark and Sync, and the Mark Connectors page had flipped from "Add connector" to "Connect".&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/92664178d846445cabf41406c6d07ea5.aspx" alt="Registered. Do the same for the Search Console app." width="1040" height="455" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Registered. Do the same for the Search Console app.&lt;/em&gt;&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/5ebb0019a68a4bf48b41d1acb204c56d.aspx" alt="The Connectors page in Mark once both Google connectors were registered." width="1105" height="785" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;The Connectors page in Mark once both Google connectors were registered.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Back in the wizard, select &lt;strong&gt;Enable teammate&lt;/strong&gt;. The card in the directory now says &lt;strong&gt;Hire&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;5. Hire your teammate (any permitted user)&lt;/h3&gt;
&lt;p&gt;Hiring is a four-screen wizard:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Name it.&lt;/strong&gt; Select &lt;strong&gt;Hire&lt;/strong&gt; and give it a name. Mine is Rankin. A name with no spaces keeps the email address clean. You can change the avatar here too.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose where it works.&lt;/strong&gt; Pick the product and instance. The MVP org offered cms and oa. I left it unset, as realyield does not run on Optimizely. The screen is honest that this only tells it where to work. It does not grant access.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Connect its tools.&lt;/strong&gt; Under &lt;strong&gt;Day one essentials&lt;/strong&gt;, select &lt;strong&gt;Connect&lt;/strong&gt; for each tool. This opens a Google OAuth pop-up that a human must complete, because the teammate cannot consent for itself. Conductor and Semrush appear here too, but as "Set up its tools on the Connectors page first".&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Start working together.&lt;/strong&gt; Once both show Connected, select &lt;strong&gt;Start working together&lt;/strong&gt;, then &lt;strong&gt;Go to your teammate&lt;/strong&gt;. You land in a Team Messages DM.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/135329bbf0824f45baacc9074d305c47.aspx" alt="The hire wizard, from naming to connected tools." width="1158" height="1000" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;The hire wizard, from naming to connected tools.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;6. Grant access inside Optimizely products (Opti ID admin)&lt;/h3&gt;
&lt;p&gt;If the teammate will work inside Optimizely products, give its Opti ID user a role on each instance in the Opti ID Admin Center. For CMS 13 and CMS SaaS, also add it under &lt;strong&gt;Settings &amp;gt; Set Access Rights&lt;/strong&gt; with only the rights its jobs need (Read, Create, Change, Delete, Publish or Administer). I skipped this, as Rankin only needed Google data.&lt;/p&gt;
&lt;h2&gt;Managing a teammate&lt;/h2&gt;
&lt;p&gt;Every hired teammate appears under &lt;strong&gt;Virtual Teammates &amp;gt; Teammates&lt;/strong&gt; with its own email address, managers, status and start date.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/89a0c15fbc2c4b3d9af804b8b03010b8.aspx" alt="Rankin on the Teammates list, with its own address on the Mark email domain." width="1330" height="400" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Rankin on the Teammates list, with its own address on the Mark email domain.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Each teammate has two areas. &lt;strong&gt;Supervise&lt;/strong&gt; shows Activity (upcoming and completed job runs, with status, priority and trigger) and Work (everything it has produced).&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/1b616769ed764883971ee04afaa4dc03.aspx" alt="Activity. The four scheduled jobs are queued, with Audit AEO readiness next up." width="1305" height="665" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Activity. The four scheduled jobs are queued, with Audit AEO readiness next up.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Settings&lt;/strong&gt; covers Identity, Tools, Product Connections and Permissions. Identity holds the basics and a personality, which shapes how it writes and reasons. There are four templates, or you can build your own.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/ea432a6c11c543b2ab81ecaba9d6dcf1.aspx" alt="Identity: name, email and start date." width="1305" height="595" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Identity: name, email and start date.&lt;/em&gt;&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/45e32531c90c4d2592d68e4fc3309449.aspx" alt="The personality templates: the tastemaker, the hype machine, the pragmatist and the stress tester." width="1035" height="605" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;The personality templates: the tastemaker, the hype machine, the pragmatist and the stress tester.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Tools shows what it can reach. Connections you own can be disconnected here; anything an admin has not set up shows as "Contact Admin".&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/65076206db1c4a95bd99c5377a4afa25.aspx" alt="Rankin's tools. GA4 and Search Console are connected; Semrush and Conductor need an admin." width="1090" height="490" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Rankin's tools. GA4 and Search Console are connected; Semrush and Conductor need an admin.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Permissions is where the governance lives. Roles set who manages the teammate and which other people can message it.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/359b7e1c59f3400b83d3fde04a73d641.aspx" alt="Roles. Managers get full access; teammates only what you allow." width="1035" height="725" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Roles. Managers get full access; teammates only what you allow.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Conversation guardrails set what it shares with each audience: managers, named teammates, and anyone in the organisation.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/82aca7a591c447b8865bd1ae41352f1b.aspx" alt="Conversation guardrails per audience." width="1035" height="430" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Conversation guardrails per audience.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Jobs can be reordered, switched off, or added. &lt;strong&gt;Add job&lt;/strong&gt; offers Create job or Install from catalog, so you are not limited to the defaults.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/acfefe33c2b84eb591dbf25072d98f2e.aspx" alt="Jobs, with the Add job menu open." width="1035" height="432" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Jobs, with the Add job menu open.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Interactions lists the channels it listens on. By default that is instant messaging, and the description shows the tool set it uses to read and reply in Team Messages. Deactivation sits at the bottom and is permanent.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/64e0ccbeebd245ee932398ccd8cfea40.aspx" alt="Interactions and deactivation." width="1035" height="495" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Interactions and deactivation.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;Hands-on: what Rankin found on realyield.co.uk&lt;/h2&gt;
&lt;p&gt;Rankin found a likely GA4 tracking break on its first run, and that alone made the setup worthwhile. realyield.co.uk is a UK buy-to-let analysis side project of mine. It is a small site, which makes it a good test: there is not much data to hide behind.&lt;/p&gt;
&lt;h3&gt;The onboarding interview&lt;/h3&gt;
&lt;p&gt;The first message was not a report. It was six questions: domain, competitors, business goals and how SEO contributes to each, priority content areas, keyword focus, and which actions it may take without approval.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/d15cc8321786490bafba875490a9e404.aspx" alt="Rankin's opening message in Team Messages." width="950" height="475" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Rankin's opening message in Team Messages.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I answered in one message and told it to propose everything first. It played the configuration back, flagged that I had not given a fixed keyword list, and said it had no data yet so would not invent findings.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/7809126a70d74e6c90156b7ffb6de411.aspx" alt="My answers, and Rankin's confirmation of its working configuration." width="950" height="655" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;My answers, and Rankin's confirmation of its working configuration.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;The first snapshot&lt;/h3&gt;
&lt;p&gt;I asked for the last 28 days against the previous 28. The reply came six minutes later, clearly labelled as proposals with nothing actioned. The headline was not a ranking problem at all:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GA4 Organic Search sessions fell 59% (164 against 398) while Search Console clicks fell only 17% (209 against 251).&lt;/li&gt;
&lt;li&gt;Every GA4 channel dropped together: 381 sessions against 934 in total, with Direct, Organic Social and AI Assistant traffic all falling at once.&lt;/li&gt;
&lt;li&gt;Organic sessions ran at 12 to 20 a day until 1 October, then 1 to 7 a day from 2 October. Search Console clicks show no such step. The GA4-to-GSC ratio went from 1.6x to 0.5x overnight.&lt;/li&gt;
&lt;li&gt;It concluded this is probably a tag or consent change around 1 to 2 October, marked it as an unconfirmed hypothesis, and told me to treat GA4 page-level data as unreliable until it is checked.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/2c27d56e5d6d4536a281daeded7894b0.aspx" alt="The first half of the snapshot: the tracking flag and the Search Console scoreboard." width="950" height="710" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;The first half of the snapshot: the tracking flag and the Search Console scoreboard.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;That is exactly the cross-source reasoning a human analyst should do and usually doesn't. The raw data backs it up. GA4's traffic acquisition report shows every channel falling off a cliff at the start of October:&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/a40f459956f9470c963533092055c5c6.aspx" alt="GA4 traffic acquisition for realyield.co.uk, last 90 days. Every channel steps down at the start of October." width="1005" height="560" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;GA4 traffic acquisition for realyield.co.uk, last 90 days. Every channel steps down at the start of October.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Search Console over the same period shows no such step in clicks:&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/17632a1427a54539bef81fc9af4d4426.aspx" alt="Search Console performance for realyield.co.uk, last three months. Clicks hold steady through October." width="1000" height="500" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Search Console performance for realyield.co.uk, last three months. Clicks hold steady through October.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The Search Console analysis was just as specific. Clicks were within one standard deviation of the daily mean since 1 July (8.7 a day, SD 3.8, against 7.5 now). 41% of the impression loss was a single query, "rent vs buy calculator", spiking to 6,536 impressions and falling back to 41. The real drift was position: impression-weighted average position went from 16.0 to 21.7 over four weeks, with desktop slipping from 16.6 to 22.4 while mobile improved from 15.4 to 13.6.&lt;/p&gt;
&lt;p&gt;It then broke down top pages and found the anomalies worth acting on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;/tools/personal-vs-ltd-company&lt;/strong&gt; lost 45% of the total click loss on its own. Position held and impressions fell only 9%, so CTR halved from 3.7% to 1.9%. A CTR problem, not a ranking one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Location pages:&lt;/strong&gt; 270 URLs with impressions (5,053 impressions, 29 clicks), and 90% of them with no clicks at all. "average rent in" queries had 381 impressions at position 9.7 and no clicks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The core net yield calculator&lt;/strong&gt; sits at position 55.7 with no clicks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Housekeeping:&lt;/strong&gt; the login subdomain appears in search and should be noindex.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/c33445ce006044628872b0abe0e687df.aspx" alt="Top pages, top queries and the other anomalies." width="950" height="580" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;Top pages, top queries and the other anomalies.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;The action plan&lt;/h3&gt;
&lt;p&gt;Five prioritised proposals, each with a first step: fix GA4 tracking and add calculator completion as a key event; rework the core calculator page for "rental yield calculator" and "net yield calculator"; pilot a new location page template on six named cities and read CTR after four weeks; recover CTR on the page that halved; and build a manual AI-citation baseline of about 20 questions because no citation tool is connected. It then listed two further opportunities outside my stated priorities and asked for a steer rather than assuming.&lt;/p&gt;
&lt;p style="text-align: center; margin: 24px 0 4px 0;"&gt;&lt;img style="width: 100%; max-width: 100%; height: auto; border: 1px solid #d9d9d9; border-radius: 4px;" src="/link/f3861225c12c4962a7dcc1c21e8193fa.aspx" alt="AI visibility, the five proposed actions, and the questions it wanted answered." width="950" height="450" /&gt;&lt;/p&gt;
&lt;p style="text-align: center; font-size: 0.9em; color: #5c5c5c; margin: 0 0 24px 0;"&gt;&lt;em&gt;AI visibility, the five proposed actions, and the questions it wanted answered.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;Where it fell short&lt;/h3&gt;
&lt;p&gt;It was honest about it. With no Conductor, Semrush or AI-citation connector, it could not report competitor rankings or citation share, which is half of the "AI Search" in its job title. It also could not find a Team Messages channel to post into, so it put everything in our DM. And at the time of writing none of its four scheduled jobs had run yet, so the daily cadence, memory and self-improvement claims are still untested. I'll follow up once it has a fortnight of runs behind it.&lt;/p&gt;
&lt;h2&gt;Where they earn their keep&lt;/h2&gt;
&lt;p&gt;The pattern that fits is recurring, data-heavy work that nobody owns and everybody means to do. Teammates are weakest where the work is a one-off, and strongest where the value comes from turning up every day. Here is where I would start with clients, roughly in order of effort.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Search and AEO monitoring for marketing teams without an SEO.&lt;/strong&gt; The SEO &amp;amp; AI Search Analyst gives a daily AEO readiness audit, a keyword digest and anomaly alerts off GA4 and Search Console. For mid-market Optimizely clients who rely on an agency retainer for this, it is a real change in cadence.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weekly reporting that people actually read.&lt;/strong&gt; The Marketing Analyst replaces the Monday GA4 export. Its 15% channel-movement threshold and "content win of the week" are opinionated defaults, which is what you want from a report.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Content governance on CMS 13 and SaaS.&lt;/strong&gt; The Web Manager scans content at publish time for brand, legal, style and accessibility issues and stages fixes for approval. Pair it with the Translation Specialist for multi-locale sites. Neither needs a third-party connector, so the only real setup is CMS access rights.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Experimentation hygiene.&lt;/strong&gt; The CRO Manager's daily SRM, zero-conversion and overrun checks are the boring safety net most programmes lack. Its test plans include sample size and runtime, and it stages experiments in Web Experimentation as drafts for QA.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Personalisation programmes that stall after launch.&lt;/strong&gt; The Personalization Strategist runs the whole loop from opportunity to campaign build to impact overview, all staged for review.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leadership admin.&lt;/strong&gt; The Chief of Staff does daily and weekly briefs, meeting prep and recaps across Microsoft 365 or Google Workspace. Useful, but it competes with Copilot and Gemini, which most clients already pay for.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For partners there is a second angle. A teammate is a packaged, governed role you can hand a client in week one of a build, before their own team is in place. That changes the shape of a launch: the site goes live with an always-on analyst and a content governance check already running.&lt;/p&gt;
&lt;h2&gt;Governance, credits and gotchas&lt;/h2&gt;
&lt;p&gt;The governance model is the strongest part of the design. Teammates are real Opti ID users, so access is granted, audited and revoked like any starter's. Managers get full access; other teammates see only what conversation guardrails allow; jobs can be switched off per teammate by its manager, or per organisation by an admin, who can also freeze hiring. Pausing keeps history. Deactivating is permanent and deletes the teammate's memory and context, so pause unless you mean it.&lt;/p&gt;
&lt;p&gt;Credits are the open question. Mark uses pooled credits across eligible products, and Scott Reed's &lt;a style="color: #1a5fb4; text-decoration: underline;" href="/link/8233085025bc4502b3e8322367f77592.aspx"&gt;October state of play&lt;/a&gt; notes that scheduled, webhook and email triggers bill to whoever created the trigger, at an average of 2 credits for chat up to 200 for an XL agent run. The Virtual Teammates docs do not yet say how a teammate's daily jobs are metered or who pays. A teammate running four daily jobs could be a meaningful line item, so model it before you put one in front of a client.&lt;/p&gt;
&lt;p&gt;The gotchas I hit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Org-level data, personal teammate.&lt;/strong&gt; GA4 property IDs are registered on the connector app for the whole organisation, so everyone in the org sees the list. Data access still runs through each person's own Google OAuth by default, so the exposure is the property name and ID, not the data. That changes if an admin turns on the app's instance-level OAuth option. Think about both before listing client properties in a shared partner sandbox.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No feedback on registration.&lt;/strong&gt; "Add to Mark" in Connect Platform spins, then silently reverts. It had worked; the Mark Connectors page just took a minute to catch up. The enable wizard also does not refresh connector status until you reload it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool lists disagree.&lt;/strong&gt; The SEO card shows four tools, but enablement asks for only GA4 and Search Console. Conductor and Semrush then appear at hire time as "Set up its tools on the Connectors page first", and on the teammate's Tools page as "Contact Admin", even though Semrush shows as installed on the Connectors page.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choosing an instance is not access.&lt;/strong&gt; The UI says so clearly, but it is still an extra trip to the Opti ID Admin Center, and for CMS a second trip to Set Access Rights.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OAuth needs a human.&lt;/strong&gt; Every third-party connection is a pop-up a person must complete. Plan this into onboarding sessions with clients rather than assuming an admin can do it alone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Three names, one product.&lt;/strong&gt; Opal, Agent Platform and Mark all appear in the UI, docs and connector descriptions. Brief clients on this up front.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Verdict&lt;/h2&gt;
&lt;p&gt;Virtual Teammates are worth taking seriously, and worth piloting now on one narrow job. The identity and governance model is properly thought through, the onboarding interview is better than most agency kick-offs, and the first piece of analysis I got back was specific, cautious and correct about the thing that mattered most.&lt;/p&gt;
&lt;p&gt;The weak spots are setup friction and cost visibility. Enablement spans Mark, Connect Platform, Opti ID and the CMS, with status that lags and tool lists that disagree. And until Optimizely documents how a teammate's scheduled jobs consume credits, I would not promise a client a monthly figure.&lt;/p&gt;
&lt;p&gt;My recommendation for partners: pick one client with a clear recurring gap, usually search monitoring or content governance, hire one teammate with propose-only permissions, and review its output weekly for a month before widening its autonomy. That is how you would onboard a junior hire, and it is the right mental model here too.&lt;/p&gt;
&lt;h2&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a style="color: #1a5fb4; text-decoration: underline;" href="https://docs.optimizely.com/agent-platform/docs/virtual-teammates-overview"&gt;Virtual Teammates overview&lt;/a&gt;, Optimizely docs&lt;/li&gt;
&lt;li&gt;&lt;a style="color: #1a5fb4; text-decoration: underline;" href="https://docs.optimizely.com/agent-platform/docs/enable-virtual-teammates"&gt;Enable Virtual Teammates&lt;/a&gt;, Optimizely docs&lt;/li&gt;
&lt;li&gt;&lt;a style="color: #1a5fb4; text-decoration: underline;" href="https://support.optimizely.com/hc/en-us/articles/47212988975885-Hire-a-Virtual-Teammate"&gt;Hire a Virtual Teammate&lt;/a&gt;, Optimizely support&lt;/li&gt;
&lt;li&gt;&lt;a style="color: #1a5fb4; text-decoration: underline;" href="https://support.optimizely.com/hc/en-us/articles/48317514197645-Manage-your-Virtual-Teammate"&gt;Manage your Virtual Teammate&lt;/a&gt;, Optimizely support&lt;/li&gt;
&lt;li&gt;&lt;a style="color: #1a5fb4; text-decoration: underline;" href="https://support.optimizely.com/hc/en-us/articles/48314434860429-Manage-Virtual-Teammates-for-your-organization"&gt;Manage Virtual Teammates for your organization&lt;/a&gt;, Optimizely support&lt;/li&gt;
&lt;li&gt;&lt;a style="color: #1a5fb4; text-decoration: underline;" href="https://docs.optimizely.com/agent-platform/release-notes"&gt;2026 release notes&lt;/a&gt;, Optimizely docs&lt;/li&gt;
&lt;li&gt;&lt;a style="color: #1a5fb4; text-decoration: underline;" href="https://www.optimizely.com/no/field-notes/articles/virtual-teammates-vs-ai-agents-whats-the-difference"&gt;Virtual Teammates vs AI agents&lt;/a&gt;, Optimizely Field Notes&lt;/li&gt;
&lt;li&gt;&lt;a style="color: #1a5fb4; text-decoration: underline;" href="/link/8233085025bc4502b3e8322367f77592.aspx"&gt;Optimizely Agent Platform: October state of play&lt;/a&gt;, Scott Reed&lt;/li&gt;
&lt;li&gt;&lt;a style="color: #1a5fb4; text-decoration: underline;" href="https://www.cmswire.com/digital-experience/optimizely-retires-dxp-label-bets-everything-on-agent-platform/"&gt;Optimizely retires DXP label, bets everything on agent platform&lt;/a&gt;, CMSWire&lt;/li&gt;
&lt;/ul&gt;</summary></entry><entry><title>Categories have landed in Optimizely SaaS CMS: structure, translate, tag and query</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/10/categories-have-landed-in-optimizely-saas-cms-structure-translate-tag-and-query/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/10/categories-have-landed-in-optimizely-saas-cms-structure-translate-tag-and-query/</id><published>2026-10-19T14:37:13.0000000Z</published><updated>2026-10-19T14:37:13.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;Categories arrived in Optimizely SaaS CMS at the end of August, with Graph support alongside. I have wanted a proper taxonomy in SaaS since the first build I did on it. Until now we have been faking it with select lists, string arrays or a folder of "tag" blocks. None of those are great. They are hard for editors to maintain, they do not translate cleanly and they make filtering in Graph clumsy.&lt;/p&gt;
&lt;p&gt;So I spent an afternoon putting the new feature through its paces on our sandbox instance. This post walks through the whole flow with screenshots: building a category structure, translating it, tagging pages and then querying it all in Optimizely Graph. There are a couple of gotchas in the Graph section that are worth knowing before you start building filters.&lt;/p&gt;
&lt;h2&gt;Where to find it&lt;/h2&gt;
&lt;p&gt;Categories live under &lt;strong&gt;Settings &amp;gt; Taxonomy &amp;gt; Categories&lt;/strong&gt;. On a fresh instance you get an empty grid with a locale switcher, a search box and a &lt;strong&gt;Create&lt;/strong&gt; button. Ours already had a single test category in it.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/c964d4ca3af645e19f354892ffd30a8a.aspx" alt="The Categories screen under Settings" /&gt;&lt;/p&gt;
&lt;p&gt;The grid shows Display name, Key, Description, Visible, Selectable, Usage and the usual audit columns. It is wide, so on a laptop you will be scrolling sideways to reach the actions menu at the end of each row.&lt;/p&gt;
&lt;h2&gt;Creating a structure&lt;/h2&gt;
&lt;p&gt;I went with a typical B2B example: an &lt;strong&gt;Industry&lt;/strong&gt; parent, three sectors under it, and a third level under Financial Services. I prefixed everything with "Demo" so it is easy to clean up later.&lt;/p&gt;
&lt;p&gt;Click &lt;strong&gt;Create&lt;/strong&gt; and you get a row-based dialog.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/a7242fc675134e2085c15665f4527625.aspx" alt="Creating a top-level category" /&gt;&lt;/p&gt;
&lt;p&gt;Each category has these fields:&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 8px 0 20px 0;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600; background-color: #eef3ee;"&gt;Field&lt;/th&gt;
&lt;th style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; background-color: #eef3ee;"&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Display name&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;What editors see. Up to 255 characters. Translatable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Key&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;The identifier you use in code and Graph. Up to 100 characters, unique, not translatable. It auto-fills from the display name with spaces swapped for underscores.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Description&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;Optional, up to 500 characters. Translatable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Parent&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;Read only. It is set by where you start the create action.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Visible&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;Whether the category shows in the editor's picker.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Selectable&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;Whether editors can assign it to content.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left; white-space: nowrap; font-weight: 600;"&gt;Usage&lt;/td&gt;
&lt;td style="border: 1px solid #c9d6cc; padding: 8px 10px; vertical-align: top; text-align: left;"&gt;Public or Internal.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The key is the field to get right first time. It is what your front end will depend on, so agree a naming convention with the team before anyone starts typing. The auto-generated value is fine for most cases, but I would not leave it to chance on a real project.&lt;/p&gt;
&lt;p&gt;To add children, open the &lt;strong&gt;...&lt;/strong&gt; menu at the end of a row and choose &lt;strong&gt;Create child&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/37ea2d123b8b4b63b98596920b2445c1.aspx" alt="The row actions menu with Create child" /&gt;&lt;/p&gt;
&lt;p&gt;The nice touch here is the &lt;strong&gt;+&lt;/strong&gt; button at the end of each row. You can add several categories in one go rather than opening the dialog for each one. The Parent column is pre-filled and locked.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/517f9dd973394bfc848188feaea668ec.aspx" alt="Creating three child categories in one go" /&gt;&lt;/p&gt;
&lt;p&gt;I repeated that on Financial Services to add Banking and Insurance as a third level.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/12cd1c737ecb4a64a7a3ee8a758cad88.aspx" alt="Adding a third level under Financial Services" /&gt;&lt;/p&gt;
&lt;p&gt;The result is an expandable tree.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/793c99fd2bf743268154ca9cf5a60e71.aspx" alt="The finished category tree" /&gt;&lt;/p&gt;
&lt;p&gt;A few things from the documentation that are worth calling out, because they will bite you if you do not plan for them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You cannot move a category to a different parent after it is created.&lt;/strong&gt; You have to delete it and recreate it. Get the hierarchy agreed before you load it.&lt;/li&gt;
&lt;li&gt;Optimizely recommend keeping trees to &lt;strong&gt;three or four levels&lt;/strong&gt;. I agree. Deeper than that and editors stop being able to find anything.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Visible and Selectable do not cascade.&lt;/strong&gt; Hiding a parent does not hide its children. This is useful, though. Clearing both flags deprecates a category without deleting it. Leaving Visible on and clearing Selectable gives you a "historic" category that still shows on old content but cannot be applied to anything new.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Usage is metadata only.&lt;/strong&gt; Marking a category as Internal does not stop it being indexed or returned by Graph. It is a hint for your front end to filter on, not a security control. You also cannot create a Public child under an Internal parent.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My advice: deprecate rather than delete. Deleting a category removes it from every piece of content it was tagged on.&lt;/p&gt;
&lt;h2&gt;Translating categories&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;Locale&lt;/strong&gt; dropdown at the top of the grid lists the languages enabled for CMS content. Our instance has English as the master, plus Chinese, Spanish and Swedish.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/3ce101818a484b71a964c7dcc856ea7e.aspx" alt="The locale switcher on the Categories screen" /&gt;&lt;/p&gt;
&lt;p&gt;Switch to another language and anything without a translation falls back to the master value. It is shown in italics with a small &lt;strong&gt;EN&lt;/strong&gt; badge so you can see at a glance what still needs doing. This fallback behaviour came in with a fix on 7 October. Before that, untranslated rows just showed blank.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/4a619cfb1bfa402d8012c6c1496ec3be.aspx" alt="Untranslated categories fall back to English with an EN badge" /&gt;&lt;/p&gt;
&lt;p&gt;Open &lt;strong&gt;... &amp;gt; Edit&lt;/strong&gt; on a row and you get an "Edit espa&amp;ntilde;ol translation" dialog. The master values appear as placeholder text. That placeholder is never saved as a translation, so you will not end up with English silently copied into your Spanish data. The key is greyed out because it is shared across all languages.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/699b2e55574841d1bd4d17252d0c4d0c.aspx" alt="Editing the Spanish translation of a category" /&gt;&lt;/p&gt;
&lt;p&gt;Once translated, the tree shows the Spanish names. I translated the whole Demo Industry branch. Note that the grid re-sorts by the translated display name, so rows move about as you work through them.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/a08186c57e9d43ddaaca6381cad7f7dd.aspx" alt="The translated Spanish category tree" /&gt;&lt;/p&gt;
&lt;p&gt;Translation is one row at a time for now. Fine for a demo. For a real taxonomy of a few hundred terms across several languages, I would want a bulk option, or I would script it. The 22 September release also lets you ask Mark, the Agent Platform assistant, to create and update categories for you if you have CMS admin rights. I have not tried that for translations yet.&lt;/p&gt;
&lt;h2&gt;Tagging pages with categories&lt;/h2&gt;
&lt;p&gt;This is the part that surprised me. &lt;strong&gt;There is no Category property to add to your content types.&lt;/strong&gt; I went looking under &lt;strong&gt;Add Property&lt;/strong&gt; on our Blog post type and there is nothing taxonomy related in the list. Categories are built in.&lt;/p&gt;
&lt;p&gt;Open any page in the editor and a &lt;strong&gt;Category&lt;/strong&gt; field is already there. Start typing and it searches across the whole tree, showing the full path for each match. That path matters when you have two terms with similar names in different branches.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/5f787f7632a5429882946199bb6f4c6f.aspx" alt="Searching for a category on a page shows the full path" /&gt;&lt;/p&gt;
&lt;p&gt;Tick as many as you need. Each one becomes a removable chip.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/64da6975a7b24a44bf6d3aaa6b5fe941.aspx" alt="Banking and Retail selected on the page" /&gt;&lt;/p&gt;
&lt;p&gt;Only categories that are both Visible and Selectable show up here, which is exactly what you want for the deprecated and historic cases above.&lt;/p&gt;
&lt;p&gt;Two behaviours from the docs to be aware of. Categories are assigned in the master language, and the localised names appear on translated versions automatically. Categories are also versioned with the content, so a change on a draft only goes live when that draft is published.&lt;/p&gt;
&lt;p&gt;Our Blog post type still has an old "Topic" select list from before this feature existed. If you have something similar, I would plan to migrate it. One managed taxonomy beats a dozen hard-coded option lists scattered across content types.&lt;/p&gt;
&lt;h2&gt;Querying categories in Optimizely Graph&lt;/h2&gt;
&lt;p&gt;I published the demo page and moved over to &lt;strong&gt;Settings &amp;gt; GraphiQL&lt;/strong&gt;. The documentation for this part is thin right now, so everything below comes from introspecting the schema and testing queries.&lt;/p&gt;
&lt;h3&gt;Querying the category tree&lt;/h3&gt;
&lt;p&gt;Categories are indexed as their own items under a new root query: &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_TaxonomyTerm&lt;/span&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-graphql hljs"&gt;query CategoryTree {
  _TaxonomyTerm(
    locale: en
    where: { _metadata: { taxonomy: { eq: "categories" } } }
    orderBy: { _metadata: { displayName: ASC } }
  ) {
    total
    items {
      _metadata {
        key
        displayName
        parent
        usage
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/79d85a6e14de41a59f9fae1a604cae70.aspx" alt="Querying the category tree in GraphiQL" /&gt;&lt;/p&gt;
&lt;p&gt;The useful fields on &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_metadata&lt;/span&gt; are &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;key&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;displayName&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;description&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;parent&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;usage&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;locale&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;status&lt;/span&gt; and &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;taxonomy&lt;/span&gt;. &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;parent&lt;/span&gt; holds the parent's key, or &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;null&lt;/span&gt; for a root category. That gives you everything you need to rebuild the tree on the front end.&lt;/p&gt;
&lt;p&gt;&lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;taxonomy&lt;/span&gt; returns &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;"categories"&lt;/span&gt; for everything today. My guess is that it is there so Optimizely can add more than one taxonomy later. Filtering on it now costs nothing and saves a refactor if that happens.&lt;/p&gt;
&lt;p&gt;To get the direct children of a term, filter on &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;parent&lt;/span&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-graphql hljs"&gt;query ChildrenOfIndustry {
  _TaxonomyTerm(
    locale: en
    where: { _metadata: { parent: { eq: "Demo_Industry" } } }
  ) {
    items {
      _metadata {
        key
        displayName
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Localised category names&lt;/h3&gt;
&lt;p&gt;Pass a locale and you get the translated values back:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-graphql hljs"&gt;query LocalisedCategory {
  _TaxonomyTerm(
    locale: sv
    where: { _metadata: { key: { eq: "Demo_Industry" } } }
  ) {
    items {
      _metadata {
        key
        displayName
        description
        locale
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/29476a5b777842a88034a33b2318b7a2.aspx" alt="A Swedish translation returned from Graph" /&gt;&lt;/p&gt;
&lt;p&gt;Two gotchas here.&lt;/p&gt;
&lt;p&gt;First, &lt;strong&gt;the master language fallback you see in the CMS does not apply in Graph.&lt;/strong&gt; I translated the display name into Swedish but left the description empty. The CMS shows the English description in italics. Graph returns &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;null&lt;/span&gt;. If you need a fallback, you have to build it yourself, either with a second query on the master locale or in your front-end mapping.&lt;/p&gt;
&lt;p&gt;Second, &lt;strong&gt;my Spanish translations never appeared in Graph.&lt;/strong&gt; Querying with &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;locale: es&lt;/span&gt; fails validation because &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;es&lt;/span&gt; is not in the schema's &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;Locales&lt;/span&gt; enum.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/4669857275f4402ebb576922c7b13389.aspx" alt="Spanish is missing from the Locales enum" /&gt;&lt;/p&gt;
&lt;p&gt;On our instance the only languages in the enum are &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;en&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;sv&lt;/span&gt; and &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;zh&lt;/span&gt;. Those are the languages we already have content in. Spanish is enabled in the CMS, but nothing else on the site is published in Spanish. Swedish translations, on the other hand, showed up within ten seconds of saving.&lt;/p&gt;
&lt;p&gt;My working theory is that a language has to exist in Graph's index before category translations in that language are picked up. I have not confirmed that with Optimizely, so treat it as an observation rather than documented behaviour. If your translated categories are not coming through, check the &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;Locales&lt;/span&gt; enum in GraphiQL first.&lt;/p&gt;
&lt;h3&gt;Querying content by category&lt;/h3&gt;
&lt;p&gt;This is the bit you will use most, and it is not where I expected it to be. Categories are &lt;strong&gt;not&lt;/strong&gt; on the usual &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_metadata&lt;/span&gt; field. They are on a separate field called &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_itemMetadata&lt;/span&gt;, which exists on every content type in the schema.&lt;/p&gt;
&lt;p&gt;The values are URIs rather than plain keys, in the format &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;cms://taxonomy/categories/&amp;lt;Key&amp;gt;&lt;/span&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;"_itemMetadata": {
  "categories": [
    "cms://taxonomy/categories/Retail",
    "cms://taxonomy/categories/Banking"
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So to find every blog post tagged with Banking or Insurance, and get facet counts back at the same time:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-graphql hljs"&gt;query PagesByCategory {
  BlogPostPage(
    where: {
      _itemMetadata: {
        categories: {
          in: [
            "cms://taxonomy/categories/Banking"
            "cms://taxonomy/categories/Insurance"
          ]
        }
      }
    }
  ) {
    total
    items {
      Heading
      _metadata {
        url {
          default
        }
      }
      _itemMetadata {
        categories
      }
    }
    facets {
      _itemMetadata {
        categories {
          name
          count
        }
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/52279e31b5f545b3a58bad1c54b62a59.aspx" alt="Filtering pages by category with facets" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;eq&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;in&lt;/span&gt;, &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;startsWith&lt;/span&gt; and &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;exist&lt;/span&gt; all worked as you would expect on a string array. Swap &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;BlogPostPage&lt;/span&gt; for &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_Content&lt;/span&gt; and the same filter works across every content type, which is handy for a site-wide "everything tagged Banking" listing.&lt;/p&gt;
&lt;p&gt;The facets come back keyed by URI, so your front end needs to strip the prefix and join to the &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_TaxonomyTerm&lt;/span&gt; results to get display names. Something like this does the job:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;const keyFromUri = (uri: string) =&amp;gt; uri.split("/").pop() ?? uri;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;No roll-up through the hierarchy&lt;/h3&gt;
&lt;p&gt;This is the gotcha most likely to catch people out. &lt;strong&gt;Filtering on a parent does not return content tagged with its children.&lt;/strong&gt; The demo page is tagged Banking, which sits under Financial Services. Filtering on &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;cms://taxonomy/categories/Financial_Services&lt;/span&gt; returns zero results.&lt;/p&gt;
&lt;p&gt;The hierarchy exists in the taxonomy, but content only knows about the exact terms it was tagged with. If you want "show me everything in Financial Services", you need to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Query &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_TaxonomyTerm&lt;/span&gt; to collect the keys of the parent and all its descendants.&lt;/li&gt;
&lt;li&gt;Turn them into URIs.&lt;/li&gt;
&lt;li&gt;Pass the list to an &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;in&lt;/span&gt; filter on &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_itemMetadata.categories&lt;/span&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Two round trips, but both are cheap and the taxonomy query caches well. Build it once as a helper and reuse it everywhere.&lt;/p&gt;
&lt;h2&gt;Final thoughts&lt;/h2&gt;
&lt;p&gt;This is a good first release. The admin UI is quick to work with, the multi-row create saves real time, and having categories built into every content item removes a pile of modelling work we used to do by hand. The Graph side gives you everything you need. It just needs a bit of plumbing.&lt;/p&gt;
&lt;p&gt;What I would like to see next:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Graph docs that cover &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_TaxonomyTerm&lt;/span&gt; and &lt;span style="font-family: Consolas, Menlo, Monaco, monospace; font-size: 0.9em; background-color: #eef3ee; color: #08251a; padding: 1px 5px; border-radius: 3px;"&gt;_itemMetadata&lt;/span&gt;, including the URI format.&lt;/li&gt;
&lt;li&gt;An option to roll content up through the hierarchy in Graph.&lt;/li&gt;
&lt;li&gt;Master language fallback in Graph to match the CMS.&lt;/li&gt;
&lt;li&gt;Bulk import and export of categories and translations.&lt;/li&gt;
&lt;li&gt;The ability to change a category's parent without deleting it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you are starting a new SaaS build, I would use categories from day one rather than rolling your own taxonomy. If you have an existing build with select-list or block-based tagging, it is worth planning the migration now, before the content volume makes it painful.&lt;/p&gt;
&lt;h3&gt;Further reading&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/cms-saas/docs/manage-categories"&gt;Manage categories (CMS SaaS docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/cms-saas/docs/manage-content-content-managers#categorize-content"&gt;Categorise content (CMS SaaS docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/cms-saas/release-notes/2026-cms-saas-release-notes"&gt;2026 CMS (SaaS) release notes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</summary></entry><entry><title>Personalisation in CMS 13 when you go headless: variations in the CMS, decisions in Experimentation</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/9/personalisation-in-cms-13-when-you-go-headless-variations-in-the-cms-decisions-in-experimentation/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/9/personalisation-in-cms-13-when-you-go-headless-variations-in-the-cms-decisions-in-experimentation/</id><published>2026-09-24T13:48:00.0000000Z</published><updated>2026-09-24T13:48:00.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;Back in February I wrote about &lt;a style="color: #008a3e;" href="/link/cb8190a3782840fdacf589c9b5b38c0a.aspx"&gt;personalisation in CMS 13 using Audiences&lt;/a&gt;. Everything in that post still holds, with one condition I should have made louder: it only works when the CMS renders the page. The moment your front end is a separate application reading content from Optimizely Graph, Audiences stop doing anything.&lt;/p&gt;
&lt;p&gt;This post covers why that is, and how I would approach personalisation on a headless (or hybrid) CMS 13 build instead. The content still lives in the CMS, but the decision about who sees which content moves to Optimizely's experimentation and personalisation products.&lt;/p&gt;
&lt;h2&gt;Why Audiences do not work on a headless build&lt;/h2&gt;
&lt;p&gt;Audiences (Visitor Groups, as they were) are evaluated inside the CMS process, on each request. When a content area renders, the CMS looks at the current visitor (their referrer, the pages they have viewed in this session, their geography and so on), checks each item's audience settings, and leaves out the items that do not match. The personalised HTML is the output of that render.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/59c834ced2c0416b9464b6f0f85c2cdb.aspx" alt="Personalized Group in a CMS 13 content area, with the Alloy Plan teaser shown only to the Alloy Track for free audience" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Audiences are evaluated when the CMS renders the content area, which is why they only apply to in-process sites.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;On a headless build there is no render in the CMS. Content is published and synchronised into Optimizely Graph once, for everybody, and your Next.js (or other) front end queries Graph when a visitor arrives. Graph never sees the visitor, and the CMS is not in the request path at all, so there is nothing to evaluate the audience against.&lt;/p&gt;
&lt;p&gt;Optimizely's own CMS 13 documentation is explicit about this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;"Audiences work only on traditional (in-process) sites, where CMS manages both content authoring and frontend rendering."&lt;br /&gt;(source: &lt;a style="color: #008a3e;" href="https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/audiences"&gt;Create audiences for personalization&lt;/a&gt;)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The CMS 13 technical Q&amp;amp;A adds that Visitor Groups and Audiences "are not indexed to Graph", and recommends "the new Variations support for content and Experimentation for personalization" for Graph-based delivery (source: &lt;a style="color: #008a3e;" href="https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/faqs-for-cms-13"&gt;CMS 13 technical webinar Q&amp;amp;A&lt;/a&gt;). The "View as audience" preview is also unavailable on decoupled front ends.&lt;/p&gt;
&lt;p&gt;So if your build is headed MVC, carry on with Audiences; the February post still applies. If it is headless or hybrid, read on.&lt;/p&gt;
&lt;h2&gt;The new split: content in the CMS, the decision in Experimentation&lt;/h2&gt;
&lt;p&gt;The recommended model separates two jobs that Audiences used to do together.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The content.&lt;/strong&gt; Editors author each version of a page in the CMS as a &lt;em&gt;content variation&lt;/em&gt;. It gets drafted, reviewed, approved and published like any other content, and it is indexed to Graph.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The decision.&lt;/strong&gt; An experimentation or personalisation engine decides which variation a given visitor should see, and the front end fetches that variation from Graph.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The two are joined by a single string: the variation name. If the engine says "WinterCampaign", the front end asks Graph for the "WinterCampaign" variation of the page. If that variation does not exist, it falls back to the original.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/6c4802448de74037af97b744850ac297.aspx" alt="Diagram of the flow: editors create content variations in CMS 13, which sync to Optimizely Graph. On each visit the front end asks Experimentation which variation to show, then queries Graph for it with the original as the fallback" /&gt;&lt;/p&gt;
&lt;p&gt;I think this is a better model than it first appears. Content stays in one place, under the CMS's workflow and version history, rather than being pasted into a testing tool's visual editor. The engine that decides is also the one that measures, so every personalised experience can be tested against the original.&lt;/p&gt;
&lt;h2&gt;Content variations in CMS 13&lt;/h2&gt;
&lt;p&gt;A content variation is a second (or third, or tenth) published version of the same content item, in the same language. Each variation has its own version history and publishing lifecycle, and only stores the properties you actually change (source: &lt;a style="color: #008a3e;" href="https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/create-content-variations"&gt;Create content variations in CMS 13&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Creating one from the editor:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Select the page in the content tree.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Variations &amp;gt; Add variation&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Give it a name, for example &lt;span style="font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;"&gt;WinterCampaign&lt;/span&gt;. It cannot start with a number or contain spaces.&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Add variation&lt;/strong&gt;. The variation opens in Visual Builder and appears in the &lt;strong&gt;Variations&lt;/strong&gt; dropdown.&lt;/li&gt;
&lt;li&gt;Make your changes, then &lt;strong&gt;Publish &amp;gt; Publish changes&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You can also copy a new variation from an existing one using &lt;strong&gt;Copy from&lt;/strong&gt;, and if a variation wins, &lt;strong&gt;Copy changes to Original&lt;/strong&gt; promotes it to become the new default.&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/b51065d16eac4f6ba2dc5775862ef3f7.aspx" alt="Variations dropdown in the CMS 13 editor showing Original and Add variation" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/9e930d504ae2457183f8251e2329532a.aspx" alt="Add variation dialog with the name WinterCampaign entered and Copy from set to Original" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/9caefb04236d4280894c5dbf1f180d68.aspx" alt="WinterCampaign selected in the Variations dropdown, with a winter offer intro in the preview" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/9443396a0e594db09210707013e9f418.aspx" alt="Publish menu on the WinterCampaign variation, including Copy changes to Original" /&gt;&lt;/p&gt;
&lt;p&gt;Two things worth knowing before your editors start:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Name variations for what they are, and treat the name as a contract.&lt;/strong&gt; The name is what the experimentation side will ask for, and it is case sensitive. Rename a variation in the CMS and the front end will quietly serve the original to everyone while the experiment keeps counting visitors.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check which properties can vary on your version.&lt;/strong&gt; At the time of writing, variations only vary localisable properties. Treat that as release-specific and re-check it on the version you ship.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Fetching a variation from Graph&lt;/h3&gt;
&lt;p&gt;By default, Graph queries only return original content, so existing queries are unaffected. To ask for a variation, add the &lt;span style="font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;"&gt;variation&lt;/span&gt; argument and keep the original in the response as the fallback (source: &lt;a style="color: #008a3e;" href="https://docs.developers.optimizely.com/platform-optimizely/docs/content-variation"&gt;Query content variations in Optimizely Graph&lt;/a&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-javascript"&gt;query LandingPage($variation: [String!]) {
  _Content(
    where: { _metadata: { url: { default: { eq: "/winter-offers/" } } } }
    variation: { include: SOME, value: $variation, includeOriginal: true }
  ) {
    items {
      _metadata { key displayName variation }
      ... on LandingPage { Heading MainBody { html } }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;const requested = items.find((i) =&amp;gt; i._metadata?.variation === variationKey);
const original = items.find((i) =&amp;gt; !i._metadata?.variation);
const page = requested ?? original;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img style="max-width: 100%; height: auto;" src="/link/cd16daca56c64127b66a4fed7da7ae21.aspx" alt="Optimizely Graph query requesting the WinterCampaign variation, and the response returning the variation alongside the original" /&gt;&lt;/p&gt;
&lt;p&gt;That fallback is what makes the whole approach safe. If the engine is switched off, returns nothing, or asks for a variation that does not exist, the visitor gets the original page.&lt;/p&gt;
&lt;h2&gt;Route 1: Feature Experimentation (server-side, the native route)&lt;/h2&gt;
&lt;p&gt;This is the route Optimizely documents today, and the one I would reach for first on a headless build (source: &lt;a style="color: #008a3e;" href="https://docs.developers.optimizely.com/feature-experimentation/docs/configure-optimizely-cms-saas"&gt;Configure the Optimizely CMS (SaaS) integration&lt;/a&gt;; Piotr Nowak's &lt;a style="color: #008a3e;" href="/link/06d8f538fc754cb9b4362f2b31071f31.aspx"&gt;Unlock Experimentation with Content Variations in CMS 13&lt;/a&gt; walks through the same pattern for CMS 13 with both an MVC and a Next.js head).&lt;/p&gt;
&lt;p&gt;In Feature Experimentation:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a flag, for example &lt;span style="font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;"&gt;winter_landing_page&lt;/span&gt;.&lt;/li&gt;
&lt;li&gt;Add variations whose keys match the CMS variation names exactly (&lt;span style="font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;"&gt;WinterCampaign&lt;/span&gt;, &lt;span style="font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;"&gt;SummerCampaign&lt;/span&gt;). The documented SaaS integration also adds a string variable carrying the name, which is a good idea because it lets you change the mapping without renaming the variation.&lt;/li&gt;
&lt;li&gt;Add a rule.
&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;A/B test&lt;/strong&gt; rule splits traffic across the variations and measures them against a metric.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;targeted delivery&lt;/strong&gt; rule with an &lt;strong&gt;audience&lt;/strong&gt; serves one variation to everyone who matches, for example visitors in the UK, logged-in members, or a segment you pass in as an attribute. This is where personalisation happens.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In the front end, decide on the server and fetch the matching variation:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;import { createInstance, OptimizelyDecideOption } from '@optimizely/optimizely-sdk';

const optimizely = createInstance({
  sdkKey: process.env.OPTIMIZELY_FX_SDK_KEY!,
  datafileOptions: { autoUpdate: true, updateInterval: 60_000 },
});

export async function getVariationKey(visitorId: string, attributes: Record&amp;lt;string, unknown&amp;gt;) {
  await optimizely.onReady();
  const user = optimizely.createUserContext(visitorId, attributes);
  const decision = user?.decide('winter_landing_page', [OptimizelyDecideOption.INCLUDE_REASONS]);
  return decision?.enabled ? decision.variationKey : null;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few things I would build in from the start:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A stable visitor id.&lt;/strong&gt; Bucketing hashes on the user id, so anonymous visitors need a first-party cookie set in middleware, or they will flip between variations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the datafile fresh.&lt;/strong&gt; Without &lt;span style="font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;"&gt;autoUpdate&lt;/span&gt;, the JavaScript SDK fetches the datafile once, so rule changes in the dashboard do not reach a long-running server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caching.&lt;/strong&gt; A page that varies by visitor cannot be served from one shared cache entry. Either mark decided responses as uncacheable, or cache per variation (the variation name makes a sensible cache key when the number of variations is small).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Attributes only come from you.&lt;/strong&gt; Feature Experimentation audiences evaluate the attributes your code passes in. If you want to target on a CRM segment or a logged-in state, the front end has to supply it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because the decision happens on the server, there is no flicker and the personalised content is in the first HTML response.&lt;/p&gt;
&lt;h2&gt;Route 2: Web Experimentation and Personalization (client-side)&lt;/h2&gt;
&lt;p&gt;The other half of Optimizely's engine is Web Experimentation and its sibling, Optimizely Personalization. Personalization adds &lt;em&gt;campaigns&lt;/em&gt; made of &lt;em&gt;experiences&lt;/em&gt;, each targeted at an audience, and can use real-time segments from Optimizely Data Platform where you have it (source: &lt;a style="color: #008a3e;" href="https://support.optimizely.com/hc/en-us/articles/27294865902733-Optimizely-Personalization-overview"&gt;Optimizely Personalization overview&lt;/a&gt;). Marketers own these audiences and campaigns in the Optimizely UI, which is often exactly what a marketing team wants.&lt;/p&gt;
&lt;p&gt;The trap here is the visual editor. It makes it easy to change a heading or swap an image directly in the browser, and when you do that the personalised content lives in Web Experimentation, not in the CMS. It is unversioned by the CMS, invisible to editors, and it breaks the moment the page markup changes.&lt;/p&gt;
&lt;p&gt;The better pattern keeps the content in the CMS and uses the campaign only to choose the variation:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Name each Web Experimentation variation or Personalization experience after the CMS variation it should show.&lt;/li&gt;
&lt;li&gt;In the front end, read the visitor's active decisions from the snippet's state API and fetch the matching CMS variation from Graph.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;function getCmsVariationName(): string | null {
  const state = (window as any).optimizely?.get?.('state');
  if (!state) return null;
  const active = state.getCampaignStates({ isActive: true });
  for (const campaign of Object.values&amp;lt;any&amp;gt;(active)) {
    const name = campaign.variation?.name;
    if (name &amp;amp;&amp;amp; /^[A-Za-z][A-Za-z0-9]*$/.test(name)) return name;
  }
  return null;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span style="font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;"&gt;getCampaignStates&lt;/span&gt; returns both experiments and personalisation campaigns (source: &lt;a style="color: #008a3e;" href="https://docs.developers.optimizely.com/web-experimentation/reference/state"&gt;Get state&lt;/a&gt;). Check the returned shape against your own snippet version before relying on it.&lt;/p&gt;
&lt;p&gt;This is a pattern rather than an out-of-the-box integration. Optimizely documents the Feature Experimentation route; I have not found a documented Web Experimentation equivalent, so treat this as something you build and own. The trade-offs are the usual client-side ones: the decision happens after the page loads, so either the personalised region is fetched late (reserve its space to avoid layout shift) or you hide it until the decision arrives. I would keep personalised regions out of the largest above-the-fold element for that reason.&lt;/p&gt;
&lt;h2&gt;Which route should you use?&lt;/h2&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 16px 0 24px 0; border: 1px solid #d0d7de; font-size: 15px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="background-color: #0b1f3a; color: #ffffff; font-weight: bold; text-align: left; vertical-align: top; padding: 10px 12px; border: 1px solid #0b1f3a;"&gt;&amp;nbsp;&lt;/th&gt;
&lt;th style="background-color: #0b1f3a; color: #ffffff; font-weight: bold; text-align: left; vertical-align: top; padding: 10px 12px; border: 1px solid #0b1f3a;"&gt;Feature Experimentation&lt;/th&gt;
&lt;th style="background-color: #0b1f3a; color: #ffffff; font-weight: bold; text-align: left; vertical-align: top; padding: 10px 12px; border: 1px solid #0b1f3a;"&gt;Web Experimentation / Personalization&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; font-weight: bold; width: 28%;"&gt;Where the decision happens&lt;/td&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; width: 36%;"&gt;Your server or edge&lt;/td&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; width: 36%;"&gt;The visitor's browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #f6f8fa; font-weight: bold; width: 28%;"&gt;Flicker and layout shift&lt;/td&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #f6f8fa; width: 36%;"&gt;None&lt;/td&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #f6f8fa; width: 36%;"&gt;Needs designing for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; font-weight: bold; width: 28%;"&gt;Who owns audiences&lt;/td&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; width: 36%;"&gt;Developers pass attributes; audiences built in FX&lt;/td&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; width: 36%;"&gt;Marketers, in the Optimizely UI, including ODP real-time segments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #f6f8fa; font-weight: bold; width: 28%;"&gt;Content lives in&lt;/td&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #f6f8fa; width: 36%;"&gt;The CMS&lt;/td&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #f6f8fa; width: 36%;"&gt;The CMS, if you use the pattern above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; font-weight: bold; width: 28%;"&gt;Documented by Optimizely for CMS variations&lt;/td&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; width: 36%;"&gt;Yes&lt;/td&gt;
&lt;td style="padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; width: 36%;"&gt;Not yet; build it as a pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;My default on a headless or hybrid CMS 13 build is Feature Experimentation for anything above the fold or on key journeys, with Personalization campaigns where marketers need to own and change audiences without a developer. Both can sit on the same content variations, because the only thing either needs from the CMS is the name.&lt;/p&gt;
&lt;h2&gt;What about headed sites?&lt;/h2&gt;
&lt;p&gt;Nothing changes. Audiences still work on in-process CMS 13 sites and remain the simplest option for rules-based personalisation. Content variations work there too, so a headed site can use the same Feature Experimentation pattern with the .NET SDK and &lt;span style="font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;"&gt;IContentLoader&lt;/span&gt; when you want proper testing rather than show-and-hide rules.&lt;/p&gt;
&lt;p&gt;Optimizely has said deeper integration between the CMS and Experimentation is on the roadmap, without a date. I will update this post if that changes the picture.&lt;/p&gt;</summary></entry><entry><title>Shareable stakeholder previews for Optimizely SaaS CMS</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/9/shareable-stakeholder-previews-for-optimizely-saas-cms/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/9/shareable-stakeholder-previews-for-optimizely-saas-cms/</id><published>2026-09-14T13:52:54.0000000Z</published><updated>2026-09-14T13:52:54.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;In August,&amp;nbsp;&lt;span style="color: rgb(45, 194, 107);"&gt;&lt;a class="text-accent hover:underline underline-offset-[1px] focus-visible:outline-hidden hide-focus-ring focus-visible:shadow-focus rounded-[3px]" style="color: rgb(45, 194, 107);" href="https://blog.nikkipunjabi.com/2026/08/shareable-stakeholder-previews-optimizely-saas-cms.html"&gt;Nikki Punjabi wrote about a gap in Optimizely SaaS CMS&lt;/a&gt;&lt;/span&gt;: there is no out-of-the-box way to share a draft page with someone outside the CMS. The editor preview is session-bound. If a legal reviewer or brand owner without a CMS account needs to approve a page, you have to build that yourself.&lt;/p&gt;
&lt;p&gt;The post set out a design. The link should be a signed "permission slip" rather than a credential: it grants access to one draft page until an expiry date. Links should be internal-only by default, with external sharing an explicit choice. No database should be needed, CMS keys should stay on the server, and drafts should be hidden from search engines.&lt;/p&gt;
&lt;p&gt;We built that design as a CMS UI Extension on the Optimizely Connect Platform (OCP). This post covers how.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/53c16214073e477fb71d655c2d73abac.aspx" alt="The Share preview panel in the CMS editor sidebar" /&gt;&lt;/p&gt;
&lt;h2&gt;Why a CMS UI Extension&lt;/h2&gt;
&lt;p&gt;Authors should create the link next to the page they are editing, not in a separate admin tool. That is what CMS UI Extensions do: an OCP app can register a sidebar panel inside the editing view. The panel is told which content item is open (key, version, locale) and updates as the editor moves between pages.&lt;/p&gt;
&lt;p&gt;The same app also carries its backend functions, a settings screen and storage, so the whole feature ships as one unit. The manifest declares everything:&lt;/p&gt;
&lt;pre class="language-yaml"&gt;&lt;code&gt;functions:
  cms_extension:
    entry_point: CmsUiExtension
    accepts: cms_ui_extension     # callable only from the panel
  preview:
    entry_point: Preview
    accepts: http                 # public, reviewer-facing endpoint

ui_extensions:
  sidebar:
    - name: share-preview
      entry_point: SharePreview
      display_name: Share preview&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The panel is a React component registered through &lt;em&gt;@optimizely/cms-extensibility-sdk&lt;/em&gt;:&lt;/p&gt;
&lt;pre class="language-jsx"&gt;&lt;code&gt;register((context) =&amp;gt; (
  &amp;lt;AxiomProvider&amp;gt;
    &amp;lt;SharePreviewPanel context={context} /&amp;gt;
  &amp;lt;/AxiomProvider&amp;gt;
));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;context.content.subscribe()&lt;/em&gt; gives the panel the open page; &lt;em&gt;context.extension.invokeFunction()&lt;/em&gt; calls the app's backend. We built the UI with Optiaxiom, Optimizely's component library, so the panel matches the rest of the CMS.&lt;/p&gt;
&lt;p&gt;That is the full setup: a manifest, a React entry point, a backend class, and &lt;em&gt;ocp app prepare --publish&lt;/em&gt; to deploy to all regions.&lt;/p&gt;
&lt;h2&gt;The link&lt;/h2&gt;
&lt;p&gt;The link contains an HMAC-SHA256 signed token over six fields:&lt;/p&gt;
&lt;pre class="language-javascript"&gt;&lt;code&gt;{ contentKey, version, locale, scope, iat, exp }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are no credentials in it and no session behind it. When a reviewer opens the link, the server recomputes the signature, checks the expiry against its own clock and enforces the scope. Editing the URL to extend the expiry or change the scope breaks the signature.&lt;/p&gt;
&lt;p&gt;Internal links only open from configured office or VPN IP ranges. If no ranges are configured, they refuse rather than falling back to shareable behaviour. Shareable is a per-link choice and the panel warns you what it means.&lt;/p&gt;
&lt;p&gt;The other guardrails from Nikki's design are all present: &lt;em&gt;noindex&lt;/em&gt; via meta tag and &lt;em&gt;X-Robots-Tag&lt;/em&gt;, &lt;em&gt;no-store&lt;/em&gt; caching, read-only rendering, a clear page for expired links, and a hard refusal when the app is unconfigured. If the page has been published since the link was created, the link redirects to the live page.&lt;/p&gt;
&lt;h2&gt;Rendering the real page&lt;/h2&gt;
&lt;p&gt;A metadata summary of the draft is not enough; stakeholders want the page as designed, and in a headless setup only the front end can render it.&lt;/p&gt;
&lt;p&gt;Our first attempt was to mint Optimizely Graph tokens from the app, both self-signed with the Graph secret and via an OAuth exchange. Graph rejected both. The documentation is clear once you read it properly: bearer tokens must come from an OIDC authorisation server.&lt;/p&gt;
&lt;p&gt;The working approach came from &lt;a href="https://github.com/episerver/cms-saas-vercel-demo"&gt;Optimizely's own SaaS demo site&lt;/a&gt;, which includes a route at &lt;em&gt;/.well-known/optimizely/preview-psk&lt;/em&gt; for exactly this purpose. You send &lt;em&gt;key&lt;/em&gt;, &lt;em&gt;ver&lt;/em&gt; and &lt;em&gt;loc&lt;/em&gt; plus an HMAC signature over those parameters, keyed with a pre-shared token the head already holds. The head validates the signature, fetches the draft with its own server-side Graph credentials, and renders it with its real components. No CMS or Graph credential ever reaches the browser.&lt;/p&gt;
&lt;p&gt;That stock route has one problem for this use case: the signature never expires, so the URL a reviewer lands on works forever. We added a sibling route that includes an &lt;em&gt;exp&lt;/em&gt; timestamp in the signed data and refuses once it has passed. Our app issues those redirects freshly signed on each visit, valid for ten minutes. The link the author shares remains the single entry point where expiry, scope and revocation are checked.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/5db6996acdcf47beb0cd94d8b5e108f2.aspx" alt="The reviewer view: the real page rendered by the front end, reached through a shareable link" /&gt;&lt;/p&gt;
&lt;h2&gt;The key-value store&lt;/h2&gt;
&lt;p&gt;The first iteration stored nothing, as the original design suggested. Two requirements changed that: authors wanted to see a page's existing links after a refresh, and to revoke one.&lt;/p&gt;
&lt;p&gt;OCP apps have a key-value store with atomic read-modify-write and per-row TTL. We keep one row per content item. Each write prunes expired entries and resets the row's TTL to the furthest expiry, so the platform deletes the row once the last link dies. The panel filters by expiry on read, so expired links drop out of the list immediately.&lt;/p&gt;
&lt;p&gt;Two details are worth copying:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The KV store documentation says not to store tokens, and our URLs contain one. HMAC tokens are deterministic, so the registry stores only metadata (id, version, locale, scope, issued and expiry times) and the app re-signs the URL when listing. No sensitive value is ever stored.&lt;/li&gt;
&lt;li&gt;Revoking moves a link's entry to a deny-list in the same row. The public preview function checks the incoming token's payload against that list. The token format is unchanged, so earlier links are revocable too. A revoke that hits a storage error fails loudly; a viewer request that cannot read the registry fails closed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src="/link/96984f6cc7af432b8c36b55dd0db869c.aspx" alt="Share preview panel showing the Active links table with version, scope, expiry, copy and revoke" /&gt;&lt;/p&gt;
&lt;h2&gt;Configuration&lt;/h2&gt;
&lt;p&gt;Everything above is driven by install-time settings, and OCP generates the settings screen for you. The fields are declared in a YAML form definition in the app (&lt;em&gt;forms/settings.yml&lt;/em&gt;); a lifecycle hook validates and saves them into the app's settings store, which is the right home for credentials, unlike the key-value store. The app never needs its own admin UI.&lt;/p&gt;
&lt;p&gt;The settings map directly to the decisions Nikki's post said to make before building:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Signing secret&lt;/strong&gt;: leave it blank and the app generates one on save. Rotating it voids every link.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Optimizely Graph credentials&lt;/strong&gt;: used by the server to fetch drafts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Front-end preview route and pre-shared key&lt;/strong&gt;: for rendering the real page.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Live site base URL&lt;/strong&gt;: published pages redirect here.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Internal network ranges&lt;/strong&gt;: defines what "internal" means.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Default link expiry&lt;/strong&gt;: used when the author doesn't pick one.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src="/link/7ca2ff4301ef4e3ca68e2ccd181e4f18.aspx" /&gt;&lt;/p&gt;
&lt;h2&gt;What it offers&lt;/h2&gt;
&lt;p&gt;For authors: a panel next to the page with an audience choice (Internal by default, Shareable deliberately), an expiry choice (1, 3 or 7 days), one click to create, copy and send. The table of active links survives refreshes, shows which draft version each link was pinned to, and revokes per row with a confirm step.&lt;/p&gt;
&lt;p&gt;For reviewers: a link that opens without an account or login, showing the real page rendered by the front end. Expired and revoked links get clear explanations.&lt;/p&gt;
&lt;p&gt;For the organisation: link creation and revocation are logged with content key, version, scope and expiry. Rotating the signing secret voids every link at once. CMS and Graph credentials never leave the server.&lt;/p&gt;
&lt;h2&gt;Closing&lt;/h2&gt;
&lt;p&gt;External preview links are a small feature that touches security, caching, indexing and publishing policy, and a SaaS CMS UI Extension handled all of it in one deployable app. If your editors need something the CMS does not ship, this is a good route to it.&lt;/p&gt;
&lt;p&gt;Thanks to Nikki Punjabi for the original design.&lt;/p&gt;</summary></entry><entry><title>Personalisation in CMS 13 Using Audiences</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/2/personalisation-in-cms-13-using-audiences/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/2/personalisation-in-cms-13-using-audiences/</id><published>2026-02-12T09:00:01.0000000Z</published><updated>2026-02-12T09:00:01.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;div&gt;
&lt;p&gt;One of the common questions around CMS 13 is whether it still supports personalisation without requiring additional products. The short answer is yes.&lt;/p&gt;
&lt;p&gt;CMS 13 includes built in personalisation using what were previously known as Visitor Groups. In CMS 13 these have been renamed to Audiences (Correction they changed to Audiences in latter versions of CMS12 which somehow I missed, Thanks Scott for pointing this out), but the core concept remains the same. You can define audiences based on visitor behaviour and use them to tailor content across your site.&lt;/p&gt;
&lt;p&gt;Audiences are managed directly within the CMS interface. For example, you might create an audience made up of visitors who have viewed pages with a specific category more than a set number of times. Once an audience is defined, it can be used to control how content is displayed.&lt;/p&gt;
&lt;p&gt;Personalisation is applied at the content level using standard content areas. Editors can choose to show or hide individual components such as a hero or jumbotron based on the selected audience. This allows targeted experiences without introducing additional complexity for editors.&lt;/p&gt;
&lt;p&gt;CMS 13 also supports previewing content as a specific audience. This makes it easy to validate personalisation rules and understand exactly what different users will see before changes are published.&lt;/p&gt;
&lt;p&gt;This approach provides traditional, rules based personalisation out of the box and offers a clear path to more advanced personalisation in the future if needed. For many organisations, it delivers exactly the right level of capability from day one.&lt;/p&gt;
&lt;p&gt;A short video walkthrough is included below to demonstrate this in action.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://youtu.be/u02AxSzp9i0?si=DslEH-KoDO-eoVQH"&gt;&lt;img src="/link/310aa2aee3a94dfbac79be07a9f90fc8.aspx" alt="" width="539" height="307" /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;</summary></entry><entry><title>Experimentation at Speed Using Optimizely Opal and Web Experimentation</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/1/experimentation-at-speed-using-optimizely-opal-and-web-experimentation/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/1/experimentation-at-speed-using-optimizely-opal-and-web-experimentation/</id><published>2026-01-30T10:24:02.0000000Z</published><updated>2026-01-30T10:24:02.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;If you are working in experimentation, you will know that speed matters. The quicker you can go from idea to implementation, the faster you can learn, optimise, and deliver impact.&lt;/p&gt;
&lt;p&gt;In this short video, I demonstrate just how quick and easy it is to use &lt;strong&gt;Optimizely Opal&lt;/strong&gt; alongside &lt;strong&gt;Optimizely Web Experimentation&lt;/strong&gt; to create and execute an experiment at pace. What normally takes multiple manual steps can be dramatically accelerated, with Opal helping to streamline the entire workflow.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://youtu.be/HqsSn6E6o4E?si=QbAd9f9VElVrZhU9"&gt;&lt;img src="/link/a910d5f0849c4bfc88133921c6137372.aspx" alt="" width="500" height="333" /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;The scenario: make a quick change and run an experiment&lt;/h3&gt;
&lt;p&gt;The goal of the demo is simple. I want to make a small change to a website and show how fast I can turn that into a working experiment using Opal.&lt;/p&gt;
&lt;p&gt;Rather than spending time manually setting everything up, Opal can assist with the process and help generate what you need, quickly and efficiently.&lt;/p&gt;
&lt;h3&gt;Why this matters: speed and experimentation velocity&lt;/h3&gt;
&lt;p&gt;The real value here is the speed.&lt;/p&gt;
&lt;p&gt;Using Optimizely Opal with Web Experimentation means you can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;reduce time spent on setup&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;increase experimentation throughput&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;test more ideas more frequently&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;accelerate learning cycles across your digital estate&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It is genuinely exciting to see experimentation becoming this efficient, and it opens up huge potential for teams who want to move faster without compromising quality.&lt;/p&gt;
&lt;h3&gt;Final thoughts&lt;/h3&gt;
&lt;p&gt;Using &lt;strong&gt;Optimizely Opal and Web Experimentation&lt;/strong&gt; together is an absolute dream. The speed in which you can execute experiments, and have Opal support so much of the process for you, is truly impressive.&lt;/p&gt;
&lt;p&gt;If you are exploring ways to increase experimentation velocity, this is well worth looking at.&lt;/p&gt;
&lt;p&gt;Thank you for watching.&lt;/p&gt;</summary></entry><entry><title>Building an Agent Replicator for Optimizely Opal</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/1/opal-tool-agent-replicator/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/1/opal-tool-agent-replicator/</id><published>2026-01-21T11:46:47.0000000Z</published><updated>2026-01-21T11:46:47.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;A couple of months ago, my team and I took part in the Opal Innovation Challenge (Which we proudly won). We decided to create an agentic AI experience that brings audience insight directly into content workflows, the&amp;nbsp;&lt;strong&gt;Virtual Focus Group&lt;/strong&gt;. By simulating real customer segments and offering instant, nuanced feedback on messaging, this solution helps teams validate messaging in minutes rather than weeks, reduces costly research cycles, and captures behavioural insights that traditional analytics often miss.&lt;/p&gt;
&lt;p&gt;To learn more about exactly what we delivered, please find further information here: &lt;a href="https://www.netcel.com/insights/virtual-focus-group/"&gt;Virtual Focus Group by Netcel&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;However, whilst building the Virtual Focus Group, we quickly discovered that creating and managing multiple specialised agents through the Opal user interface became time consuming and repetitive. Each customer segment needed its own agent with specific configurations, and manually creating dozens of agents was not scalable. This led to the development of the &lt;strong&gt;Agent Replicator&lt;/strong&gt;, a comprehensive toolkit that enables programmatic agent creation, management, and execution following the official Opal Agent JSON Schema specification.&lt;/p&gt;
&lt;p&gt;Whilst the Opal API does not yet support programmatic agent creation, this implementation is production ready and can be immediately integrated once Optimizely opens those endpoints. The system includes full CRUD operations, parameter validation, prompt template processing, and request context propagation, everything needed to dynamically create and orchestrate AI agents at scale.&lt;/p&gt;
&lt;h2&gt;What the Agent Replicator is in practice&lt;/h2&gt;
&lt;p&gt;In practice, the Agent Replicator is an Opal tool that enables users to create new Virtual Focus Group persona agents quickly and consistently, without leaving Opal.&lt;/p&gt;
&lt;p&gt;Once an Opal administrator has registered the Agent Replicator tool endpoint, the capability becomes available in Opal Chat and Opal Workflows. A user can provide a persona scenario and empathy map, and the tool generates a new specialised persona agent, including a tailored prompt template and the parameters needed to run it. That persona agent can then be used by the Virtual Focus Group to review content from a specific audience perspective.&lt;/p&gt;
&lt;p&gt;This shifts persona creation from a manual, repetitive task into a fast, repeatable workflow that teams can run whenever new customer segments are needed. It also keeps persona definitions consistent by generating them in a schema aligned format, which makes them easier to manage and evolve over time.&lt;/p&gt;
&lt;h2&gt;How it works&lt;/h2&gt;
&lt;p&gt;At a high level, the implementation follows a simple flow.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;You deploy the Agent Replicator as a small web service.&lt;/li&gt;
&lt;li&gt;The service exposes Opal compatible tool endpoints.&lt;/li&gt;
&lt;li&gt;An Opal administrator registers the endpoint inside Opal, which makes the tools available in Chat and Workflows.&lt;/li&gt;
&lt;li&gt;Users call the tool to generate persona agents, and then use those persona agents as part of the Virtual Focus Group experience.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;The core implementation&lt;/h2&gt;
&lt;p&gt;The Agent Replicator is built around three pieces:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Schema aligned agent models that match the Opal Agent JSON Schema&lt;/li&gt;
&lt;li&gt;Opal tool methods that create and retrieve agent definitions&lt;/li&gt;
&lt;li&gt;A small host application that exposes tool endpoints for Opal to register&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The sections below show the key parts of the implementation.&lt;/p&gt;
&lt;h2&gt;1. Adding the Opal tools package&lt;/h2&gt;
&lt;p&gt;This project references the &lt;span style="color: rgb(224, 62, 45);"&gt;Optimizely.Opal.Tools&lt;/span&gt; package, which provides the infrastructure for exposing tools in a way that Opal can discover and invoke.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;ItemGroup&amp;gt;
  &amp;lt;PackageReference Include="Optimizely.Opal.Tools" Version="0.4.0" /&amp;gt;
&amp;lt;/ItemGroup&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. Hosting the tools and exposing endpoints for Opal&lt;/h2&gt;
&lt;p&gt;This is the important wiring in &lt;span style="color: rgb(224, 62, 45);"&gt;Program.cs&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;It registers the Opal tool service, registers your tool class, and maps the tool endpoints. Once deployed, the mapped routes are what an Opal administrator registers inside Opal.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Register Opal Tool Service and Tools
builder.Services.AddOpalToolService();

// Register Agent Management Tools
builder.Services.AddOpalTool&amp;lt;AgentManagementTools&amp;gt;();

// ...

app.MapOpalTools();&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. Creating the tool that generates a new persona agent&lt;/h2&gt;
&lt;p&gt;The Agent Replicator tool methods live in &lt;span style="color: rgb(224, 62, 45);"&gt;AgentManagementTools.cs&lt;/span&gt;. Each method that Opal can call is decorated with &lt;span style="color: rgb(224, 62, 45);"&gt;[OpalTool]&lt;/span&gt; and has a description that Opal can surface in the interface.&lt;/p&gt;
&lt;p&gt;The method below creates a new agent definition that matches the Opal Agent JSON Schema fields, stores it for the proof of concept, and returns the created agent back to Opal.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[OpalTool(Name = "create-opal-agent")]
[Description("Creates a new Opal agent within the virtual focus group. This agent can be assigned specific roles, tools, and a prompt template that defines its behavior.")]
public Task&amp;lt;object&amp;gt; CreateOpalAgent(CreateAgentParameters parameters)
{
    var agentId = Guid.NewGuid().ToString();

    var agent = new AgentDefinition
    {
        // Internal tracking fields
        Id = agentId,
        DisplayName = parameters.DisplayName,
        Role = parameters.Role,
        CreatedAt = DateTime.UtcNow,
        UpdatedAt = DateTime.UtcNow,

        // Opal schema fields
        SchemaVersion = "1.0",
        AgentId = parameters.AgentId,
        AgentType = parameters.AgentType,
        Name = parameters.DisplayName,
        Description = parameters.Description,
        Version = parameters.Version,
        PromptTemplate = parameters.PromptTemplate,
        Parameters = parameters.Parameters ?? new List&amp;lt;AgentParameterDefinition&amp;gt;(),
        EnabledTools = parameters.EnabledTools ?? new List&amp;lt;string&amp;gt;(),
        Creativity = parameters.Creativity,
        InferenceType = parameters.InferenceType,
        Output = parameters.Output ?? new AgentOutputDefinition(),
        FileUrls = parameters.FileUrls ?? new List&amp;lt;string&amp;gt;(),
        AgentMetadata = parameters.AgentMetadata,
        IsActive = true,
        IsDeleted = false,
        InternalVersion = 1
    };

    if (!_agents.TryAdd(agentId, agent))
    {
        return Task.FromResult&amp;lt;object&amp;gt;(new
        {
            IsSuccess = false,
            Message = "Failed to create agent due to ID collision. Please try again."
        });
    }

    return Task.FromResult&amp;lt;object&amp;gt;(new
    {
        IsSuccess = true,
        Message = $"Successfully created agent '{parameters.DisplayName}' with agent_id '{parameters.AgentId}'.",
        Agent = agent
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;img src="/link/c611cb43828f48a8a3582e49c163bacf.aspx" /&gt;&lt;/h2&gt;
&lt;h2&gt;4. Listing persona agents inside Opal&lt;/h2&gt;
&lt;p&gt;Once users start generating persona agents, it is useful to list what exists. This tool method returns all agents that have not been deleted, ordered by creation date. This aligns with the experience of listing agents from within Opal.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[OpalTool(Name = "list-opal-agents")]
[Description("Lists all available Opal agents in the virtual focus group (excluding deleted agents).")]
public Task&amp;lt;object&amp;gt; ListOpalAgents(ListAgentParameters parameters)
{
    var agents = _agents.Values
        .Where(a =&amp;gt; !a.IsDeleted)
        .OrderByDescending(a =&amp;gt; a.CreatedAt)
        .ToList();

    return Task.FromResult&amp;lt;object&amp;gt;(new
    {
        IsSuccess = true,
        Message = $"Found {agents.Count} agent(s).",
        TotalAgents = agents.Count,
        Agents = agents
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;img src="/link/f290478d8eb04afbb9f3544eeca396c8.aspx" /&gt;&lt;/h2&gt;
&lt;h2&gt;5. Retrieving an agent and its prompt template&lt;/h2&gt;
&lt;p&gt;To support review and reuse, the next tool fetches a specific agent. This is the foundation for showing an agent&amp;rsquo;s prompt template inside Opal, which helps teams understand exactly how the persona will evaluate content.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[OpalTool(Name = "get-opal-agent")]
[Description("Retrieves details of a specific Opal agent by ID or agent_id.")]
public Task&amp;lt;object&amp;gt; GetOpalAgent(GetAgentParameters parameters)
{
    var agent = _agents.Values.FirstOrDefault(a =&amp;gt;
        a.Id == parameters.AgentId ||
        a.AgentId.Equals(parameters.AgentId, StringComparison.OrdinalIgnoreCase));

    if (agent == null)
    {
        return Task.FromResult&amp;lt;object&amp;gt;(new
        {
            IsSuccess = false,
            Message = $"Agent '{parameters.AgentId}' not found."
        });
    }

    return Task.FromResult&amp;lt;object&amp;gt;(new
    {
        IsSuccess = true,
        Message = $"Successfully retrieved agent '{agent.DisplayName}'.",
        Agent = agent
    });
}&lt;/code&gt;&lt;br /&gt;&lt;br /&gt;&lt;img src="/link/918adb73ec3f446a9ff9d222af7dfe9d.aspx" /&gt;&lt;/pre&gt;
&lt;h2&gt;6. Defining tool parameters so Opal can guide users&lt;/h2&gt;
&lt;p&gt;Tool parameter models use &lt;span style="color: rgb(224, 62, 45);"&gt;[Required]&lt;/span&gt; and &lt;span style="color: rgb(224, 62, 45);"&gt;[Description]&lt;/span&gt; attributes. This makes the tool self describing, helps validation, and helps Opal present a clear input contract when the tool is used in Chat or Workflows.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public class CreateAgentParameters
{
    [Required]
    [Description("Human-friendly name of the new agent.")]
    public string DisplayName { get; set; } = string.Empty;

    [Required]
    [Description("What this agent does and when to use it.")]
    public string Description { get; set; } = string.Empty;

    [Required]
    [Description("Unique identifier for the agent (e.g., 'brand_lp_research'). Use lowercase with underscores.")]
    public string AgentId { get; set; } = string.Empty;

    [Description("Type of agent: 'specialized', 'workflow', or 'autonomous'. Default is 'specialized'.")]
    public string AgentType { get; set; } = "specialized";

    [Required]
    [Description("Prompt template with agent's instructions. Use [[parameter_name]] syntax for parameters.")]
    public string PromptTemplate { get; set; } = string.Empty;

    [Description("Agent parameters that can be passed at execution time.")]
    public List&amp;lt;AgentParameterDefinition&amp;gt;? Parameters { get; set; }

    [Description("List of tool names the agent can call (e.g., 'browse_web', 'search_web').")]
    public List&amp;lt;string&amp;gt;? EnabledTools { get; set; }

    [Description("Creativity level from 0.0 (deterministic) to 1.0 (creative). Default is 0.5.")]
    public double Creativity { get; set; } = 0.5;

    [Description("Inference type: 'simple' or 'complex'. Default is 'complex'.")]
    public string InferenceType { get; set; } = "complex";

    [Description("Agent version (e.g., '1.0.0'). Default is '1.0.0'.")]
    public string Version { get; set; } = "1.0.0";
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Registering the Agent Replicator in Opal&lt;/h2&gt;
&lt;p&gt;&lt;img src="/link/15468a15399f429f92220ab7f2d319f9.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;Once the service is deployed, an Opal administrator registers the tool provider inside Opal using the URL exposed by &lt;span style="color: rgb(224, 62, 45);"&gt;MapOpalTools()&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;After registration, the tools become available within Opal:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In Opal Chat, a user can invoke the tool to generate a new persona agent.&lt;/li&gt;
&lt;li&gt;In Opal Workflows, a step can call the same tool to create personas dynamically as part of a workflow.&lt;/li&gt;
&lt;li&gt;The newly created persona agents can then be used by the Virtual Focus Group experience to evaluate content from a specific audience perspective.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The Agent Replicator makes it practical to scale Virtual Focus Group personas directly within Opal.&lt;/p&gt;
&lt;p&gt;By deploying a small tool service and registering it in Opal, teams can generate persona agents quickly and consistently, inspect the prompt templates those agents use, and reuse the agents across Workflows and Chat. This reduces the manual effort of building and maintaining dozens of audience segments and provides a stable foundation for future integration when Optimizely opens official agent creation endpoints.&lt;/p&gt;</summary></entry><entry><title>Cleaning Up Content Graph Webhooks in PaaS CMS: Scheduled Job</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/12/cleaning-up-content-graph-webhooks-in-paas-cms-scheduled-job/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/12/cleaning-up-content-graph-webhooks-in-paas-cms-scheduled-job/</id><published>2025-12-17T12:42:30.0000000Z</published><updated>2025-12-17T12:42:30.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;Bit of a niche issue, but we are building a headless solution where the presentation layer is hosted on Netlify, when in a regular delivery cycle and work has to be tested a deployment preview is generated with a unique url, as part of the frontend deployment a new Web Hook is registered in Optimizely Content Graph so when content is published the frontend cache can be invalidated. The problem here is that with so many deployments occuring the list of web hooks can become quite long. While we could have configured deployment previews to skip webhook registration, that would have meant cached content on those environments, preventing proper testing of real-time content updates.&lt;/p&gt;
&lt;h2&gt;The Solution&lt;/h2&gt;
&lt;p&gt;Instead of compromising our testing workflow, we built an automated cleanup solution using Optimizely's Scheduled Jobs feature. This job periodically scans all registered webhooks and removes any that don't belong to our production environments, keeping the list clean and manageable.&lt;/p&gt;
&lt;h2&gt;Implementation&lt;/h2&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;using EPiServer.PlugIn;
using EPiServer.Scheduler;
using RestSharp;
using System.Text;
using System.Text.Json;

namespace Client.Cms.Infrastructure.Jobs
{
    [ScheduledPlugIn(DisplayName = "Clear Graph Webhooks", GUID = "D4F5A6B7-C8D9-4E0F-AB12-3456789ABCDE")]
    public class ClearGraphWebhooksJob : ScheduledJobBase
    {
        private readonly IConfiguration _configuration;
        private readonly IContentLoader _contentLoader;
        private bool _stopSignaled;

        public ClearGraphWebhooksJob(IConfiguration configuration, IContentLoader contentLoader)
        {
            _configuration = configuration;
            _contentLoader = contentLoader;
            IsStoppable = true;
        }

        public override string Execute()
        {
            try
            {
                // Fetch Content Graph Gateway Address, AppKey and Secret from appSettings
                var gatewayAddress = _configuration["Optimizely:ContentGraph:GatewayAddress"];
                var appKey = _configuration["Optimizely:ContentGraph:AppKey"];
                var appSecret = _configuration["Optimizely:ContentGraph:Secret"];

                if (string.IsNullOrEmpty(gatewayAddress) || string.IsNullOrEmpty(appKey) || string.IsNullOrEmpty(appSecret))
                {
                    return "Failed: Missing configuration values for ContentGraph";
                }

                var auth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{appKey}:{appSecret}"));
                var baseUrl = $"{gatewayAddress}/api/webhooks";

                var client = new RestClient(new RestClientOptions(baseUrl));
                var request = new RestRequest();
                request.AddHeader("Accept", "application/json");
                request.AddHeader("Authorization", $"Basic {auth}");

                var response = client.GetAsync(request).GetAwaiter().GetResult();

                if (!response.IsSuccessful)
                {
                    OnStatusChanged($"Failed to fetch webhooks: {response.StatusCode} {response.ErrorMessage}");
                    return $"Failed: {response.StatusCode} - {response.ErrorMessage}";
                }

                var json = response.Content;
                var webhooks = JsonSerializer.Deserialize&amp;lt;List&amp;lt;WebhookItem&amp;gt;&amp;gt;(json, new JsonSerializerOptions
                {
                    PropertyNameCaseInsensitive = true
                });

                if (webhooks == null || webhooks.Count == 0)
                {
                    OnStatusChanged("No webhooks found or failed to deserialize response.");
                    return "Success: No webhooks to process";
                }

                int deletedCount = 0;
                int skippedCount = 0;

                foreach (var webhook in webhooks)
                {
                    if (_stopSignaled)
                    {
                        return $"Stopped: Processed {deletedCount} deletions, {skippedCount} skipped";
                    }

                    var urls = GetUrls().ToList();
                    if (urls.Any(url =&amp;gt; webhook.Request.Url.Contains(url)))
                    {
                        OnStatusChanged($"Skipping webhook {webhook.Id} with URL {webhook.Request.Url}");
                        skippedCount++;
                        continue;
                    }

                    DeleteWebhook(webhook.Id.ToString(), auth, baseUrl);
                    deletedCount++;
                }

                return $"Success: Deleted {deletedCount} webhooks, skipped {skippedCount}";
            }
            catch (Exception ex)
            {
                OnStatusChanged($"Error: {ex.Message}");
                return $"Failed: {ex.Message}";
            }
        }

        public override void Stop()
        {
            _stopSignaled = true;
            base.Stop();
        }

        private void DeleteWebhook(string id, string auth, string baseUrl)
        {
            var client = new RestClient(new RestClientOptions($"{baseUrl}/{id}"));
            var request = new RestRequest();
            request.AddHeader("Authorization", $"Basic {auth}");

            var response = client.DeleteAsync(request).GetAwaiter().GetResult();

            if (!response.IsSuccessful)
            {
                OnStatusChanged($"Failed to delete webhook {id}: {response.StatusCode} {response.ErrorMessage}");
            }
            else
            {
                OnStatusChanged($"Deleted webhook {id}");
            }
        }

        // Fetch URLs from Site Config Block
        private IEnumerable&amp;lt;string&amp;gt; GetUrls()
        {
            var globalSettingsBlock = _contentLoader
                .GetChildren&amp;lt;OptimizelySettingsBlock&amp;gt;(ContentReference.GlobalBlockFolder)
                .FirstOrDefault();

            return globalSettingsBlock?.FrontendUrls ?? Enumerable.Empty&amp;lt;string&amp;gt;;
        }
    }

    public class WebhookRequest
    {
        public string Url { get; set; }
        public string Method { get; set; }
        public Dictionary&amp;lt;string, string&amp;gt; Headers { get; set; }
    }

    public class WebhookItem
    {
        public Guid Id { get; set; }
        public WebhookRequest Request { get; set; }
        public string Preset { get; set; }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;How It Works&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Authentication: The job authenticates with Content Graph using your AppKey and Secret from configuration&lt;/li&gt;
&lt;li&gt;Fetch Webhooks: It retrieves all registered webhooks via the Content Graph API&lt;/li&gt;
&lt;li&gt;Filter by URL: The job checks each webhook URL against a configurable list of production URLs stored in a global settings block&lt;/li&gt;
&lt;li&gt;Smart Deletion: Webhooks matching production URLs are preserved; all others are deleted&lt;/li&gt;
&lt;li&gt;Progress Tracking: The job reports its progress and provides a summary of deletions and skips&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Content-Driven Configuration: Production URLs are stored in an Optimizely block, making them editable by content editors without code changes&lt;/li&gt;
&lt;li&gt;Stoppable: The job implements IsStoppable = true and properly handles stop signals for graceful cancellation&lt;/li&gt;
&lt;li&gt;Comprehensive Logging: Uses OnStatusChanged() to provide detailed progress updates visible in the Scheduled Jobs interface&lt;/li&gt;
&lt;li&gt;Error Handling: Robust try-catch blocks and validation ensure the job fails gracefully with meaningful error messages&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Benefits&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Deployment previews can register webhooks and test real-time content updates&lt;/li&gt;
&lt;li&gt;The webhook list stays clean through automated maintenance&lt;/li&gt;
&lt;li&gt;No manual intervention required&lt;/li&gt;
&lt;li&gt;Content editors control which URLs to preserve through the CMS interface&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;By leveraging Optimizely's Scheduled Jobs framework, we've created an automated solution that maintains a clean webhook registry while preserving full functionality across all environments. This approach is far more maintainable than manually managing webhooks or compromising testing capabilities on deployment previews.&lt;/p&gt;
&lt;p&gt;The scheduled job can run daily, weekly, or at any interval that suits your deployment frequency, ensuring your Content Graph webhook list remains lean and manageable.&lt;/p&gt;</summary></entry><entry><title>Building a Lightweight Optimizely SaaS CMS Solution with 11ty</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/12/building-a-lightweight-optimizely-saas-cms-solution-with-11ty/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/12/building-a-lightweight-optimizely-saas-cms-solution-with-11ty/</id><published>2025-12-03T10:59:17.0000000Z</published><updated>2025-12-03T10:59:17.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;figure&gt;&lt;img src="/link/68c9d668701f42a49f0f5d684cf2374b.aspx" alt="" width="1919" height="707" /&gt;&lt;/figure&gt;
&lt;p&gt;Modern web development often requires striking a difficult balance between site performance and the flexibility needed by content editors. To address this, myself and fellow Optimizely OMVP &lt;strong&gt;Graham Carr&lt;/strong&gt; embarked on a Proof of Concept (POC) to build a solution that prioritises speed and simplicity, while fully leveraging the powerful capabilities of the Optimizely SaaS CMS.&lt;/p&gt;
&lt;h2&gt;The Challenge: Performance vs. Preview&lt;/h2&gt;
&lt;p&gt;Our Frontend Architect / Lead, &lt;strong&gt;Allyn Thomas&lt;/strong&gt;, set a clear challenge: find a solution with minimal bloat that is statically generated but still fully compatible with the CMS's live preview capabilities. While frameworks like Next.js are popular, they often come with a significant amount of client-side JavaScript (hydration) that isn't always necessary for content-heavy sites. We wanted something leaner.&lt;/p&gt;
&lt;p&gt;The goal was to build a site that is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Statically Generated&lt;/strong&gt;: For blazing-fast load times and security.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lightweight&lt;/strong&gt;: Avoiding the "hydration tax" of heavier frameworks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Editor-Friendly&lt;/strong&gt;: Allowing editors to see live previews of their content in the Optimizely Visual Builder before publishing.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The Solution: 11ty Meets Optimizely SaaS CMS&lt;/h2&gt;
&lt;p&gt;We chose &lt;strong&gt;&lt;a href="https://www.11ty.dev/"&gt;11ty (Eleventy)&lt;/a&gt;&lt;/strong&gt; as our Static Site Generator (SSG). 11ty is renowned for its simplicity and flexibility. Unlike other frameworks that force you into a specific client-side architecture, 11ty gives you full control over the output. It generates pure HTML, which is exactly what we needed to keep the bloat to a minimum.&lt;/p&gt;
&lt;p&gt;To connect 11ty with Optimizely, we utilised the &lt;strong&gt;&lt;a href="https://www.optimizely.com/products/content-management/"&gt;Optimizely SaaS CMS&lt;/a&gt;&lt;/strong&gt; and the &lt;strong&gt;&lt;a href="https://github.com/episerver/content-js-sdk"&gt;Content JS SDK&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;Optimizely SaaS CMS &amp;amp; Content Graph&lt;/h3&gt;
&lt;p&gt;Our solution centers on the Optimizely SaaS CMS as the headless content repository. We use &lt;strong&gt;&lt;a href="https://docs.developers.optimizely.com/platform-optimizely/docs/introduction-optimizely-graph"&gt;Optimizely Content Graph&lt;/a&gt;&lt;/strong&gt;, a high-performance GraphQL API, to fetch content. This lets us query exactly what we need, when we need it, keeping our build process efficient.&lt;/p&gt;
&lt;h3&gt;The Content JS SDK&lt;/h3&gt;
&lt;p&gt;The &lt;strong&gt;Content JS SDK&lt;/strong&gt; was essential to our architecture. It allowed us to define our content models directly in code using a type-safe builder pattern.&lt;/p&gt;
&lt;p&gt;For example, defining a "Button Block" becomes a straightforward TypeScript definition:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-typescript"&gt;import { contentType } from '@optimizely/cms-sdk';

export const ButtonBlock = contentType({
  key: 'ButtonBlock',
  baseType: '_block',
  displayName: 'Button Block',
  properties: {
    Text: { type: 'string', displayName: 'Text' },
    Url: { type: 'url', displayName: 'Url' },
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This approach ensures that our codebase stays in sync with our content models, providing excellent developer ergonomics and reducing runtime errors.&lt;/p&gt;
&lt;h3&gt;Structured for Scalability&lt;/h3&gt;
&lt;p&gt;To maintain sanity as the project grows, we adopted a strict directory structure that mirrors both our content strategy and modern component design principles.&lt;/p&gt;
&lt;h4&gt;The Models Folder&lt;/h4&gt;
&lt;p&gt;Our src/models directory is the source of truth for all content definitions. We organized it to reflect the different types of content in the CMS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;src/models/pages/&lt;/strong&gt;: Definitions for full pages (e.g., ArticlePage, LandingPage).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;src/models/experiences/&lt;/strong&gt;: Composition-based content types used in the Visual Builder.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;src/models/components/&lt;/strong&gt;: Reusable content blocks (e.g., ButtonBlock, TextBlock).&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Atomic Component Library&lt;/h4&gt;
&lt;p&gt;For the frontend implementation, we embraced &lt;strong&gt;&lt;a href="https://bradfrost.com/blog/post/atomic-web-design/"&gt;Atomic Design&lt;/a&gt;&lt;/strong&gt;. This methodology breaks the UI down into its smallest fundamental units, ensuring consistency and reusability across the site.&lt;/p&gt;
&lt;p&gt;Our src/components directory is structured as follows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;atoms/&lt;/strong&gt;: Basic building blocks like buttons, inputs, and text blocks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;molecules/&lt;/strong&gt;: Groups of atoms working together (e.g., a search form).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;organisms/&lt;/strong&gt;: Complex UI sections like headers, footers, and hero banners.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;templates/&lt;/strong&gt;: Page-level layouts that stitch everything together.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This clear separation of concerns allows developers to work on individual components in isolation while ensuring they fit perfectly into the larger system. It also naturally encourages adherence to the &lt;strong&gt;Single Responsibility Principle&lt;/strong&gt;, as each atom, molecule, or organism has a distinct and focused purpose.&lt;/p&gt;
&lt;h3&gt;Dynamic Routing with 11ty Pagination&lt;/h3&gt;
&lt;p&gt;One of the challenges with a static site generator is mapping dynamic content from a CMS to static routes. 11ty handles this elegantly through its &lt;strong&gt;pagination&lt;/strong&gt; feature. We treat our entire content repository as a single "paginated" data set, where each item becomes a page.&lt;/p&gt;
&lt;p&gt;First, we fetch all routable content in a global data file, src/_data/routes.js:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-javascript"&gt;// src/_data/routes.js

module.exports = async function () {
  // ... client setup ...

  // Fetch all content items that have a URL
  const query = getRoutePagesQuery(blockFragments);
  const data = await client.request(query);

  // Filter out items without a default URL
  const pages = data._Content.items.filter(item =&amp;gt;
    item._metadata &amp;amp;&amp;amp;
    item._metadata.url &amp;amp;&amp;amp;
    item._metadata.url.default
  );

  return pages;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then, we create a single template, src/pages.11ty.ts, that iterates over this data. By setting size: 1, 11ty generates a separate HTML file for each item in the routes array. The permalink function ensures the file is saved to the correct path as defined in the CMS.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-typescript"&gt;// src/pages.11ty.ts

export const data = {
  pagination: {
    data: 'routes',
    size: 1,
    alias: 'contentItem',
    addAllPagesToCollections: true,
  },
  layout: 'base.11ty.ts',
  permalink: (data: any) =&amp;gt; {
    const item = data.pagination.items[0];
    return item._metadata.url.default;
  },
  // ...
};

export function render(data: any): string {
  const item = data.pagination.items[0];
  return ComponentFactory(item);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This pattern is incredibly powerful. It means we don't need to manually configure routes or create separate templates for every page type. As editors add new pages in Optimizely, 11ty automatically discovers them and builds the corresponding static pages.&lt;/p&gt;
&lt;h3&gt;The Component Factory&lt;/h3&gt;
&lt;p&gt;To handle the diverse range of content types returned by the CMS, we implemented a &lt;strong&gt;Component Factory&lt;/strong&gt;. This function acts as a dispatcher, inspecting the __typename or content type of each item and rendering the appropriate component.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-typescript"&gt;// src/components/ComponentFactory.ts

export function ComponentFactory(content: ContentItem): string {
  // Handle CompositionComponentNode wrapper
  if (content.component) {
    return ComponentFactory(content.component);
  }

  const types = content._metadata?.types || [];
  const typename = content.__typename || '';

  // Dynamic component lookup from registry
  const ComponentView = getView(typename);
  if (ComponentView) {
    return ComponentView(content);
  }

  // Fallback for unknown types
  return `
    &amp;lt;div class="unknown-component" data-epi-block-id="${content._metadata?.key}"&amp;gt;
      &amp;lt;h3&amp;gt;${content.Title || content._metadata?.displayName || 'Unknown'}&amp;lt;/h3&amp;gt;
      &amp;lt;p&amp;gt;Unknown component type: ${typename}&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  `;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This architecture adheres to the &lt;strong&gt;Open/Closed Principle&lt;/strong&gt;. We can add new component types to our registry without modifying the core routing or rendering logic.&lt;/p&gt;
&lt;h3&gt;Enabling Live Previews&lt;/h3&gt;
&lt;p&gt;The most critical part of this POC was ensuring that 11ty's static nature didn't hinder the editing experience. We implemented a dedicated &lt;strong&gt;Express Preview Server&lt;/strong&gt; that runs alongside our 11ty build.&lt;/p&gt;
&lt;p&gt;This server acts as a dynamic bridge between the Optimizely Visual Builder and our static templates.&lt;/p&gt;
&lt;h4&gt;The Express Server Implementation&lt;/h4&gt;
&lt;p&gt;We chose &lt;strong&gt;&lt;a href="https://expressjs.com/"&gt;Express&lt;/a&gt;&lt;/strong&gt; for its robustness and ease of use. The server handles several key responsibilities:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;HMAC Authentication&lt;/strong&gt;: Securely fetches &lt;em&gt;draft&lt;/em&gt; content using preview tokens.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context Awareness&lt;/strong&gt;: Toggles between "Edit Mode" (injecting data-epi-* attributes) and "Preview Mode".&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real-time Updates&lt;/strong&gt;: Listens for webhooks to trigger 11ty rebuilds when content is published.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here is a glimpse of how we handle preview requests in src/preview/server.ts:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-typescript"&gt;// src/preview/server.ts

app.get('/preview/:contentKey', async (req: Request, res: Response) =&amp;gt; {
  const { contentKey } = req.params;
  const { preview_token, ctx } = req.query;

  try {
    // 1. Set context mode for Visual Builder (edit vs preview)
    const contextMode =
      ctx === 'edit' ? 'edit' :
      ctx === 'preview' ? 'preview' :
      null;
    setContextMode(contextMode);

    // 2. Set preview token to enable access to draft content
    if (preview_token &amp;amp;&amp;amp; typeof preview_token === 'string') {
      previewClient.setPreviewToken(preview_token);
    }

    // 3. Fetch content dynamically
    const content = await previewClient.getContentByKey(contentKey);

    if (!content) {
      return res.status(404).send(renderNotFound(contentKey));
    }

    // 4. Render using the same shared templates as 11ty
    const html = renderContent(content);
    res.send(html);
  } catch (error) {
    res.status(500).send(renderError('Server Error', String(error)));
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Handling Webhooks&lt;/h4&gt;
&lt;p&gt;To keep the static site in sync with published content, we also exposed a webhook endpoint. When an editor publishes a page, Optimizely notifies our server, which triggers a debounced rebuild of the 11ty site.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-typescript"&gt;// src/preview/server.ts

app.post('/webhook/content-published', (req: Request, res: Response) =&amp;gt; {
  // ... validation ...

  // Debounce rebuilds to handle rapid updates efficiently
  if (rebuildTimeout) {
    clearTimeout(rebuildTimeout);
  }

  rebuildTimeout = setTimeout(() =&amp;gt; {
    triggerRebuild();
  }, REBUILD_DEBOUNCE_MS);

  res.json({ status: 'ok', message: 'Rebuild scheduled' });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This dual approach gives us the best of both worlds: a static site for production visitors and a dynamic, interactive preview for editors.&lt;/p&gt;
&lt;h2&gt;Why 11ty Over Next.js?&lt;/h2&gt;
&lt;p&gt;While Next.js is a fantastic framework, for this specific use case, 11ty offered distinct advantages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Zero Client-Side JS by Default&lt;/strong&gt;: 11ty doesn't assume you want a Single Page Application (SPA). It serves HTML. If you want JavaScript, you add it. This results in significantly smaller bundle sizes and faster Time to Interactive (TTI).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Build Speed&lt;/strong&gt;: 11ty is incredibly fast at building static pages, which is crucial when scaling to thousands of pages.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Simplicity&lt;/strong&gt;: The learning curve is gentler, and the architecture is easier to reason about. There is no complex hydration logic to debug.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;This POC has demonstrated that you don't need a heavy JavaScript framework to build a modern, dynamic, and editor-friendly website with Optimizely SaaS CMS. By combining the raw power and simplicity of 11ty with the robust APIs of Optimizely, we have delivered a solution that delights both developers and content editors.&lt;/p&gt;
&lt;p&gt;We have achieved the best of both worlds: the performance of a static site with the dynamic editing capabilities of a CMS-driven application.&lt;/p&gt;
&lt;p&gt;You can view the final result here: &lt;a href="https://11ty-netcel-saas.netlify.app/"&gt;11ty-netcel-saas.netlify.app&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The full source code is available on GitHub: &lt;a href="https://github.com/MineshS/11ty/"&gt;github.com/MineshS/11ty&lt;/a&gt; (Currently a private repo but please do reach out with Git Username so can add)&amp;nbsp;&lt;/p&gt;</summary></entry><entry><title>Extending Application Insights in an Optimizely PaaS CMS Solution</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/9/extending-application-insights-in-an-optimizely-paas-cms-solution/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/9/extending-application-insights-in-an-optimizely-paas-cms-solution/</id><published>2025-09-24T23:19:07.0000000Z</published><updated>2025-09-24T23:19:07.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;As part of a recent Optimizely DXP project, I needed richer telemetry from the Content Delivery API than the default Application Insights integration provides. The aim was to capture custom properties such as content IDs and cache status so that we could slice and query API performance in more detail. Here is the approach I took.&lt;/p&gt;
&lt;h2&gt;Creating a Custom Telemetry Processor&lt;/h2&gt;
&lt;p&gt;The first step was to create a custom 'ITelemetryProcessor' implementation to enrich telemetry for Content Delivery API requests. This processor inspects each telemetry item, checks whether it relates to the Content Delivery API, and then adds custom properties such as 'ApiType', 'IsOptimizelyApi', and the extracted 'ContentId'.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public class ContentDeliveryApiTelemetryProcessor(ITelemetryProcessor next) : ITelemetryProcessor
{
    private readonly ITelemetryProcessor _next = next;

    public void Process(ITelemetry item)
    {
        if (item is RequestTelemetry requestTelemetry &amp;amp;&amp;amp;
            IsContentDeliveryApiRequest(requestTelemetry.Name))
        {
            requestTelemetry.Properties["ApiType"] = "ContentDeliveryApi";
            requestTelemetry.Properties["IsOptimizelyApi"] = "true";

            var contentId = ExtractContentIdFromUrl(requestTelemetry.Url?.ToString());
            if (!string.IsNullOrEmpty(contentId))
            {
                requestTelemetry.Properties["ContentId"] = contentId;
            }
        }

        _next.Process(item);
    }

    // Helper methods to identify API requests and extract IDs omitted for brevity
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register the processor with Application Insights in 'startup.cs' or the Program configuration so that it runs for every request.&lt;/p&gt;
&lt;h2&gt;Adding Request and Response Logging Middleware&lt;/h2&gt;
&lt;p&gt;To go further, I introduced a middleware component to log the full request and response cycle for API calls. This middleware captures request and response bodies, headers, and timing, and sends a single 'RequestTelementry' entry to Application Insights with custom properties such as cache status.&lt;/p&gt;
&lt;p&gt;Below is a reduced example focusing on adding custom properties to a tracked request:&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public class RequestResponseLoggingMiddleware(RequestDelegate next, TelemetryClient telemetryClient)
{
    private readonly RequestDelegate _next = next;
    private readonly TelemetryClient _telemetryClient = telemetryClient;

    public async Task InvokeAsync(HttpContext context)
    {
        // &amp;hellip; capture request/response details &amp;hellip;

        var telemetry = new RequestTelemetry
        {
            Name = $"{context.Request.Method} {context.Request.Path}",
            Url = new Uri($"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}"),
            Duration = stopwatch.Elapsed,
            ResponseCode = context.Response.StatusCode.ToString(),
            Success = context.Response.StatusCode &amp;lt; 400
        };

        telemetry.Properties["ApiType"] = "ContentDeliveryApi";
        telemetry.Properties["IsOptimizelyApi"] = "true";
        telemetry.Properties["CacheStatus"] = cacheStatus;
        telemetry.Properties["RequestHeader_UserAgent"] = context.Request.Headers["User-Agent"];

        _telemetryClient.TrackRequest(telemetry);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register the middleware in 'Startup.cs':&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;app.UseMiddleware&amp;lt;RequestResponseLoggingMiddleware&amp;gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And configure logging levels in 'appSettings.json:&lt;/p&gt;
&lt;pre class="language-html"&gt;&lt;code&gt;"Logging": {
  "LogLevel": {
    "Default": "Warning",
    "Cms.Infrastructure.Middleware.RequestResponseLoggingMiddleware": "Information"
  },
  "ApplicationInsights": {
    "LogLevel": {
      "Default": "Information"
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This approach provides structured, queryable telemetry for each Content Delivery API call.&lt;/p&gt;
&lt;h2&gt;Example Output&lt;/h2&gt;
&lt;p&gt;&lt;img src="/link/0598b8872d424dbca0c1d0de85e7459a.aspx" /&gt;&lt;/p&gt;
&lt;h2&gt;Benefits of Extended Telemetry&lt;/h2&gt;
&lt;p&gt;Extending telemetry offers several practical advantages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Deeper diagnostics&lt;/strong&gt;: You can quickly isolate performance bottlenecks, such as slow content retrieval or cache misses.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Better observability&lt;/strong&gt;: Custom properties allow you to group or filter requests by content item, making it easier to identify patterns.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Operational insight&lt;/strong&gt;: Logging cache status and request headers reveals how your CDN, edge caching, and client applications interact with the CMS.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In large Optimizely solutions, where API calls power multiple front ends, this level of detail is invaluable for proactive monitoring.&lt;/p&gt;</summary></entry><entry><title>Dynamic CSP Management for Headless and Hybrid Optimizely CMS with Next.js</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/9/dynamic-content-security-policy-management-with-optimizely-cms-in-headless-architecture/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/9/dynamic-content-security-policy-management-with-optimizely-cms-in-headless-architecture/</id><published>2025-09-08T09:43:10.0000000Z</published><updated>2025-09-08T09:43:10.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;In the evolving realm of web security, Content Security Policy (CSP) is essential for defending against XSS and injection attacks. Traditional approaches often fall short because policies are embedded in code and hard to coordinate across environments.&lt;/p&gt;
&lt;p&gt;Optimizely CMS supports dynamic CSP in a headed setup, where the CMS renders pages. This is straightforward with third-party modules such as the &lt;a href="https://github.com/GeekInTheNorth/Stott.Security.Optimizely"&gt;Stott Security Optimizely module&lt;/a&gt;. Installation is simple, configuration is clear, and the headers flow with the response.&lt;/p&gt;
&lt;p&gt;Headless and hybrid architectures introduce a gap. The frontend is separate, so CSP headers often end up fixed in the application tier. That limits agility for security and content teams.&lt;/p&gt;
&lt;p&gt;By combining the Stott Security module with Next.js middleware, dynamic CSP becomes possible in headless and hybrid environments. Policies remain CMS-managed, even when the site is delivered through a decoupled frontend.&lt;/p&gt;
&lt;h2&gt;What we&amp;rsquo;re solving&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Policy changes without releases:&lt;/strong&gt; Update CSP in CMS and apply instantly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Environment variations:&lt;/strong&gt; Manage dev, staging, and production policies centrally.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-team flow:&lt;/strong&gt; Security and marketing can adjust policies without blocking frontend teams.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Headed vs headless&lt;/h2&gt;
&lt;p&gt;In headed Optimizely CMS, dynamic CSP is native to the page response. This remains a solid and current pattern. In headless and hybrid setups, the CMS serves content APIs while a separate frontend handles rendering. Without a bridge, CSP is usually hardcoded in that frontend. The approach below restores the same dynamic control you expect in headed.&lt;/p&gt;
&lt;h2&gt;Architecture&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;User Request &amp;rarr; Next.js Middleware &amp;rarr; Stott Security API &amp;rarr; Optimizely CMS &amp;rarr; Dynamic CSP Headers&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;The user requests a page from the Next.js app.&lt;/li&gt;
&lt;li&gt;Middleware intercepts the request.&lt;/li&gt;
&lt;li&gt;Middleware calls the Stott Security endpoint to retrieve current headers.&lt;/li&gt;
&lt;li&gt;Headers are applied to the response before it is returned.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Implementation in Next.js middleware&lt;/h2&gt;
&lt;p&gt;The example below removes development-only branches. It always sources headers from CMS for consistency.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-typescript"&gt;import { NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  const baseCmsUrl = process.env.DXP_URL || "https://localhost:5000";
  const headersUrl = `${baseCmsUrl}/stott.security.optimizely/api/compiled-headers/list/`;

  // Create or reuse a Response object from your app logic prior to this point.
  // For illustration we assume you already have a 'response' to enrich.
  let response = new Response();

  try {
    const cmsResponse = await fetch(headersUrl, {
      headers: { "Content-Type": "application/json" },
      // Consider short caching and timeouts at edge to keep latency tight
    });

    if (!cmsResponse.ok) {
      // Replace with your logger
      console.error({
        status: cmsResponse.status,
        statusText: cmsResponse.statusText,
        url: headersUrl,
        path: request.nextUrl.pathname,
        context: "middleware - security headers fetch failed"
      });
      return response; // graceful fallback
    }

    const securityHeaders: Array&amp;lt;{ key: string; value: string }&amp;gt; = await cmsResponse.json();

    securityHeaders.forEach(h =&amp;gt; response.headers.set(h.key, h.value));

    return response;
  } catch (error) {
    // Replace with your logger
    console.error({
      error,
      url: headersUrl,
      path: request.nextUrl.pathname,
      context: "middleware - security headers processing error"
    });
    return response; // fail safely
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In your production app, you&amp;rsquo;ll attach these headers to the actual page or asset response you are returning from Next.js. Keep the fetch lean, and prefer edge runtime where possible.&lt;/p&gt;
&lt;h2&gt;Key benefits&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CMS-managed security:&lt;/strong&gt; Update CSP without code changes. See the effect immediately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Environment flexibility:&lt;/strong&gt; Dev can be relaxed, staging can mirror production with test tools, production can stay strict.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Operational speed:&lt;/strong&gt; Emergency updates and new integrations go live from CMS.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resilience:&lt;/strong&gt; If the CMS API is unavailable, the site continues with safe defaults.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Developer experience:&lt;/strong&gt; Clear separation of concerns. No more per-environment hardcoding.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Practical considerations&lt;/h2&gt;
&lt;h3&gt;Performance&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Enable caching at the CMS endpoint for short periods.&lt;/li&gt;
&lt;li&gt;Return compact JSON from the Stott Security module.&lt;/li&gt;
&lt;li&gt;Use edge middleware for minimal latency.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Security&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Rely on the module&amp;rsquo;s validation to prevent invalid CSP syntax.&lt;/li&gt;
&lt;li&gt;Keep an audit trail of edits to support compliance.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Workflow&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Document your policy structure in CMS.&lt;/li&gt;
&lt;li&gt;Align environments through content, not code.&lt;/li&gt;
&lt;li&gt;Test policy variations in staging, then promote.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why the Stott Security module&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Comfortable CMS UI for policy editing.&lt;/li&gt;
&lt;li&gt;Validation before changes go live.&lt;/li&gt;
&lt;li&gt;CSP violation reporting integration.&lt;/li&gt;
&lt;li&gt;Support for multiple sites and additional security headers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Explore the module on GitHub: &lt;a href="https://github.com/GeekInTheNorth/Stott.Security.Optimizely"&gt;GeekInTheNorth/Stott.Security.Optimizely&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Headed Optimizely CMS already delivers dynamic CSP with ease. The approach above brings the same control to headless and hybrid builds. Policies stay in CMS, the frontend stays decoupled, and security remains responsive to change.&lt;/p&gt;
&lt;p&gt;With Next.js middleware, Optimizely CMS, and the Stott Security module, CSP moves from static configuration to a manageable, collaborative capability. It works with the speed of your teams and the realities of modern delivery.&lt;/p&gt;
&lt;h3&gt;Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/GeekInTheNorth/Stott.Security.Optimizely"&gt;Stott Security Optimizely Module&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/building-your-application/routing/middleware"&gt;Next.js Middleware&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP"&gt;MDN: Content Security Policy&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</summary></entry><entry><title>The Sweet Spot: Hybrid Headless Architecture</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/9/the-sweet-spot-hybrid-headless-architecture/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/9/the-sweet-spot-hybrid-headless-architecture/</id><published>2025-09-04T10:54:45.0000000Z</published><updated>2025-09-04T10:54:45.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;When it comes to content management architecture, the pendulum often swings between tightly coupled &amp;ldquo;headed&amp;rdquo; CMS setups and the flexibility of fully headless. In reality, many organizations are finding the sweet spot in between:&amp;nbsp;&lt;strong&gt;hybrid headless&lt;/strong&gt;. Pair that with &lt;strong&gt;Optimizely PaaS CMS&lt;/strong&gt; and &lt;strong&gt;GraphQL&lt;/strong&gt;, and you get an architecture that balances agility, scalability, and most importantly, content editor empowerment.&lt;/p&gt;
&lt;h2&gt;The Three Approaches: Headed, Headless, and Hybrid&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Traditional headed CMS&lt;/strong&gt;&lt;br /&gt;CMS and presentation are tightly coupled. It is easy for content editors and fast for classic web builds, but it struggles to expand into multi-channel experiences.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fully headless CMS&lt;/strong&gt;&lt;br /&gt;The CMS acts purely as a content repository, serving JSON via APIs. Front-ends can be built with any technology, but editors lose in-context previewing, and development teams bear additional complexity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hybrid headless CMS&lt;/strong&gt;&lt;br /&gt;With Optimizely, you retain in-context editing while exposing content via APIs. Add &lt;strong&gt;GraphQL&lt;/strong&gt;, and your content architecture gains flexibility, typing, and API efficiency, all while keeping the editorial experience intact.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why Hybrid Headless with Optimizely PaaS + GraphQL Wins&lt;/h2&gt;
&lt;h3&gt;1. Editor-first without compromise&lt;/h3&gt;
&lt;p&gt;Editors stay in their familiar Optimizely interface, including on-page preview, content workflows, and drag-and-drop editing, without workarounds required in fully headless setups.&lt;/p&gt;
&lt;h3&gt;2. Developer agility with GraphQL&lt;/h3&gt;
&lt;p&gt;GraphQL gives developers precise, efficient data access in a single request. It is strongly typed, self-documenting, and accelerates onboarding as well as development velocity.&lt;/p&gt;
&lt;h3&gt;3. Why Optimizely PaaS CMS&lt;/h3&gt;
&lt;p&gt;One of the main advantages of running on PaaS rather than SaaS is the additional control it gives development teams. With PaaS, you are not limited to purely configuration-driven approaches. You can implement bespoke functionality, advanced business logic, and integrations that go beyond what SaaS-only solutions allow.&lt;/p&gt;
&lt;p&gt;PaaS also provides deeper control over &lt;strong&gt;GraphQL&lt;/strong&gt; and &lt;strong&gt;custom properties&lt;/strong&gt;, which can be crucial when modelling complex content or delivering advanced omnichannel experiences. This level of flexibility ensures you can tailor the platform to your unique business needs rather than shaping your requirements around the platform.&lt;/p&gt;
&lt;p&gt;For organizations with more sophisticated demands, this makes PaaS the better fit. You get the stability and scalability of Optimizely&amp;rsquo;s managed environment while still retaining the freedom to innovate and customize.&lt;/p&gt;
&lt;h3&gt;4. Scale baked in with Optimizely One&lt;/h3&gt;
&lt;p&gt;Optimizely&amp;rsquo;s DXP handles infrastructure, scaling, uptime, and resilience. Crucially, infrastructure is managed directly by Optimizely, so your team does not need to worry about patching servers or handling traffic spikes.&lt;/p&gt;
&lt;p&gt;What is new is that you now also have the option to &lt;strong&gt;host your decoupled presentation layer on Optimizely&lt;/strong&gt;. This means you can run your React, Next.js, or other modern front-end applications side by side with your CMS in a fully managed environment. Optimizely takes care of hosting, CDN, and security, the same way it already does for your CMS (If PaaS CMS, inhouse engineering team will need to handle upgrades, SaaS CMS is fully managed by Optimizely). This makes hybrid headless even more compelling, since both your content layer and your front-end layer can be managed under one roof. For more background, see my earlier take on Frontend Hosting (&lt;a href="/link/78cf3461994c4df084c5ae460b447ed0.aspx"&gt;early thoughts and key questions&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;5. Reduced complexity vs. fully headless&lt;/h3&gt;
&lt;p&gt;Going fully headless often means every template, component, and preview experience has to be engineered from scratch. While that gives developers maximum flexibility, it also creates a lot of operational overhead.&lt;/p&gt;
&lt;p&gt;Hybrid headless takes a more balanced approach. Editors retain control over templates, layouts, and content structures directly in the CMS, without needing to wait on engineering teams for every change. This lets marketing teams spin up new landing pages, adjust layouts, or test different content formats on their own, while still benefiting from in-context previewing.&lt;/p&gt;
&lt;p&gt;When it comes to personalization, hybrid headless works hand in hand with Optimizely&amp;rsquo;s &lt;strong&gt;Personalization Platform&lt;/strong&gt; and &lt;strong&gt;Web Experimentation Platform&lt;/strong&gt;. Editors and marketers can create personalized variants or run experiments without relying heavily on developers to rewire the front end. The CMS ensures content and templates are structured in a way that these platforms can easily consume, so experimentation and targeting become smoother across channels.&lt;/p&gt;
&lt;p&gt;The result is less engineering effort, faster time to market, and greater autonomy for editors and marketers to deliver tailored customer experiences.&lt;/p&gt;
&lt;h3&gt;6. Personalization and future-proofing&lt;/h3&gt;
&lt;p&gt;Optimizely&amp;rsquo;s personalization and experimentation capabilities extend naturally in a hybrid model. Using the &lt;strong&gt;Optimizely Personalization Platform&lt;/strong&gt; and &lt;strong&gt;Web Experimentation Platform&lt;/strong&gt;, marketing teams can deliver tailored content and run experiments across websites, apps, and other channels.&lt;/p&gt;
&lt;p&gt;Because the CMS provides structured content and GraphQL enables efficient delivery, these platforms can target the right segments without requiring heavy developer involvement. As new channels like AR or VR, wearables, or AI-driven assistants emerge, the same approach applies: structured content from the CMS, flexible delivery via GraphQL, and personalization layered on top through Optimizely&amp;rsquo;s dedicated platforms.&lt;/p&gt;
&lt;h2&gt;Where Opal and the JS SDK Fit In&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Opal&lt;/strong&gt;: Generative AI capabilities embedded in the CMS help editors query content, generate ideas, and streamline content-to-experience workflows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Content JS SDK&lt;/strong&gt;: The &lt;a href="https://github.com/episerver/content-js-sdk"&gt;Content JS SDK&lt;/a&gt; abstracts GraphQL wiring, giving front-end developers a clean, easy-to-use interface for consuming CMS content. This cuts boilerplate and speeds up delivery.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Conclusion: The Sweet Spot&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Editors stay empowered&lt;/li&gt;
&lt;li&gt;Developers get modern flexibility&lt;/li&gt;
&lt;li&gt;Optimizely manages infrastructure, including the option to host your decoupled front-end alongside your CMS&lt;/li&gt;
&lt;li&gt;Future personalization and multi-channel strategies remain fully open&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It is not about swinging to one extreme. It is about choosing an architecture that enables speed now and adaptability tomorrow. With GraphQL, Opal, the Content JS SDK, and the ability to host both CMS and presentation layer on Optimizely, hybrid headless is the architecture that delivers the sweet spot.&lt;/p&gt;</summary></entry><entry><title>Optimizely Frontend Hosting Beta – Early Thoughts and Key Questions</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/4/optimizely-frontend-hosting-beta--early-thoughts-and-key-questions/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/4/optimizely-frontend-hosting-beta--early-thoughts-and-key-questions/</id><published>2025-04-23T13:43:24.0000000Z</published><updated>2025-04-23T13:43:24.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;Optimizely has opened the waitlist for its new Frontend Hosting capability. I&amp;rsquo;m part of the beta programme, but my invite isn&amp;rsquo;t due until May, while I haven&amp;rsquo;t had hands-on access yet, I&amp;rsquo;ve already been digging into what I&amp;rsquo;ll be looking for once it lands.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.optimizely.com/beta/"&gt;Sign up for the beta&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;What is Frontend Hosting?&lt;/h3&gt;
&lt;p&gt;This new offering is designed to simplify how frontend apps are deployed and managed alongside Optimizely CMS. It&amp;rsquo;s available as an add-on to both CMS SaaS and PaaS, and brings together:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Hosting for headless frontend applications&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A built-in delivery network&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Web Application Firewall (WAF)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Managed services for build, deploy and performance&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This could offer a tighter, more integrated experience for those already using a headless CMS setup.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re after background on other frontend hosting options, I covered related topics in these earlier blog posts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="/link/6baf1a675602481d812efea991190290.aspx"&gt;Frontend Hosting for SaaS CMS&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="/link/741c17e1162c4428a15b99a7e061b647.aspx"&gt;Self-hosting the Presentation Layer with Coolify&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3&gt;What I&amp;rsquo;ll Be Exploring in the Beta&lt;/h3&gt;
&lt;h4&gt;Environment &amp;amp; Compatibility&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Will it work cleanly across CMS SaaS and CMS PaaS?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;What frameworks will be supported out of the box (e.g. Next.js, Astro)?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Will it handle different build strategies?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Static&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Dynamic&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Incremental builds&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Are serverless functions on the roadmap (Lambda, Edge functions)?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Is there a CDN and WAF already in place?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Are preview environments supported (e.g. URLs for pull requests)?&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Developer Experience&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Any limits around build minutes, concurrent builds?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Built-in CI/CD or integrations with GitHub/GitLab?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;How is secret management handled? (Environment Variables)&amp;nbsp;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;What kind of logging and observability is available?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Will it help surface key metrics (e.g. Core Web Vitals, SEO signals)?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Can I get alerts if a deploy fails or performance drops?&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Access &amp;amp; Security&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Can access be restricted (IP allow lists, password protection)?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Is traffic control through WAF configurable?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Can we apply rate limits?&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Commercial Considerations&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;How will pricing work?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Bandwidth&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Seats&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Execution time for serverless&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Multi-project or multi-site support &amp;mdash; all under one roof?&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3&gt;Why It&amp;rsquo;s Interesting&lt;/h3&gt;
&lt;p&gt;For teams running hybrid headless setups, this could remove a fair bit of friction. No need to spin up hosting separately or stitch things together manually, this could mean fewer moving parts, more predictable deployment workflows, and a clearer support model.&lt;/p&gt;
&lt;p&gt;And being Optimizely-native, it should also mean closer alignment with the rest of the platform ecosystem.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Discovery &amp;amp; Early Access&lt;/h3&gt;
&lt;p&gt;I was part of an early discovery interview with the team last year, which gave me a first look at the thinking behind this product. Here&amp;rsquo;s part of the follow-up I received:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;"As you may recall, you participated in a discovery interview with us about CMS frontend hosting last year. Your feedback back then was extremely helpful! I wanted to let you know that we are now in a beta testing phase of frontend hosting!"&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I&amp;rsquo;ve also some early preview screenshots from the discovery. I&amp;rsquo;ll include a few of those in this post to give a sense of what might be coming.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Note: The screenshots shown here are from early design mockups shared during the initial discovery phase. The UI and features in the current beta release may differ significantly as the product has continued to evolve.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/bd2927fc5a3349768416e86356bbdf68.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/e635a7e7d9b1472ba76607daac685f46.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/e6925b1bb29643fa902317d7edbeda18.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/5d9d7602a21945bdba63d4c3f24671bc.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Get Involved&lt;/h3&gt;
&lt;p&gt;Optimizely is actively inviting feedback to help shape this feature before full release. If this looks like something you&amp;rsquo;d want to test, you can still register via the waitlist.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.optimizely.com/beta/"&gt;Register for the beta&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ll post more as soon as I&amp;rsquo;m hands-on in May &amp;mdash; including walkthroughs, findings, and observations from real use cases.&lt;/p&gt;
&lt;p&gt;Stay tuned.&lt;/p&gt;</summary></entry><entry><title>Experimentation strategies with Optimizely: Web, Edge, and Feature Experimentation</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/4/experimentation-strategies-with-optimizely-web-edge-and-feature-experimentation/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/4/experimentation-strategies-with-optimizely-web-edge-and-feature-experimentation/</id><published>2025-04-05T12:24:37.0000000Z</published><updated>2025-04-05T12:24:37.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p class="MsoNormal"&gt;Experimentation is a cornerstone of data-driven decision-making, and Optimizely offers multiple ways to run experiments across your digital experiences. Whether you are optimising a website, personalising a user journey, or testing new features, choosing the right experimentation strategy can have a significant impact on both user experience and performance.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;In this post, we&amp;rsquo;ll explore three key experimentation approaches in Optimizely: &lt;strong&gt;Web Experimentation, Edge Experimentation, and Feature Experimentation&lt;/strong&gt;&amp;mdash;highlighting their strengths, trade-offs, and performance considerations.&lt;/p&gt;
&lt;h2 class="MsoNormal"&gt;&lt;strong&gt;Web Experimentation: Client-Side Testing&lt;/strong&gt;&lt;/h2&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;&lt;img src="/link/8db1849d0e3743c688ce31b6485ee81c.aspx" /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Optimizely Web Experimentation is the most commonly known form of A/B testing, where experiments are executed client-side via JavaScript. This means that changes are applied dynamically in the browser after the page has loaded.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Benefits of Web Experimentation&lt;/strong&gt;&lt;/p&gt;
&lt;ul style="margin-top: 0cm;"&gt;
&lt;li class="MsoNormal" style="mso-list: l0 level1 lfo1; tab-stops: list 36.0pt;"&gt;&lt;strong&gt;Fast iteration:&lt;/strong&gt; Non-technical teams (e.g. marketers, designers) can quickly create and deploy experiments without engineering support.&lt;/li&gt;
&lt;li class="MsoNormal" style="mso-list: l0 level1 lfo1; tab-stops: list 36.0pt;"&gt;&lt;strong&gt;Rich visual editing:&lt;/strong&gt; The WYSIWYG editor enables easy modification of elements without touching code.&lt;/li&gt;
&lt;li class="MsoNormal" style="mso-list: l0 level1 lfo1; tab-stops: list 36.0pt;"&gt;&lt;strong&gt;Wide experimentation scope:&lt;/strong&gt; Ideal for A/B testing landing pages, copy variations, UI elements, and other front-end components.&lt;/li&gt;
&lt;/ul&gt;
&lt;p class="MsoNormal"&gt;For more information, refer to the official Optimizely Web Experimentation documentation: &lt;a href="https://docs.developers.optimizely.com/web-experimentation/docs/introduction"&gt;Optimizely Web Experimentation Introduction&lt;/a&gt;&lt;/p&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Performance considerations&lt;/strong&gt;&lt;/p&gt;
&lt;ul style="margin-top: 0cm;"&gt;
&lt;li class="MsoNormal" style="mso-list: l1 level1 lfo2; tab-stops: list 36.0pt;"&gt;&lt;strong&gt;Flickering effect:&lt;/strong&gt; Since changes are applied after the page loads, some users may briefly see the original content before the variation is applied.&lt;/li&gt;
&lt;li class="MsoNormal" style="mso-list: l1 level1 lfo2; tab-stops: list 36.0pt;"&gt;&lt;strong&gt;JavaScript overhead:&lt;/strong&gt; As the number of experiments grow, the required JavaScript also increases, which may affect page load times.&lt;/li&gt;
&lt;li class="MsoNormal" style="mso-list: l1 level1 lfo2; tab-stops: list 36.0pt;"&gt;&lt;strong&gt;Execution time:&lt;/strong&gt; Performance can vary depending on the complexity of the variation and network conditions.&lt;/li&gt;
&lt;/ul&gt;
&lt;p class="MsoNormal"&gt;Despite these considerations, Web Experimentation remains a powerful tool for non-technical teams looking to optimise experiences without needing backend development effort.&lt;/p&gt;
&lt;h2 class="MsoNormal"&gt;&lt;strong&gt;Edge Experimentation: Server-side, high-performance testing&lt;/strong&gt;&lt;/h2&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;&lt;img src="/link/3796bfc2b06c46b09517f646d089db25.aspx" /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Edge Experimentation moves the execution of experiments from the browser to the edge layer, where content delivery happens closer to the user (e.g. via a CDN or middleware). This approach ensures experiments are applied before the page reaches the end user.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Benefits of Edge Experimentation&lt;/strong&gt;&lt;/p&gt;
&lt;ul style="margin-top: 0cm;"&gt;
&lt;li class="MsoNormal" style="mso-list: l2 level1 lfo3; tab-stops: list 36.0pt;"&gt;&lt;strong&gt;Near-zero performance impact:&lt;/strong&gt; Since variations are applied before the response reaches the client, there&amp;rsquo;s no flickering or delay.&lt;/li&gt;
&lt;li class="MsoNormal" style="mso-list: l2 level1 lfo3; tab-stops: list 36.0pt;"&gt;&lt;strong&gt;More reliable data:&lt;/strong&gt; Eliminates client-side inconsistencies, ensuring accurate measurement of experiment results.&lt;/li&gt;
&lt;li class="MsoNormal" style="mso-list: l2 level1 lfo3; tab-stops: list 36.0pt;"&gt;&lt;strong&gt;Faster experimentation for global users:&lt;/strong&gt; Leveraging CDNs and edge computing reduces latency, making experiences smoother for users worldwide.&lt;/li&gt;
&lt;/ul&gt;
&lt;p class="MsoNormal"&gt;For more information, refer to the official Optimizely Performance Edge documentation: &lt;a href="https://docs.developers.optimizely.com/performance-edge/docs/overview"&gt;Optimizely Performance Edge Overview&lt;/a&gt;&lt;/p&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Use cases&lt;/strong&gt;&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Edge Experimentation is ideal for personalisation at scale, content experiments, and changes that impact performance-sensitive areas such as eCommerce checkout flows and high-traffic landing pages.&lt;/p&gt;
&lt;h2 class="MsoNormal"&gt;&lt;strong&gt;Feature Experimentation: Server-side testing for feature rollouts&lt;/strong&gt;&lt;/h2&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;&lt;img src="/link/aec3a57f068b42c8bb142ac43f56ce00.aspx" /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Feature Experimentation allows teams to test and control feature rollouts via &lt;strong&gt;feature flags&lt;/strong&gt; at the server or middleware level. Unlike Web Experimentation, which focuses on front-end changes, this method is geared towards testing new functionalities before full deployment.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Benefits of Feature Experimentation&lt;/strong&gt;&lt;/p&gt;
&lt;ul style="margin-top: 0cm;"&gt;
&lt;li class="MsoNormal" style="mso-list: l3 level1 lfo4; tab-stops: list 36.0pt;"&gt;&lt;strong&gt;No performance trade-offs:&lt;/strong&gt; Since variations are applied server-side, users receive the final content without delay.&lt;/li&gt;
&lt;li class="MsoNormal" style="mso-list: l3 level1 lfo4; tab-stops: list 36.0pt;"&gt;&lt;strong&gt;Safe feature rollouts:&lt;/strong&gt; Teams can progressively roll out features to a subset of users before full release.&lt;/li&gt;
&lt;li class="MsoNormal" style="mso-list: l3 level1 lfo4; tab-stops: list 36.0pt;"&gt;&lt;strong&gt;Deeper experimentation possibilities:&lt;/strong&gt; Beyond UI tests, Feature Experimentation enables testing changes in logic, APIs, and infrastructure.&lt;/li&gt;
&lt;/ul&gt;
&lt;p class="MsoNormal"&gt;For more information, refer to the official Optimizely Feature Experimentation documentation: &lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/introduction"&gt;Optimizely Feature Experimentation Introduction&lt;/a&gt;&lt;/p&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Use cases&lt;/strong&gt;&lt;/p&gt;
&lt;p class="MsoNormal"&gt;This approach is perfect for testing new functionalities, backend optimizations, and product feature toggles while minimising risks.&lt;/p&gt;
&lt;h2 class="MsoNormal"&gt;&lt;strong&gt;Choosing the right experimentation strategy&lt;/strong&gt;&lt;/h2&gt;
&lt;p class="MsoNormal"&gt;When deciding between Web, Edge, and Feature Experimentation, consider the following:&lt;/p&gt;
&lt;table class="MsoNormalTable" style="mso-cellspacing: 1.5pt; border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; mso-yfti-tbllook: 1184; mso-border-insideh: .5pt solid windowtext; mso-border-insidev: .5pt solid windowtext;"&gt;
&lt;tbody&gt;
&lt;tr style="mso-yfti-irow: 0; mso-yfti-firstrow: yes;"&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Factor&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Web Experimentation&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Edge Experimentation&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Feature Experimentation&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style="mso-yfti-irow: 1;"&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Performance&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;Can introduce flickering and JavaScript overhead&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;Near-instant, no flickering&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;No client-side impact&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style="mso-yfti-irow: 2;"&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Experiment Scope&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;Front-end UI elements&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;Content and UI changes at the edge layer&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;Feature toggles, backend logic&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style="mso-yfti-irow: 3;"&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Execution Location&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;Client-side (browser)&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;CDN/Edge layer&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;Server-side or middleware&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style="mso-yfti-irow: 4;"&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Implementation Effort&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;Low (no engineering needed)&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;Medium (requires setup)&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;High (involves development)&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style="mso-yfti-irow: 5; mso-yfti-lastrow: yes;"&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;Best For&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;Marketing, UI tweaks, messaging tests&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;Performance-critical content changes&lt;/p&gt;
&lt;/td&gt;
&lt;td style="border: solid windowtext 1.0pt; mso-border-alt: solid windowtext .5pt; padding: .75pt .75pt .75pt .75pt;"&gt;
&lt;p class="MsoNormal"&gt;Feature rollouts, backend logic experiments&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p class="MsoNormal"&gt;&lt;strong&gt;&amp;nbsp;&lt;/strong&gt;&lt;/p&gt;
&lt;h2 class="MsoNormal"&gt;&lt;strong&gt;Final thoughts&lt;/strong&gt;&lt;/h2&gt;
&lt;p class="MsoNormal"&gt;Each experimentation approach in Optimizely has its place. &lt;strong&gt;Web Experimentation&lt;/strong&gt; remains a powerful tool for marketers and teams that need agility, despite its minor performance trade-offs. &lt;strong&gt;Edge and Feature Experimentation&lt;/strong&gt;, on the other hand, offer superior performance and scalability by shifting execution away from the client.&lt;/p&gt;
&lt;p class="MsoNormal"&gt;Ultimately, leveraging a combination of these approaches based on your goals, performance requirements, and team capabilities will lead to the most effective experimentation strategy.&lt;/p&gt;</summary></entry><entry><title>Exploring Optimizely SaaS CMS – What’s New &amp; How to Accelerate your Build</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/2/exploring-optimizely-saas-cms--whats-new--how-to-accelerate-your-build/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2025/2/exploring-optimizely-saas-cms--whats-new--how-to-accelerate-your-build/</id><published>2025-02-27T11:00:25.0000000Z</published><updated>2025-02-27T11:00:25.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;In my latest video, I take a fresh look at &lt;strong&gt;Optimizely SaaS CMS&lt;/strong&gt;, covering some of the recent improvements aimed at enhancing the editor experience. Plus, I dive into the fantastic work &lt;strong&gt;Remko&lt;/strong&gt; has done with his &lt;strong&gt;helper packages&lt;/strong&gt; and &lt;strong&gt;starter solutions&lt;/strong&gt;, making development even more seamless.&lt;/p&gt;
&lt;p&gt;🔹 Learn how to effortlessly create an &lt;strong&gt;Element&lt;/strong&gt; in the UI and scaffold it in the frontend.&lt;br /&gt;🔹 See how you can generate a &lt;strong&gt;new element&lt;/strong&gt; directly in the frontend and push it to the CMS.&lt;/p&gt;
&lt;p&gt;If you're working with Optimizely or just curious about headless CMS solutions, this one's for you!&lt;/p&gt;
&lt;p&gt;📺 Watch here: &lt;a href="https://youtu.be/m8zvDgdYmpk"&gt;https://youtu.be/m8zvDgdYmpk&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Links I used in the video:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Remkos Git Repository: &lt;a href="https://github.com/remkoj"&gt;https://github.com/remkoj&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Optimizely Mosey Demo: &lt;a href="https://github.com/episerver/cms-saas-vercel-demo"&gt;https://github.com/episerver/cms-saas-vercel-demo&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Let me know your thoughts in the comments!&lt;/p&gt;</summary></entry><entry><title>SaaS CMS - Pages and Blocks get the Visual Builder Treatment</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2024/12/saas-cms---pages-and-blocks-get-the-visual-builder-treatment/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2024/12/saas-cms---pages-and-blocks-get-the-visual-builder-treatment/</id><published>2024-12-17T16:27:43.0000000Z</published><updated>2024-12-17T16:27:43.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;I&amp;rsquo;m thrilled to see that Optimizely has now enabled Visual Builder for OG Pages and Blocks within SaaS CMS, and I&amp;rsquo;m guessing this will become standard for CMS13. Previously, Visual Builder was only available for Experiences, forcing us to revert to traditional on-page editing which was somewhat clunky or use the "All Properties" view, which didn&amp;rsquo;t provide a great preview experience.&lt;/p&gt;
&lt;p&gt;With Visual Builder, we now get the best of both worlds: an intuitive editor experience combined with the ability to preview our changes in real time. I haven&amp;rsquo;t taken this for a proper spin yet, but the early signs are very promising.&lt;/p&gt;
&lt;p&gt;When editing structured pages, the new interface looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/b50c210556874c05bf8a49145b1989f4.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;As shown above, updates can be made in the left panel, and they are instantly reflected in the main preview panel.&lt;/p&gt;
&lt;p&gt;In contrast, the old on-page editing view looked like this and was never as responsive:&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/b3e241059b15460bad5da6e83d9f4713.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;Additionally, Visual Builder is now available for Blocks as well. The interface for Blocks is similar to Pages, and it looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/4bb19a0466b54f7e8eaae66e1b226cff.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;Although my templates aren&amp;rsquo;t perfect, this is entirely within our control via the frontend presentation layer (Next.js).&lt;/p&gt;
&lt;p&gt;This is just a quick post to share my initial impressions, but I&amp;rsquo;ll provide more updates as I integrate this into my own templates.&lt;/p&gt;</summary></entry><entry><title>SaaS CMS - Self-hosting Presentation Layer with Coolify</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2024/9/saas-cms---self-hosting-presentation-layer-with-coolify/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2024/9/saas-cms---self-hosting-presentation-layer-with-coolify/</id><published>2024-09-23T19:00:11.0000000Z</published><updated>2024-09-23T19:00:11.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;In my previous post, I explored the hosting options for the Hybrid Headless/Fully Headless presentation layer (frontend) using Netlify and Vercel. These platforms are my top recommendations due to their ease of use and feature-rich offerings. However, there are situations where they may not entirely meet your needs, or their pricing models might pose challenges.&lt;/p&gt;
&lt;p&gt;If you're looking for more control over your hosting costs, self-hosting is a viable third option, whether on physical servers or virtual private servers (VPS). To make the self-hosting process more manageable, I&amp;rsquo;ve created a proof of concept using Coolify. Coolify is an open-source project that simplifies server configuration for hosting your frontends with minimal effort. It offers many of the same features as Vercel and Netlify, but without the associated costs.&lt;/p&gt;
&lt;p&gt;Here are some of Coolify's standout features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Language Agnostic&lt;/strong&gt;: Supports a wide range of programming languages and frameworks, including Next.js.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flexible Deployment&lt;/strong&gt;: Can be deployed on any resource, including your own servers, VPS, EC2, and DigitalOcean&amp;mdash;all you need is an SSH connection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Push to Deploy&lt;/strong&gt;: Easily deploy by pointing to any Git repository.&lt;/li&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PR/Commit Deployments&lt;/strong&gt;: Automatically deploy new commits and pull requests.&lt;/li&gt;
&lt;/ul&gt;
&lt;li&gt;&lt;strong&gt;Additional Features&lt;/strong&gt;:&lt;/li&gt;
&lt;ul&gt;
&lt;li&gt;SSL certificates&lt;/li&gt;
&lt;li&gt;Automatic backups&lt;/li&gt;
&lt;li&gt;Webhooks&lt;/li&gt;
&lt;li&gt;API-first approach&lt;/li&gt;
&lt;li&gt;Collaboration tools&lt;/li&gt;
&lt;li&gt;Monitoring and notifications&lt;/li&gt;
&lt;/ul&gt;
&lt;/ul&gt;
&lt;h2&gt;Azure Example&lt;/h2&gt;
&lt;p&gt;Coolify offers flexible installation and configuration options, allowing deployment on virtually any environment. For full documentation, you can refer to &lt;a href="https://coolify.io/docs/installation"&gt;&lt;span&gt;Coolify's&lt;/span&gt;&lt;span&gt; official&lt;/span&gt;&lt;span&gt; guide&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;For this example, I chose to test Coolify on a &lt;strong&gt;Linux (Ubuntu 24.04) Virtual Machine&lt;/strong&gt;. The setup process in Azure is straightforward, so I won't dive into each setup step, but here&amp;rsquo;s a quick overview of my configuration below:&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/6657cca0cdc74998a64bf70e61f59f6a.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;I also had to open a few ports to allow for communication to and from the virtual machine. This was for both setup purposes and public site access (I was adding these as and when required).&amp;nbsp;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Port 22 for SSH Access&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Port 8000 for initial Coolify server Access&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Port 80 for Public HTTP Access&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Port 443 for Public HTTPS Access&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src="/link/4c76347d3fa94dceb3535c355d3beae2.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;Once my virtual machine was configured, I made an SSH connection. You can connect either via the Azure Portal or using your favorite terminal.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/ca74d51676c24f26892de7343ea12b4c.aspx" /&gt;&lt;/p&gt;
&lt;h2&gt;Install and Configure Coolify&lt;/h2&gt;
&lt;p&gt;Once connected via SSH, the installation of Coolify was simple. Running the following command started the process:&lt;/p&gt;
&lt;pre class="language-c"&gt;&lt;code&gt;curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This installs &lt;strong&gt;Docker&lt;/strong&gt; and runs the Coolify image. The entire process took about 5 minutes. After installation, Coolify provides a URL to complete the configuration. Once I registered, I was taken to the Coolify dashboard, ready to create a new project.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/be347e3621204be9b3337d6f86fcfb03.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/f29bf0f226134be28a8bcba42ac77594.aspx" /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;Domain Configuration&lt;/h2&gt;
&lt;p&gt;To make access easier, I connected a domain to my server. I used an existing domain from &lt;strong&gt;Fasthosts&lt;/strong&gt; and made simple DNS changes by adding two &lt;strong&gt;A Records&lt;/strong&gt; (&lt;code&gt;@&lt;/code&gt; and &lt;code&gt;*&lt;/code&gt;), pointing them to my server&amp;rsquo;s public IP address.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/81133d5676a84627acbdb4da055e9a59.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;In Coolify&amp;rsquo;s settings, I configured the domain name for the control panel at &lt;code&gt;http://coolify.chillicrunch.co.uk&lt;/code&gt;. I also set up &lt;strong&gt;Wildcard Subdomains&lt;/strong&gt; to allow deployments to URLs like &lt;code&gt;https://&amp;lt;my-project-name&amp;gt;.chilicrunch.co.uk/&lt;/code&gt;, similar to how Netlify and Vercel manage deployments.&lt;/p&gt;
&lt;p&gt;This was done by navigating to &lt;strong&gt;Servers&lt;/strong&gt; &amp;gt; &lt;strong&gt;General&lt;/strong&gt; and adjusting the settings.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/b9c4f8455e6f4863b74ce5b51b8acf18.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/b2a7a85fa1904c07b76f7b25f47d44c7.aspx" /&gt;&lt;/p&gt;
&lt;h2&gt;Connecting to Git Hub Repo&lt;/h2&gt;
&lt;p&gt;To enable continuous deployment, I connected Coolify to my private &lt;strong&gt;GitHub&lt;/strong&gt; repository. Coolify supports several Git sources, but for this example, I used GitHub. You can add your GitHub repo by navigating to &lt;strong&gt;Sources&lt;/strong&gt; and clicking &lt;strong&gt;Add&lt;/strong&gt;. The process requires setting up &lt;strong&gt;Client ID&lt;/strong&gt;, &lt;strong&gt;Secret&lt;/strong&gt;, and &lt;strong&gt;Webhook Secret&lt;/strong&gt;, which are generated during the setup.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/64546679fd3d480d8a4df8ed48f4b86b.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/f032f9a23bf24aa2a95e5902d55aeb42.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/21741afe51de4b409df632e67f1a81a7.aspx" /&gt;&lt;/p&gt;
&lt;h2&gt;Adding a new Project&lt;/h2&gt;
&lt;p&gt;With the Git connection established, I was able to add my first project. In the &lt;strong&gt;Projects&lt;/strong&gt; section, I clicked &lt;strong&gt;New&lt;/strong&gt;, provided a name and description, then selected the Git repo I wanted to deploy.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/eb33c9666590413f8ec044fc22187186.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;The deployment process took about 3 minutes. Thanks to the wildcard subdomains setup, the subdomain was automatically configured. I also added the necessary environment variables for my project. You can find more details on this process in &lt;a href="https://youtu.be/-cFsPUdIVFY?si=FYycbX5v9fny77Xw"&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt; video&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/0017a532a5c54c83a5c61366a1cdada8.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/deb4fb8b440b423a8757cb61faef13c5.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;With everything in place, my website was live: &lt;a href="https://saas-cms-visual-builder.chilicrunch.co.uk/"&gt;https://saas-cms-visual-builder.chilicrunch.co.uk/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/8f9595a956d74d2a83a917c904e51a22.aspx" /&gt;&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Setting up Coolify on an Azure Virtual Machine was smooth and straightforward. As you can see, it's easy to get Coolify up and running on your private servers with minimal effort, making it a cost-effective alternative to traditional hosting platforms like Vercel and Netlify.&lt;/p&gt;</summary></entry><entry><title>Frontend Hosting for SaaS CMS Solutions</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2024/7/frontend-hosting-for-saas-cms-solutions/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2024/7/frontend-hosting-for-saas-cms-solutions/</id><published>2024-07-20T00:21:31.0000000Z</published><updated>2024-07-20T00:21:31.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;Now that CMS SaaS Core has gone into general availability, it is a good time to start discussing where to host the head. SaaS Core is natively a Headless CMS, and regardless of whether you plan to use it in a "Pure Headless" or "Hybrid Headless" manner, the presentation layer (Head/Frontend) needs to be hosted somewhere.&lt;/p&gt;
&lt;h2&gt;&lt;/h2&gt;
&lt;h2&gt;Cloud Native Providers: Vercel and Netlify&lt;/h2&gt;
&lt;p&gt;Optimizely has partnered with Vercel and Netlify, both of which offer fantastic services for hosting the frontend of your application. Both platforms support a variety of frontend frameworks and static site generators, making them versatile choices for a project's hosting needs.&lt;/p&gt;
&lt;p&gt;They provide a seamless experience for developers with continuous deployments, user-friendly CLI tools, unlimited deployment environments, and highly efficient build processes. E&lt;span&gt;very time a pull request is raised, or a commit has been made to a branch, both services automatically build a preview with a unique URL. Like a staging environment for every PR or branch, previews are perfect for testing and collaboration.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;To ensure the frontend is fast and scalable, both platforms offer a CDN to serve requests and support edge or serverless functions to execute any business logic. Serverless functions &lt;span&gt;are a way to deploy server-side code as API endpoints. These will spin up automatically when triggered by an event, handle and process server ran code, and then spin down until the next event. Edge Functions are similar to serverless functions that are generally more efficient and faster than traditional Serverless compute, since they operate within a much leaner runtime. Deployed globally by default, Edge Functions run in the region closest to the request for the lowest latency possible.&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;&lt;img src="/link/b00fa58ff48545d8bddda2bd7d94b121.aspx" width="800" height="237" /&gt;&lt;/h2&gt;
&lt;p&gt;&lt;img src="/link/10c259fec61342d289c65ee0e069e0b8.aspx" width="800" height="460" /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;/h2&gt;
&lt;h2&gt;&lt;span&gt;Commercial&lt;/span&gt; Models&lt;/h2&gt;
&lt;p&gt;Vercel and Netlify are designed to simplify the deployment and management of your website&amp;rsquo;s frontend, ensuring reliability, speed, and ease of use. Their commercial models are quite similar; both offer customizable enterprise pricing based on the specific needs of the organization. Pricing typically includes multiple developer seats, high bandwidth limits with additional costs for overage, and extensive usage of serverless and edge functions with charges for higher usage. Both platforms provide enterprise-grade support, including dedicated account management and service level agreements (SLAs) to ensure high availability and priority issue resolution.&lt;/p&gt;
&lt;h2&gt;Demonstration&lt;/h2&gt;
&lt;p&gt;Here is a quick video demonstrating the deployment of the Mosey Template to Vercel: &lt;a href="https://youtu.be/-cFsPUdIVFY?si=ZDOQwslyZIXAr7kH"&gt;Watch the video&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A similar process was replicated on Netlify with very few changes to the frontend code or configuration.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;By leveraging the robust hosting capabilities of Vercel and Netlify, you can ensure that the frontend of your application is performant, scalable, and easy to manage. Whether you choose Vercel or Netlify, both platforms offer the tools and support needed to seamlessly deploy and maintain your website.&lt;/p&gt;
&lt;h2&gt;But Wait&amp;nbsp;&lt;/h2&gt;
&lt;p&gt;...&amp;nbsp;Oh and for a bit of fun I also looked at self hosting either on prem or on a VPS using Coolify, surprisingly this was also very easy and in less than an hour I had this : &lt;a href="https://saas-cms-visual-builder.chilicrunch.co.uk/en"&gt;https://saas-cms-visual-builder.chilicrunch.co.uk&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/4c41307ffe2940cf8b573c67f3c8d170.aspx" width="800" height="209" /&gt;&lt;/p&gt;
&lt;p&gt;Hopefully have a demo on this early next week :)&lt;/p&gt;</summary></entry><entry><title>Exploring SaaS CMS: API Clients and Content Management APIs</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2024/3/exploring-saas-cms-api-clients-and-content-management-apis/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2024/3/exploring-saas-cms-api-clients-and-content-management-apis/</id><published>2024-03-06T20:29:56.0000000Z</published><updated>2024-03-06T20:29:56.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;In continuation of my &lt;a href="/link/807ad30b59c140a6baec9c7633a5a256.aspx"&gt;previous post&lt;/a&gt; on leveraging the Content Management API and OpenID Connect Authentication on the PaaS-based Optimizely CMS, I delve into the delivery mechanisms within the SaaS-based CMS Platform. Surprisingly, the majority of functionalities are readily available and seamlessly integrated into the system. In this article, I provide a quick preview of the available features and guide on configuring the exposed API for definition and content management.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; It's essential to bear in mind that while I explore these features, the SaaS platform is still in its BETA phase, and APIs are currently at version 0.5. Changes might occur as the SaaS CMS transitions into general availability.&lt;/p&gt;
&lt;h2&gt;Configuring the API Client (Equivalent to OpenID Connect Package)&lt;/h2&gt;
&lt;p&gt;The API client functionality comes pre-installed in the SaaS CMS, and the setup is near identical to what we found on the PaaS platform with the Open ID Connect package. The tool can be found within Settings and the Access Rights section as highligted in the image below.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Once on the API Client interface we can create a Client ID, the Client Secret is automatically generated please store this safely as there is no way of retrieving once you leave the page.&amp;nbsp;The option to "Allow the client to impersonate users" is self-explanatory; enabling this allows the client to function as another user within the CMS.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/2a7d3af9b80749d189b9c3ddc6fa2287.aspx" /&gt;&lt;/p&gt;
&lt;h3&gt;Using the credentials&lt;/h3&gt;
&lt;p&gt;I'll demonstrate how to utilize these credentials using Postman to retrieve a JWT token for subsequent API calls. To obtain a Bearer Auth Token, a GET call needs to be made to the designated URL:&lt;/p&gt;
&lt;pre class="language-markup"&gt;&lt;code&gt;https://app-xxxprod.cms.optimizely.com/_cms/v0.5/oauth/token&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;Sending the required parameters along with the request, including &lt;/span&gt;&lt;code&gt;grant_type&lt;/code&gt;&lt;span&gt;, &lt;/span&gt;&lt;code&gt;client_id&lt;/code&gt;&lt;span&gt;, &lt;/span&gt;&lt;code&gt;client_secret&lt;/code&gt;&lt;span&gt;, and optionally &lt;/span&gt;&lt;code&gt;act_as&lt;/code&gt;&lt;span&gt;, will result in the generation of a token for future requests. Notably, this token expires automatically after 300 seconds.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;Example:&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;&lt;img src="/link/afad4afd59dd4d66b7dc4837d4a088e6.aspx" /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;Authorisation to API using the Bearer Token&lt;/h2&gt;
&lt;p&gt;With the Bearer Token generated, subsequent API requests can now be authenticated by passing this token as the "Authorization" Header parameter.&lt;/p&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/73a267ca07894182883fda04000e5583.aspx" /&gt;&lt;/p&gt;
&lt;h2&gt;Content Definitions API&lt;/h2&gt;
&lt;p&gt;&lt;span&gt;Now armed with the bearer token, we can interact with the Content and Definitions API. The first API we explore is the Content Definitions API. Detailed API reference can be found &lt;/span&gt;&lt;a href="https://docs.developers.optimizely.com/content-management-system/v1.0.0-SaaS-Core/reference/contenttypes_list"&gt;here&lt;/a&gt;&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;h3&gt;Get Content Types&lt;/h3&gt;
&lt;p&gt;&lt;span&gt;A simple GET request to the following URL provides a list of all content types within the CMS:&lt;/span&gt;&lt;/p&gt;
&lt;pre class="language-markup"&gt;&lt;code&gt;https://app-xxxprod.cms.optimizely.com/_cms/v0.5/contenttypes&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example:&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/c9015e217b714b16ba0c466475e46fa2.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;To only get the details of a certain known type we pass in the definition name (key) to the URL:&lt;/p&gt;
&lt;pre class="language-markup"&gt;&lt;code&gt;https://app- xxxprod.cms.optimizely.com/_cms/v0.5/contenttypes/articlepage&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Create Content Type&lt;/h3&gt;
&lt;p&gt;To create a content type, a POST request is made to the same URL, passing in the necessary parameters.&lt;/p&gt;
&lt;p&gt;Example:&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/593612ffa8464867aa73b1f11a9f202f.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/1c718e89bd7144c696bb0078b05f4c24.aspx" /&gt;&lt;/p&gt;
&lt;h2&gt;Content {Delivery} API&lt;/h2&gt;
&lt;p&gt;The API reference to the Content API can be found here : &lt;a href="https://docs.developers.optimizely.com/content-management-system/v1.0.0-SaaS-Core/reference/content_create"&gt;Create content (optimizely.com)&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Get Content&lt;/h3&gt;
&lt;p&gt;To retrieve a content item, a GET request is made to the designated URL, using the Guid of the page as the key. The key is in a UUID format so should not include any dashes e.g. 115988243510434482925671c3ee601a&lt;/p&gt;
&lt;pre class="language-markup"&gt;&lt;code&gt;https://app- xxxprod.cms.optimizely.com/_cms/v0.5/content/{key}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example:&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/418614fbd0f245c4b586f1b1ca1ea884.aspx" /&gt;&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;As you can see its very easy to interact with the API&amp;rsquo;s and retrieve the relevant information you may need, as well as programmatically being able to create Content Models and Instances of these models. Its great to see this has all been included from the get go and provides a lot of scope to decide on how we manage the content definition creation process.&lt;/p&gt;</summary></entry><entry><title>A Quick Guide to Using the Content Management API and OpenID Connect Authentication</title><link href="https://world.optimizely.com/blogs/Minesh-Shah/Dates/2023/11/a-quick-guide-to-using-the-content-management-api-and-openid-connect-authentication/" /><id>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2023/11/a-quick-guide-to-using-the-content-management-api-and-openid-connect-authentication/</id><published>2023-11-01T22:44:53.0000000Z</published><updated>2023-11-01T22:44:53.0000000Z</updated><author><name>minesh.shah@netcel.com</name></author><category term="Blog post" /><summary type="html">&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;Content management in Optimizely CMS becomes more efficient and streamlined with the power of the &lt;strong&gt;Content Management API&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;strong&gt;Optimizely Content Management API&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/strong&gt;adds REST endpoints for basic content management operations such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Create content objects&lt;/li&gt;
&lt;li&gt;Modify existing content objects&lt;/li&gt;
&lt;li&gt;Delete content objects&lt;/li&gt;
&lt;li&gt;Get draft content objects&lt;/li&gt;
&lt;li&gt;Move content objects&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The REST API is useful for pushing external content to Optimizely without having to deploy custom integration code to the Optimizely Content Management System (CMS).&lt;/p&gt;
&lt;p&gt;In this quick guide, I'll walk through the steps to set up a solution for content creation using the API.&lt;/p&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;Before diving into the process, ensure you have the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An active Optimizely CMS instance (For Alloy site setup, refer to my guide &lt;a href="/link/9eaccb8259bf485296a54407dc071768.aspx"&gt;here&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Familiarity with an API request tool like Postman.&lt;/li&gt;
&lt;li&gt;A good understanding of your CMS's content structure and types.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Step 1: Install and Configure the Content Management API&lt;/h2&gt;
&lt;p&gt;&lt;span&gt;First, let's set up the Content Management API. If you're using Visual Studio, you can install it via the Package Manager or via the CLI using this command:&lt;/span&gt;&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;dotnet add package EPiServer.ContentManagementApi&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;Once installed, modify the Startup Class to include the basic configuration. In the &lt;/span&gt;&lt;code&gt;ConfigureServices&lt;/code&gt;&lt;span&gt; method, add the following line:&lt;/span&gt;&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;services.AddContentManagementApi(o =&amp;gt; o.DisableScopeValidation = true);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then, build and run your solution with:&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;dotnet run&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 2: Test the Endpoint&lt;/h2&gt;
&lt;p&gt;&lt;span&gt;After installing the Content Management API, you can test the endpoint using Postman. Send a simple POST request to &lt;/span&gt;&lt;code&gt;/api/episerver/v3.0/contentmanagement&lt;/code&gt;&lt;span&gt;. As we haven't added any parameters, expect a 400 Bad Request status code with a specific JSON response.&lt;/span&gt;&lt;/p&gt;
&lt;pre class="language-markup"&gt;&lt;code&gt;{
    "errors": {
        "filename": [
            "The filename field is required."
        ],
        "content-Type": [
            "The contentType field is required."
        ]
    },
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-b2f4877cb70b04fc20ea6fc0dfe1d2fe-d37b0cf2e4dc9458-00",
    "code": "InvalidModel"
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;This response tells us the endpoint is operational. Alternatively, it would have resulted in a 404 status code. At this stage, we can try adding content to Optimizely CMS via Postman. Use the provided JSON body, but you'll likely encounter a 401 status due to permissions.&lt;/span&gt;&lt;/p&gt;
&lt;pre class="language-markup"&gt;&lt;code&gt;{
  "name": "Text Block (Content Management API)",
  "language": {
      "name": "en"
  },
  "contentType": [
      "Block",
      "EditorialBlock"
  ],
  "parentLink": {
      "id": "33"
  },
  "mainBody": {
      "value": "&amp;lt;p&amp;gt;Hello World&amp;lt;/p&amp;gt;"
  },
  "status": "Published"
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Response Example&lt;/p&gt;
&lt;pre class="language-markup"&gt;&lt;code&gt;{
    "type": "https://tools.ietf.org/html/rfc7235#section-3.1",
    "title": "Unauthorized",
    "status": 401,
    "detail": "Access was denied to content 33. The required access level was \"Create, Publish\".",
    "instance": "/api/episerver/v3.0/contentmanagement",
    "traceId": "00-c91883ef32f39b8dda0571df62c44b7b-d48fe0471f6254da-00"
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;For testing purposes, you can temporarily grant access rights to "Everyone" for creating and publishing pages. After making this change, re-run the request. You should now receive a 201 (Success) status code with the content successfully created in CMS.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;&lt;img src="/link/17191f74030b45d2890b9a429189d49c.aspx" /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;&lt;img src="/link/6bec4a590ea3429eab25c352fb109049.aspx" /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;Step 3: API Authentication via OpenID Connect&lt;/h2&gt;
&lt;p&gt;In the real world, giving "Everyone" access to publish content is not acceptable. To ensure authorized individuals/services create and publish content, we need to authenticate Content Management API requests using OpenID Connect and Bearer Tokens (JWT). Here's how:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Install the OpenIDConnect NuGet package with this command:
&lt;pre class="language-csharp"&gt;&lt;code&gt;dotnet add package EPiServer.OpenIDConnect.UI&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;span&gt;Amend the Startup class to configure this package and make Content Management API support it. Update the code as follows:&lt;/span&gt;&lt;/span&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;services.AddContentManagementApi(OpenIDConnectOptionsDefaults.AuthenticationScheme, options =&amp;gt;
{
    options.DisableScopeValidation = true;
});

services.AddOpenIDConnect&amp;lt;ApplicationUser&amp;gt;(
    useDevelopmentCertificate: true,
    signingCertificate: null,
    encryptionCertificate: null,
    createSchema: true
);

services.AddOpenIDConnectUI();
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span&gt;After saving the file, build and run the solution and access Optimizely Admin mode. Look for "OpenID Connect" in Settings, create a new application (set Scopes as "epi_content_management").&lt;/span&gt;&lt;span&gt;&lt;img src="/link/4a8cd7fe899141e49f8d4b6e62c806ec.aspx" /&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;Test if it all works by making a POST request in Postman to &lt;code&gt;/api/episerver/connect/token&lt;/code&gt; with the following parameters:&lt;/span&gt;
&lt;ul&gt;
&lt;li&gt;client_id: api-client&lt;/li&gt;
&lt;li&gt;client_secret: SuperSecret&lt;/li&gt;
&lt;li&gt;grant_type: client_credentials&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span&gt;You'll receive a time-limited access token.&lt;/span&gt;&lt;span&gt;&lt;img src="/link/f2faa2d4556842c28c6ce3ae081acd34.aspx" /&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;span&gt;Having got this far and sucessfully being able to generate a bearer token, we have to grant our newly created client permissions to create and publish content, we do this by managing access rights in Optimizely. Remove additional rights given to the "Everyone" group and add them to the &lt;code&gt;api-client&lt;/code&gt; with the same rights instead.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;&lt;img src="/link/57750409614146909e5d1a7d5fb85875.aspx" /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;Finally, copy the access token from Postman and make another POST request to &lt;code&gt;/api/episerver/v3.0/contentmanagement&lt;/code&gt;, this time also setting the Authorization Token.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/f5558b9a21b44378aaf97fa3d41bc492.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/26638829b9a54d08b457425e8a539d74.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;That's it! We're now securely creating content via the Content Management API using a JWT Bearer Token.&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/58c2c1319e4e45d99bfaf0c15bf50040.aspx" /&gt;&lt;/p&gt;
&lt;p&gt;** For additional Security measures you can add some additional CORS policies to your Optimizely Solution so only certain services can call the Content Management API requests or even lock the API down via IP Restrictions.&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The Content Management API alongside Content Definitions API are quite powerful tools, they are not difficult to setup and have many use cases for example with the Content Management API we can quite easily utilise for migration processes and import a large amount of content into the CMS in bulk all programmatically. With the recent announcement of the SaaS CMS offering the Content Definitions API is quite useful in creating Content Types and something that can quite easily be source controlled, so definitely something to look out for and try-out.&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;References&amp;nbsp;&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/content-management-system/v1.6.0-content-management-api/docs"&gt;Get started with Content Management API (optimizely.com)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/content-management-system/v1.5.0-content-delivery-api/docs/api-authentication"&gt;API authentication (optimizely.com)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/content-management-system/v1.6.0-content-management-api/docs/api-fundamentals"&gt;Examples of Content Management API (optimizely.com)&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;</summary></entry></feed>