npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 at GET|POST /content-variants/segments, GET|PUT|DELETE /content-variants/segments/:id
  • Content API: Read-only GET /api/content-variants/segments for 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: true in 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: true on 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 segment parameter 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 segment

Installation

Install the plugin in your Strapi v5 project:

npm install @zachariaz/strapi-plugin-content-variants

Enable 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 watch

REST 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 find and findOne for each content type the storefront needs (e.g., hero-banner, campaign) plus find for content-variants plugin (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

  1. Without segment: Returns the base document with default field values. No variant data is included unless includeVariants=true is set.

  2. 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.

  3. With includeVariants=true: Returns the base document as-is, plus a _variants[] array. Each entry contains the variant's documentId, segments (name + slug), and fields (the overridden field values). Useful for client-side resolution or preview UIs.

  4. Relations: Segment resolution propagates through populate. If a Campaign populates its heroBanner relation and segment=new-members is set, the heroBanner's fields are resolved for that segment too.