@zachariaz/strapi-plugin-content-variants
v0.1.0
Published
Strapi plugin for managing content variants for content personalization and A/B testing
Readme
@solteq/strapi-plugin-content-variants
Strapi v5 plugin for segment-based content personalization. Define audience segments, mark fields as variant-aware, and serve different content to different user groups -- all within Strapi's existing component system.
Current State
Working -- Verified in Browser
Phase 1: Segment Model -- plugin::content-variants.segment
A collection type storing audience segment definitions.
- Fields:
name(string, unique, required),slug(string, unique, required, auto-generated from name),description(text, optional),externalId(string, optional -- for future CDP integration) - No draft/publish -- segments are always active
- Server: CRUD service using
strapi.documents(), controller, admin-only routes atGET|POST /content-variants/segments,GET|PUT|DELETE /content-variants/segments/:id - Content API: Read-only
GET /api/content-variants/segmentsfor frontends
Files: server/src/content-types/segment/schema.json, server/src/services/segment.ts, server/src/controllers/segment.ts, server/src/routes/admin.ts, server/src/routes/content-api.ts
Phase 1: Segments Settings Page
Admin page under Settings > Global Settings > Content Variants (/admin/settings/content-variants).
- Table of all segments with Name, Slug, External ID, Description columns
- Edit and Delete action buttons per row
- "Add Segment" opens an inline form with auto-slug generation from name
- Delete shows a confirm dialog
Files: admin/src/pages/Settings/Segments.tsx, admin/src/hooks/useSegments.ts
Phase 2: CTB Content-Type-Level Toggle
"Enable content variants" checkbox in the Content-Type Builder when editing any content type's Advanced Settings.
- Stores
pluginOptions['content-variants'].enabled: truein the content type schema - Appears alongside "Draft & publish" and "Internationalization" checkboxes
Location: CTB > click content type > Edit > Advanced Settings tab
Phase 2: CTB Per-Field Variant Checkbox
"Enable variants for this field" checkbox in Advanced Settings of string, text, richtext, media, and blocks fields within components.
- Stores
pluginOptions['content-variants'].variant: trueon the field schema - Only appears for fields belonging to components (
forTarget === 'component'), since variants live inside dynamic zone components
Location: CTB > select component (e.g., Hero) > Edit field > Advanced Settings tab
Phase 3: Edit View Sparkle Indicators
Sparkle icon badge on variant-enabled fields in the Content Manager edit view, so editors can see at a glance which fields have per-segment values.
- Registered via
registerHook('Admin/CM/pages/EditView/mutate-edit-view-layout') - Adds a tooltip: "This field has per-segment variants"
- Only active when the content type has
pluginOptions['content-variants'].enabled: true - Verified working -- sparkle icons appear next to Title, Description, Image fields
Files: admin/src/contentManagerHooks/editView.tsx
Phase 3: Segment Picker Header Action
Dropdown in the Content Manager edit view header (next to locale picker) for switching between "Default" and segment-specific variant views.
- Shows "Default" option plus all defined segments
- Segments with existing variants show a checkmark; segments without show "(no variant)"
- Selecting a segment swaps variant-enabled field values in the form using
setValues() - Stores active segment in URL query params:
?plugins[content-variants][segment]=slug - Preserves default values when switching and writes back edits to the correct variant slot
- Depends on: components having a
variants[]repeatable component field
Files: admin/src/components/SegmentPickerAction.tsx
Phase 3: Variant Management Side Panel
"VARIANTS" panel in the edit view right sidebar (alongside ENTRY and PREVIEW).
- Lists all dynamic zone components that support variants
- Shows each variant with its segment assignments and priority
- "Add variant" button creates a new empty variant in the component's
variants[]array - Per-variant: assign/remove segments with priority number input
- Delete variant action
Files: admin/src/components/VariantPanel.tsx
Phase 3: List View Variant Count Column
"Variants" column in the Content Manager list view showing variant count per document.
- Registered via
registerHook('Admin/CM/pages/ListView/inject-column-in-table') - Shows a badge with the count, or "--" if no variants
- Only injected for content types with variants enabled
Files: admin/src/contentManagerHooks/listView.tsx
Phase 3: Variant Utilities
Core utility functions for admin-side variant detection and value manipulation.
isVariantEnabledContentType(),isVariantField(),getVariantFieldNames()hasVariantsField(),getDynamicZoneFields(),getVariantComponentUIDs()findVariantForSegment(),extractFieldValues(),buildSwappedFormValues()writeBackToVariant(),countVariants(),getSegmentSlugsWithVariants()
Files: admin/src/utils/variants.ts
Phase 4: Variant Resolver Service
Server-side service that resolves variant fields given a segment slug.
- Walks all dynamic zone components in a document
- Finds the variant matching the segment with highest priority (lowest number)
- Merges variant field values over the defaults
- Strips the
variants[]array from resolved output
Files: server/src/services/variant-resolver.ts
Phase 4: Document Service Middleware
Intercepts findMany/findOne Document Service operations for REST API segment filtering.
- When a
segmentparameter is present in the request, resolves variants server-side - Returns flat, resolved content (variant fields merged, variants array removed)
- Handles both single documents and paginated results
Files: server/src/bootstrap.ts
Variant Data Model
Variants are stored as Strapi components embedded inside the main component:
Hero (component in dynamic zone)
├── title (default value -- fallback, variant:true)
├── description (default value -- fallback, variant:true)
├── image (default media -- fallback, variant:true)
└── variants[] (repeatable component: zone.hero-variant)
├── title (variant-specific override)
├── description (variant-specific override)
├── image (variant-specific media)
└── segmentAssignments[] (repeatable: shared.segment-assignment)
├── segment (relation → plugin::content-variants.segment)
└── priority (integer, lower = higher priority)The plugin does not auto-generate these components. Developers create them following the naming convention, or use the "Scaffold Demo Content" feature (not yet built).
Required shared components (created in host project)
shared.segment-assignment (src/components/shared/segment-assignment.json):
{
"collectionName": "components_shared_segment_assignments",
"info": { "displayName": "Segment Assignment" },
"attributes": {
"segment": {
"type": "relation",
"relation": "oneToOne",
"target": "plugin::content-variants.segment"
},
"priority": {
"type": "integer",
"default": 0,
"min": 0
}
}
}zone.hero-variant (src/components/zone/hero-variant.json) -- mirrors variant-enabled fields + segmentAssignments:
{
"collectionName": "components_zone_hero_variants",
"info": { "displayName": "Hero Variant" },
"attributes": {
"Title": { "type": "string" },
"Description": { "type": "blocks" },
"Image": { "type": "media", "multiple": false },
"segmentAssignments": {
"type": "component",
"repeatable": true,
"component": "shared.segment-assignment"
}
}
}Then add variants field to the parent Hero component:
"variants": {
"type": "component",
"repeatable": true,
"component": "zone.hero-variant"
}Plugin Architecture
strapi-plugin-content-variants/
├── package.json
├── admin/
│ ├── custom.d.ts
│ ├── tsconfig.json
│ ├── tsconfig.build.json
│ └── src/
│ ├── index.tsx # register + bootstrap (CTB, CM, hooks)
│ ├── pluginId.ts
│ ├── components/
│ │ ├── Initializer.tsx
│ │ ├── SegmentPickerAction.tsx # Header dropdown for segment selection
│ │ └── VariantPanel.tsx # Side panel for variant management
│ ├── contentManagerHooks/
│ │ ├── editView.tsx # Variant field indicators (sparkle badge)
│ │ └── listView.tsx # Variant count column
│ ├── hooks/
│ │ └── useSegments.ts # Fetch/manage segments via admin API
│ ├── pages/
│ │ └── Settings/
│ │ └── Segments.tsx # Settings page: segment CRUD table + form
│ ├── translations/
│ │ └── en.json
│ └── utils/
│ └── variants.ts # Variant detection and value manipulation
└── server/
├── tsconfig.json
├── tsconfig.build.json
└── src/
├── index.ts # Exports all server modules
├── register.ts # Plugin register lifecycle
├── bootstrap.ts # Document Service middleware registration
├── destroy.ts # Plugin destroy lifecycle
├── config/
│ └── index.ts # Plugin config defaults
├── content-types/
│ ├── index.ts # Exports { segment }
│ └── segment/
│ └── schema.json # Segment model definition
├── controllers/
│ ├── index.ts # Exports { segment }
│ └── segment.ts # Segment CRUD controller
├── routes/
│ ├── index.ts # Exports { admin, 'content-api' }
│ ├── admin.ts # Admin-only CRUD routes for /segments
│ └── content-api.ts # Public read-only /segments route
└── services/
├── index.ts # Exports { segment, 'variant-resolver' }
├── segment.ts # Segment CRUD service with auto-slug
└── variant-resolver.ts # Resolve variants by segmentInstallation
Install the plugin in your Strapi v5 project:
npm install @zachariaz/strapi-plugin-content-variantsEnable it in config/plugins.ts (or .js):
export default {
'content-variants': {
enabled: true,
},
};Restart Strapi. The plugin registers its admin pages, Content-Type Builder extensions, and Content Manager hooks on boot.
Development
# Build plugin
npm run build # or: npx @strapi/pack-up build
# Watch mode
npm run watchREST API Usage
The plugin intercepts standard Strapi Content API calls via Document Service middleware. No special endpoints needed — just add query parameters to your existing API calls.
Authentication
Storefronts and frontends authenticate with a Strapi API Token (Bearer token). Create one at Settings > API Tokens (/admin/settings/api-tokens):
- Token type: Read-only
- Permissions: Enable
findandfindOnefor each content type the storefront needs (e.g.,hero-banner,campaign) plusfindforcontent-variantsplugin (segments endpoint)
All Content API calls require the Authorization header:
Authorization: Bearer <your-api-token>Query Parameters
| Parameter | Description |
|-----------|-------------|
| segment | Segment slug. Resolves variant fields server-side, returns flat merged content. |
| includeVariants | Set to true to return base content + _variants[] array with all variant data. |
| locale | Standard Strapi i18n locale (e.g., en, fi). |
| status | draft or published. Default: published. Use draft to see unpublished content. |
| populate | Standard Strapi populate (e.g., *, heroBanner, image). |
Important: Draft vs Published
By default, the Content API returns published content only. Variant resolution only works if both the base document and the variant documents have been published. If variant documents are draft-only, the published base content won't be resolved.
Use status=draft during development to test with unpublished content. In production, make sure to publish both base and variant documents.
API Test Calls (Postman / curl)
Below are example calls using the test data. Replace localhost:1337 with your Strapi host.
All calls require the Authorization: Bearer <token> header. In curl:
curl -H 'Authorization: Bearer <your-api-token>' 'http://localhost:1337/api/...'In Postman, set the Authorization type to "Bearer Token" and paste the token value.
1. List hero banners — base content (no segment)
Returns default field values without variant resolution.
GET http://localhost:1337/api/hero-banners?locale=en&status=draft&populate=*
Authorization: Bearer <token>Response (trimmed to first entry):
{
"data": [
{
"id": 1,
"documentId": "c0y1hk1245eats3bret72241",
"title": "Herobanner ",
"subtitle": "hero subtitle",
"ctaLabel": "Click me",
"ctaUrl": "#",
"locale": "en",
"image": null
}
],
"meta": { "pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 5 } }
}2. List hero banners — resolved for a segment
The segment parameter triggers server-side variant resolution. Variant-marked fields are replaced with segment-specific values.
GET http://localhost:1337/api/hero-banners?locale=en&status=draft&segment=new-members&populate=*
Authorization: Bearer <token>Response — note title changed from "Herobanner " to "Herobanner for new members":
{
"data": [
{
"id": 1,
"documentId": "c0y1hk1245eats3bret72241",
"title": "Herobanner for new members",
"subtitle": "hero subtitle",
"ctaLabel": "Click me",
"ctaUrl": "#",
"locale": "en",
"image": null
}
]
}3. Single hero banner — resolved for a different segment
GET http://localhost:1337/api/hero-banners/c0y1hk1245eats3bret72241?locale=en&status=draft&segment=club-members&populate=*
Authorization: Bearer <token>Response — title resolved for club-members segment:
{
"data": {
"id": 1,
"documentId": "c0y1hk1245eats3bret72241",
"title": "Herobanner for club",
"subtitle": "hero subtitle",
"ctaLabel": "Click me",
"ctaUrl": "#",
"locale": "en",
"image": null
}
}4. Single hero banner — enriched mode with all variants
The includeVariants=true parameter returns base content plus a _variants[] array listing every variant with its segment assignments and overridden field values.
GET http://localhost:1337/api/hero-banners/c0y1hk1245eats3bret72241?locale=en&status=draft&includeVariants=true&populate=*
Authorization: Bearer <token>Response:
{
"data": {
"id": 1,
"documentId": "c0y1hk1245eats3bret72241",
"title": "Herobanner ",
"subtitle": "hero subtitle",
"ctaLabel": "Click me",
"ctaUrl": "#",
"locale": "en",
"_variants": [
{
"documentId": "j55u6yrwiukbdbz50tsir7g1",
"segments": [
{ "name": "New members", "slug": "new-members" }
],
"fields": {
"title": "Herobanner for new members",
"subtitle": "hero subtitle",
"ctaLabel": "Click me"
}
},
{
"documentId": "q65aizvvuneke6lpnxpglsoj",
"segments": [
{ "name": "Club Members", "slug": "club-members" }
],
"fields": {
"title": "Herobanner for club",
"subtitle": "hero subtitle",
"ctaLabel": "Click me"
}
},
{
"documentId": "mkjtwxoyc509uedvsbh6uq5b",
"segments": [
{ "name": "Superbuyers", "slug": "superbyers" }
],
"fields": {
"title": "Herobanner superbyers",
"subtitle": "hero subtitle",
"ctaLabel": "Click me"
}
}
]
}
}5. Campaigns with relation — no segment
Campaigns have a heroBanner relation. Without segment, the related hero banner returns base (default) field values.
GET http://localhost:1337/api/campaigns?locale=en&status=draft&populate=heroBanner
Authorization: Bearer <token>Response:
{
"data": [
{
"id": 1,
"documentId": "nwqo1liifvnuz6iv5eb2riu1",
"title": "Summer campaign",
"slug": "summercampaign",
"description": "Description",
"locale": "en",
"heroBanner": {
"id": 1,
"documentId": "c0y1hk1245eats3bret72241",
"title": "Herobanner ",
"subtitle": "hero subtitle",
"ctaLabel": "Click me",
"ctaUrl": "#",
"locale": "en"
}
}
]
}6. Campaigns with relation — segment resolution propagates through relations
When segment is set, variant resolution propagates into populated relations. The hero banner's variant-marked fields are resolved for the segment.
GET http://localhost:1337/api/campaigns?locale=en&status=draft&segment=new-members&populate=heroBanner
Authorization: Bearer <token>Response — the heroBanner.title is now resolved for new-members:
{
"data": [
{
"id": 1,
"documentId": "nwqo1liifvnuz6iv5eb2riu1",
"title": "Summer campaign",
"slug": "summercampaign",
"description": "Description",
"locale": "en",
"heroBanner": {
"id": 1,
"documentId": "c0y1hk1245eats3bret72241",
"title": "Herobanner for new members",
"subtitle": "hero subtitle",
"ctaLabel": "Click me",
"ctaUrl": "#",
"locale": "en"
}
}
]
}7. List available segments
Returns all defined segments. The API token must have find permission for the content-variants plugin.
GET http://localhost:1337/api/content-variants/segments
Authorization: Bearer <token>Response:
[
{
"id": 2,
"documentId": "nlhcms3seykmet4bniz2z794",
"name": "New members",
"slug": "new-members",
"description": null,
"externalId": null
},
{
"id": 1,
"documentId": "qf7hz4xyuo2rlkr74hnfa20c",
"name": "Club Members",
"slug": "club-members",
"description": null,
"externalId": null
},
{
"id": 3,
"documentId": "iww0h4gt6z2wka0o92hg25co",
"name": "Superbuyers",
"slug": "superbyers",
"description": null,
"externalId": null
}
]Use the slug values from this response as the segment query parameter in content API calls.
How Variant Resolution Works
Without
segment: Returns the base document with default field values. No variant data is included unlessincludeVariants=trueis set.With
segment=slug: The Document Service middleware intercepts the response. For each variant-enabled content type, it finds the variant link matching the segment slug, fetches the variant document, and merges its variant-marked fields over the base document's fields. The response looks identical to a normal Strapi response — the frontend doesn't need to know about variants.With
includeVariants=true: Returns the base document as-is, plus a_variants[]array. Each entry contains the variant'sdocumentId,segments(name + slug), andfields(the overridden field values). Useful for client-side resolution or preview UIs.Relations: Segment resolution propagates through
populate. If a Campaign populates itsheroBannerrelation andsegment=new-membersis set, the heroBanner's fields are resolved for that segment too.
