Create, update, publish, and deliver Contentstack Personalize entry variants via the CMA and CDA APIs. Covers the correct variant payload format (_change_set, _order, _metadata), bulk publishing variants, fetching variant content with the Delivery SDK, and Next.js middleware integration with the Personalize Edge SDK. Use when creating A/B test or segmented experience variants for entries, publishing variant content, or wiring up Personalize delivery in Next.js.
This skill covers programmatic creation, publishing, and delivery of entry variants for Contentstack Personalize experiences (A/B Tests and Segmented).
Personalize Experience (e.g., "<Experience Name>")
└─ Variant Group (auto-created, linked to content types)
├─ Variant A (uid: cs...) ← "Group A"
└─ Variant B (uid: cs...) ← "Group B"
└─ Entry Variants (per entry, per variant)
| ID | Example |
|---|
| Where to find |
|---|
| Experience UID | <experience_uid> | Personalize → Experiences |
| Experience Short UID | 0 | Experience response .shortUid |
| Variant UID | <variant_uid_a> | CMA variant group or experience ._cms.variants |
| Variant Short UID | 0, 1 | Experience ._cms.variants keys |
| Variant Alias | cs_personalize_0_0 | Personalize.variantParamToVariantAliases("0_0") |
| Variant Param | 0_0 | {experienceShortUid}_{variantShortUid} |
| Variant Group UID | <variant_group_uid> | Experience ._cms.variantGroup |
| Block Metadata UID | <block_metadata_uid> | Base entry section ._metadata.uid |
# Get experience details (includes CMS variant mapping)
curl -s "https://api.contentstack.io/v3/content_types/{ct}/entries/{entry}" \
-H "api_key: {API_KEY}" \
-H "authorization: {MANAGEMENT_TOKEN}"
The experience response contains:
{
"_cms": {
"variantGroup": "<variant_group_uid>",
"variants": {
"0": "<variant_uid_a>",
"1": "<variant_uid_b>"
}
}
}
Before creating variants, read the base entry to capture each modular block's _metadata.uid:
curl -s "https://api.contentstack.io/v3/content_types/{ct}/entries/{entry_uid}" \
-H "api_key: {API_KEY}" \
-H "authorization: {MANAGEMENT_TOKEN}"
Each section block has a _metadata.uid:
{
"sections": [
{
"hero": {
"heading": "...",
"_metadata": { "uid": "<block_uid>" }
}
}
]
}
These UIDs are required for the variant _change_set and _order.
PUT https://api.contentstack.io/v3/content_types/{content_type_uid}/entries/{entry_uid}/variants/{variant_uid}
The payload has three critical parts:
_variant._change_set — Lists which fields differ from base. Format: sections.{block_type}.{block_metadata_uid}.{field_name}_variant._order — Defines section ordering. Format: base.{block_type}.{block_metadata_uid} or variant.{block_type}.{block_metadata_uid}sections[].{block}._metadata.uid — Each section must reference the base block's metadata UID{
"entry": {
"_variant": {
"_uid": "<variant_uid_a>",
"_change_set": [
"sections.hero.<hero_block_uid>.heading",
"sections.hero.<hero_block_uid>.description",
"sections.hero.<hero_block_uid>.cta_text",
"sections.cta.<cta_block_uid>.heading",
"sections.cta.<cta_block_uid>.description",
"sections.cta.<cta_block_uid>.cta_text"
],
"_order": [
{
"sections": [
"base.hero.<hero_block_uid>",
"base.featured.<block_2_uid>",
"base.categories.<block_3_uid>",
"base.steps.<block_4_uid>",
"base.cta.<cta_block_uid>"
]
}
]
},
"sections": [
{
"hero": {
"heading": "Your personalized heading here",
"description": "Your personalized description here.",
"cta_text": "Your CTA Label",
"cta_link": "/action",
"_metadata": { "uid": "<hero_block_uid>" }
}
},
{
"cta": {
"heading": "Your personalized CTA heading",
"description": "Your personalized CTA description.",
"cta_text": "Take Action",
"cta_link": "/action",
"_metadata": { "uid": "<cta_block_uid>" }
}
}
]
}
}
_change_set format: sections.{block_type}.{block_uid}.{field_name} — The API auto-normalizes field order but the block UID must be included_order is required — Without it, the variant won't render correctly in the UI or CDA_metadata.uid on each section — Must match the base entry's block UID so Contentstack knows which block is being overridden_variant._uid must match the variant UID in the URL path| Mistake | Symptom | Fix |
|---|---|---|
_change_set without block UID | Variant appears empty in UI | Use sections.hero.{uid}.heading not sections.hero.heading |
Missing _order | Variant content not rendered | Include full _order array with all base sections |
Missing _metadata.uid on sections | New block created instead of override | Add _metadata.uid matching the base block |
| Sending all sections | Unchanged sections marked as variant diffs | Only send sections with actual changes |
Variants must be published separately from the base entry.
curl -s -X POST "https://api.contentstack.io/v3/bulk/publish" \
-H "api_key: {API_KEY}" \
-H "authorization: {MANAGEMENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"entries": [
{
"uid": "{ENTRY_UID}",
"content_type": "{CONTENT_TYPE}",
"locale": "en-us",
"version": {BASE_ENTRY_VERSION},
"variant_uid": "{VARIANT_UID}"
}
],
"environments": ["production"],
"locales": ["en-us"]
}'
WARNING: The version field refers to the base entry version, NOT the variant version. Using the wrong value (e.g., 1 instead of the current base entry version) will overwrite the live base entry and can blank the page. Always check _version from GET /v3/content_types/{ct}/entries/{entry} first, or omit version to publish the latest.
mcp__contentstack__publish_variants_of_an_entry
content_type_uid: "<content_type>"
entry: "{ENTRY_UID}"
environment_uids: "{ENV_UID}"
locales: "en-us"
variant_ids: "{VARIANT_UID_A},{VARIANT_UID_B}"
Note: MCP publish may not always work for variants. Use the bulk publish API as a reliable fallback.
# Check variant publish details
curl -s "https://api.contentstack.io/v3/content_types/{ct}/entries/{entry}/variants/{variant_uid}" \
-H "api_key: {API_KEY}" \
-H "authorization: {MANAGEMENT_TOKEN}"
Check publish_details in the response — if null, the variant is not published.
curl -s "https://cdn.contentstack.io/v3/content_types/{ct}/entries?environment=production" \
-H "api_key: {API_KEY}" \
-H "access_token: {DELIVERY_TOKEN}" \
-H "x-cs-variant-uid: cs_personalize_0_0"
The x-cs-variant-uid header takes a variant alias (e.g., cs_personalize_0_0), not the variant UID.
import Personalize from "@contentstack/personalize-edge-sdk";
const variantParam = "0_0"; // from middleware
const variantAlias = Personalize.variantParamToVariantAliases(variantParam).join(",");
const result = await stack
.contentType("<content_type>")
.entry()
.variants(variantAlias)
.query()
.find();
Important: Call .variants() on entry() before .query(). The SDK sets the x-cs-variant-uid header internally.
middleware.ts)import { NextRequest, NextResponse } from "next/server";
import Personalize from "@contentstack/personalize-edge-sdk";
export const config = {
matcher: ["/((?!_next|api|favicon\\.ico|.*\\.(?:ico|png|jpg|jpeg|svg|gif|webp|css|js|woff2?|ttf|eot)$).*)"],
};
export default async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.includes("favicon") || pathname.includes(".")) {
return NextResponse.next();
}
const projectUid = process.env.NEXT_PUBLIC_CONTENTSTACK_PERSONALIZE_PROJECT_UID;
if (!projectUid) return NextResponse.next();
const edgeApiUrl = process.env.NEXT_PUBLIC_CONTENTSTACK_PERSONALIZE_EDGE_API_URL;
if (edgeApiUrl) Personalize.setEdgeApiUrl(edgeApiUrl);
try {
const personalizeSdk = await Personalize.init(projectUid, { request });
const variantParam = personalizeSdk.getVariantParam();
const url = request.nextUrl.clone();
url.searchParams.set(Personalize.VARIANT_QUERY_PARAM, variantParam);
const response = NextResponse.rewrite(url);
await personalizeSdk.addStateToResponse(response);
response.headers.set("cache-control", "no-store");
return response;
} catch (e) {
console.error("[Personalize] middleware error:", e);
return NextResponse.next();
}
}
?personalize_variants=0_0 (invisible to the user)cs-personalize-user-uid, cs-personalize-manifest) for visitor identitytry/catch ensures the page renders with base content if the Edge API is downFor triggering impressions and events on the client:
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import Personalize from "@contentstack/personalize-edge-sdk";
import type { Sdk } from "@contentstack/personalize-edge-sdk/dist/sdk";
const PersonalizeContext = createContext<Sdk | null>(null);
let sdkInstance: Sdk | null = null;
async function getPersonalizeInstance(): Promise<Sdk | null> {
const projectUid = process.env.NEXT_PUBLIC_CONTENTSTACK_PERSONALIZE_PROJECT_UID;
if (!projectUid) return null;
try {
if (!Personalize.getInitializationStatus()) {
sdkInstance = await Personalize.init(projectUid);
}
return sdkInstance;
} catch (e) {
console.warn("[Personalize] Client SDK init failed:", e);
return null;
}
}
export function PersonalizeProvider({ children }: { children: React.ReactNode }) {
const [sdk, setSdk] = useState<Sdk | null>(null);
useEffect(() => { getPersonalizeInstance().then(setSdk); }, []);
return (
<PersonalizeContext.Provider value={sdk}>
{children}
</PersonalizeContext.Provider>
);
}
export function usePersonalize() { return useContext(PersonalizeContext); }
Critical: The client-side Personalize.init() must be wrapped in try/catch. Without it, a missing env var or network error will throw Project not found: undefined, crash the React tree, and blank the entire page.
| Variable | Required | Where |
|---|---|---|
NEXT_PUBLIC_CONTENTSTACK_PERSONALIZE_PROJECT_UID | Yes | Server + Client (must rebuild after adding) |
NEXT_PUBLIC_CONTENTSTACK_PERSONALIZE_EDGE_API_URL | No | Override Edge API region |
| Region | URL |
|---|---|
| AWS NA (default) | https://personalize-edge.contentstack.com |
| AWS EU | https://eu-personalize-edge.contentstack.com |
| Azure NA | https://azure-na-personalize-edge.contentstack.com |
| Azure EU | https://azure-eu-personalize-edge.contentstack.com |
| GCP NA | https://gcp-na-personalize-edge.contentstack.com |
| AWS AU | https://au-personalize-edge.contentstack.com |
Complete sequence for creating an A/B test with variant content:
1. Create experience (Personalize MCP or UI)
→ experience_uid, variant UIDs from ._cms.variants
2. Read base entry to capture block _metadata.uid values
GET /content_types/{ct}/entries/{entry}
3. Create Variant A entry
PUT /content_types/{ct}/entries/{entry}/variants/{variant_a_uid}
Body: { entry: { _variant: { _change_set, _order }, sections: [...] } }
4. Create Variant B entry
PUT /content_types/{ct}/entries/{entry}/variants/{variant_b_uid}
5. Publish both variants
POST /bulk/publish with variant_uid for each
6. Verify on CDA
GET entries with x-cs-variant-uid header
The following steps require human intervention in the Contentstack UI, hosting platform, or Personalize dashboard. Inform the user of these before starting:
Create a Personalize project — In Contentstack Personalize, create a project and connect it to the stack. Note the project UID.
Create an experience with variants — In Personalize, create an A/B Test or Segmented experience. Define variant groups (e.g., Group A, Group B) and link them to the target content type. The variant UIDs are available in the experience's ._cms.variants response.
Create conversion events — In Personalize → Events, define the events you want to track (e.g., "Conversion"). Note the event key.
Add env vars to hosting platform — Add NEXT_PUBLIC_CONTENTSTACK_PERSONALIZE_PROJECT_UID to Contentstack Launch (or your hosting platform). This is a NEXT_PUBLIC_ var — it requires a full rebuild, not just a redeploy.
Verify variant content in Contentstack UI — After creating variants via API, check the entry in Contentstack UI to confirm variant data appears correctly under the Personalize tab. If _change_set or _order was malformed, the variant will appear empty.
Publish variants — Variants must be published separately from the base entry. While the bulk publish API can automate this, verify publish status in the UI if content isn't being delivered. Check publish_details in the variant CMA response — if null, it's not published.
Activate the experience — The experience must be set to Active in Personalize for variant delivery to work. Draft experiences won't trigger the Edge SDK.
publish_details is not nullx-cs-variant-uid: cs_personalize_{exp}_{var} (alias format, not raw UID)NEXT_PUBLIC_ env vars are inlined at build time — redeploy won't work, must rebuild_change_set: Must include block UID — sections.hero.{uid}.heading not sections.hero.heading_order: Required even if order matches base_metadata.uid: Each variant section block must reference the base block UIDPersonalize.init() throws if project UID is undefined — always wrap in try/catchNEXT_PUBLIC_CONTENTSTACK_PERSONALIZE_PROJECT_UID to Launch and rebuild