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:
| Ninetailed | Croct | Notes |
|---|---|---|
| Audience | Audience | Real-time evaluation |
| Experience | Experience | Same concept: an audience plus the content it receives |
| Baseline | Slot default content | The baseline can be your existing CMS content |
| Variant | Variant | Same concept |
| Experiment | Experiment | Bayesian analysis on unsampled data |
| Personalize / Experience component | Slot | The component fetches from a slot rather than wrapping variants |
| Profile and traits | User profile and attributes | Built in, no external CDP required |
| identify | Identification | Same purpose |
| Track call | GoalCompleted and other events | Richer 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.
Most Ninetailed implementations used a fraction of the capability. Before writing any code, list your old experiences, the audience behind each one, and the elements each one changes. Teams routinely find that a dozen configured experiences come down to three that actually make sense right now.
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:
npx croct@latest initThe 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:
// 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:
Add the slot to your project:
npmnpx croct@latest add slotThe CLI generates the type definitions and downloads the default content for use as an automatic fallback.
Fetch the content where the component renders, passing your CMS content as the fallback:
components/HomeHero.jsx123456789export 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.jsx123456789101112export 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.
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:
- Preview each variant
Use preview mode to render the page as a member of each audience before publishing.
- 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.
- 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
| Symptom | Likely cause |
|---|---|
| All visitors see the same content | The route is statically generated. Confirm that the component calls fetchContent and renders dynamically. See Prerendering. |
| Content flashes before updating | The slot is rendering on the client. Move it to a server-side fetch. |
| Personalization does not match the audience | The CQL condition does not evaluate as expected. Test it in isolation with evaluate. |
| A returning visitor is treated as new | The client ID is not persisting. Check that a proxy or CDN is not stripping the cookies. |
| Experiences differ across devices | Expected for anonymous visitors. Identify the user to link their profiles. |
| Fallback content appears in production | Content requests are failing. Check the application credentials and the timeout option. |