Migrate from Ninetailed

Move your experiences to Croct without changing your CMS.

Contentful acquired Ninetailed in August 2024 and sunset the standalone app on March 26, 2026. The capability now lives only inside the Contentful web app, so teams running Ninetailed on Sanity, Storyblok, Contentstack, or any other CMS need a different personalization layer.

The migration from Ninetailed to Croct does not require replatforming. Your CMS stays where it is, and your existing content becomes the fallback for every slot.

In this tutorial, you will map Ninetailed’s concepts to ours, replace the Ninetailed components with slots, and rebuild your audiences, experiences, and experiments.

How the concepts map

Most of Ninetailed’s model transfers directly. The vocabulary is close enough that the mapping is usually the easiest part of the migration:

NinetailedCroctNotes
AudienceAudienceReal-time evaluation
ExperienceExperienceSame concept: an audience plus the content it receives
BaselineSlot default contentThe baseline can be your existing CMS content
VariantVariantSame concept
ExperimentExperimentBayesian analysis on unsampled data
Personalize / Experience componentSlotThe component fetches from a slot rather than wrapping variants
Profile and traitsUser profile and attributesBuilt in, no external CDP required
identifyIdentificationSame purpose
Track callGoalCompleted and other eventsRicher typed event catalog

The structural difference is where variants live. Ninetailed stored baseline and variant entries in your CMS and referenced audiences from them. We keep your CMS content as the fallback or default and resolve variants from our own content layer, so no audience references or variant entries are added to your schemas.

Before you start

You need:

  • A Croct account with a workspace and application set up.
  • Access to your CMS and your frontend repository.
  • A list of your old Ninetailed experiences, their audiences, and the pages they run on.

Migrate your experiences

The migration follows the same flow as any other experiment, with your Ninetailed inventory as the starting point.

Integrate the SDK

Run the CLI in your project:

Command to initialize your project
npx croct@latest init

The CLI detects your framework, installs the SDK, wires the provider, and generates the type definitions. This is additive: nothing changes on your site until you fetch a slot, so you can merge it safely before migrating any experience.

Replace Experience components with slots

This is the main code change. A Ninetailed integration wrapped a component and passed it a baseline plus a list of variants, each referencing an audience:

components/HomeHero.jsx
1234567
// Variants and audience references came from your CMS<Experience  {...baselineEntry} // Any props your `component` needs  id={baselineEntry.sys.id} // The content ID of the BASELINE entry  component={YourComponent} // What to use to render the selected variant  experiences={mappedExperiences} // Array of mapped experiences/>

With Croct, the component fetches from a slot and passes your CMS content as the fallback. For each area of the page you personalize, follow these steps:

  1. Create a component and a slot.

  2. Add the slot to your project:

    npx croct@latest add slot

    The CLI generates the type definitions and downloads the default content for use as an automatic fallback.

  3. Fetch the content where the component renders, passing your CMS content as the fallback:

    components/HomeHero.jsx
    123456789
    export default async function HomeHero() {  const cmsHero = await cms.getHero();
      const {content} = await fetchContent('home-hero', {    fallback: cmsHero,  });
      return <Hero {...content} />;}

    If you prefer to use Croct to serve only the dynamic content, while your CMS serves both the default and the fallback, check where the content came from before rendering it. When the metadata.contentSource value is slot, the request matched no experience or A/B test, so the result contains no dynamic content, and your CMS content should be rendered instead.

    components/HomeHero.jsx
    123456789101112
    export default async function HomeHero() {  const cmsHero = await cms.getHero();
      const {content, metadata} = await fetchContent('home-hero', {    fallback: cmsHero,  });
      // Render the Sanity content unless Croct returned dynamic content  const hero = metadata?.contentSource === 'slot' ? cmsHero : content;
      return <Hero {...hero} />;}

Three things follow from this shape:

  • Your CMS query is unchanged. No audience references, variant arrays, or segment parameters.
  • The fallback and, optionally, the default is your existing content. When no experience matches, or if a request fails, the page renders exactly what it renders today.
  • Resolution happens on the server, so the correct content is in the first HTML response. There is no client-side swap and no flicker.

Move identification and tracking

Ninetailed’s identify call becomes our identification feature and identify function. See the data collection guide for your SDK for details.

croct.identify(userId);

For conversions, track a goalCompleted event where the conversion happens:

croct.track('goalCompleted', {  goalId: 'sign-up',});

To fuel your experience dashboards, also track the leadGenerated and orderPlaced events.

Recreate your audiences

Ninetailed audiences are built from audience traits and behaviors in the audience builder, and Croct is no different. We evaluate audiences on the server against a live profile, so behavioral conditions reflect the current session rather than a value synced earlier.

Rebuild experiences and experiments

Create an experience for each audience, attach the slots it should affect, and write the content for each variant.

Croct's mascot neutral
Relaunch with experiments

Migrating is the cheapest opportunity you will ever have to check whether your personalization strategy was earning its keep. Rather than switching each experience back on and assuming it still works, run it against your default content as an experiment. Some experiences will turn out to have been neutral, and finding that out now costs nothing.

Review and publish

Before you let the experiments run, confirm that everything behaves as expected:

  1. Preview each variant

    Use preview mode to render the page as a member of each audience before publishing.

  2. Confirm that the content is in the HTML

    View the page source, or disable JavaScript, and check that the personalized content is in the initial response.

  3. Confirm that goals are firing

    Complete the conversion yourself and check that the event appears with the correct goal.

What does not carry over

Keep these differences in mind when planning the work:

  • Variant entries in your CMS become unused since we do not read them. Leave them in place during the migration, then clean up the audience references and variant entries once every experience is live on Croct.
  • Audience definitions are rewritten, not imported from Ninetailed. In practice, audience criteria are quick to write, and the inventory usually reveals fewer audiences than expected.
  • Merge tags and inline personalization tokens are configured differently. We resolve the whole content of a slot rather than substituting tokens in a string.

Troubleshooting

SymptomLikely cause
All visitors see the same contentThe route is statically generated. Confirm that the component calls fetchContent and renders dynamically. See Prerendering.
Content flashes before updatingThe slot is rendering on the client. Move it to a server-side fetch.
Personalization does not match the audienceThe CQL condition does not evaluate as expected. Test it in isolation with evaluate.
A returning visitor is treated as newThe client ID is not persisting. Check that a proxy or CDN is not stripping the cookies.
Experiences differ across devicesExpected for anonymous visitors. Identify the user to link their profiles.
Fallback content appears in productionContent requests are failing. Check the application credentials and the timeout option.