Edge and Server Side Rendering

Rendering Experiences on the edge with Ninetailed's Experience API.

ESR implementations are absolutely supported by Ninetailed and much simpler than this documentation details. This documentation details an approach from a time when the Experience API only returned a profile. Now, the Experience API also returns the list of experiences and variants that the profile should see. This end-to-end example documentation will be updated 🔜. In the mean time, see: Experience API selected experiences Working with the Shared SDK ESR Code Example

The easiest way to get started with Ninetailed is to use our SDKs client-side to select the applicable personalization or experiment variants. However, it is possible to retrieve Ninetailed profiles and select and render the appropriate experience variants in either server or edge contexts. This allows the first page requested by your visitors to show the appropriate content without having to wait for hydration to complete.

You can use Next.js to fetch personalized content from the server and serve a fully updated page via server side rendering.

Methodology

Ninetailed returns an up-to-date representation of a profile whenever an event is sent to the Ninetailed Experience API. By storing a Ninetailed anonymous ID in a cookie, an edge- or server-side function can make requests to update and receive the visitor's profile.

After receiving the profile edge- or server-side, you'd then use Ninetailed's SDK methods to select the appropriate combination of experiences and their variants. You can cache each unique combination so that subsequent visitors can be performantly served the same combination.

Use the loadingComponent prop of the <Experience> component to serve the selected experience and variant. Then, the rehydrated Ninetailed SDKs will take over again on client-side so that new experiences and variants can be rendered immediately in response to the visitor's changing audiences.

Full ESR Example

This example shows how to retrieve a profile, select the correct experiences and variants, and pass that information to the Next.js origin from a Cloudflare Worker edge function (though any edge function provider can be used).

Two utility functions are used to build the page event and send the request to the Ninetailed Experience API. The edge function then extracts the information out of the returned profile to select the appropriate experiences variants and passes them in the URL as a hash, which also serves as the cache key. getStaticProps then reads this hash to provide the correct props to the page. Finally, the <Experience> component uses an ESRLoadingComponent as the value of the loadingComponent prop to render the edge-selected experience and variant.

// utils
import { 
  buildPageEvent,
  GeoLocation,
  NinetailedApiClient,
  NinetailedRequestContext,
  Profile
} from '@ninetailed/experience.js-shared'
import { NINETAILED_ANONYMOUS_ID_COOKIE } from '@ninetailed/experience.js-plugin-ssr';

type GetServerSideProfileOptions = {
  ctx: NinetailedRequestContext;
  cookies: Cookies;
  clientId: string;
  environment?: string;
  url?: string;
  ip?: string;
  location?: GeoLocation;
};

export const fetchEdgeProfile = async ({
  ctx,
  cookies,
  clientId,
  environment,
  url,
  ip,
  location,
}: GetServerSideProfileOptions): Promise<Profile> => {
  const apiClient = new NinetailedApiClient({ clientId, environment, url });
  const anonymousId = cookies[NINETAILED_ANONYMOUS_ID_COOKIE];

  const pageEvent = buildPageEvent({
    ctx,
    messageId: uuid(),
    timestamp: Date.now(),
    properties: {},
  });

  const profile = await apiClient.upsertProfile(
    {
      profileId: anonymousId,
      events: [{ ...pageEvent, context: { ...pageEvent.context, location } }],
    },
    { ip, preflight: true }
  );

  return profile;
};

export const buildNinetailedEdgeRequestContext = (
  request: Request
): NinetailedRequestContext => {
  return {
    url: request.url,
    locale: getLocale(request),
    referrer: request.headers.get('referer') || '',
    userAgent: request.headers.get('user-agent') || '',
  };
};
// myEdgeFunction.ts

import {
  isExperienceMatch,
  selectActiveExperiments,
  selectEligibleExperiences,
} from '@ninetailed/experience.js';
import { NINETAILED_ANONYMOUS_ID_COOKIE } from '@ninetailed/experience.js-plugin-ssr';
// As above
import {
  buildNinetailedEdgeRequestContext,
  fetchEdgeProfile,
} from 'myUtils.ts'; 
import { ContentfulClient } from './contentful';
import { CachedFetcher, getVariantIndex } from './utils';

type Cookies = {
  [key: string]: string;
};

type Env = {
  NINETAILED_API_KEY: string;
  NINETAILED_ENVIRONMENT: string;

  CONTENTFUL_SPACE_ID: string;
  CONTENTFUL_ENVIRONMENT_ID: string;
  CONTENTFUL_ACCESS_TOKEN: string;
};

type VariantSelection = {
  experienceId: string;
  variantIndex: number;
};

const getCookies = (request: Request): Cookies => {
  const cookieStr = request.headers.get('Cookie');

  if (!cookieStr) {
    return {};
  }

  const cookieEntries = cookieStr.split(';').map((cookie) => {
    return cookie.trim().split('=');
  });
  const cookies: Cookies = Object.fromEntries(cookieEntries);
  return cookies;
};

const getIP = (request: Request): string => {
  const ip = request.headers.get('CF-Connecting-IP') || '';
  return ip;
};

export default {
  async fetch(
    request: Request,
    env: Env,
    context: ExecutionContext
  ): Promise<Response> {
    const acceptHeaders = request.headers.get('Accept') || '';
    if (!acceptHeaders.includes('text/html')) {
      return fetch(request);
    }

    const cachedFetcher = new CachedFetcher({
      context,
      defaultTtl: 5,
    });

    const contentfulClient = new ContentfulClient({
      cachedFetcher,
      spaceId: env.CONTENTFUL_SPACE_ID,
      environmentId: env.CONTENTFUL_ENVIRONMENT_ID,
      apiToken: env.CONTENTFUL_ACCESS_TOKEN,
    });

    const slug = new URL(request.url).pathname;

    const fetchProfileOptions = {
      ctx: buildNinetailedEdgeRequestContext({ req: request }),
      clientId: env.NINETAILED_API_KEY,
      environment: env.NINETAILED_ENVIRONMENT,
      cookies: getCookies(request),
      ip: getIP(request),
      location: {
        city: request.cf?.city,
        region: request.cf?.region,
        country: request.cf?.country,
        continent: request.cf?.continent,
      },
    };

    const [profile, allExperiments, experiencesOnPage] = await Promise.all([
      fetchEdgeProfile(fetchProfileOptions),
      contentfulClient.getAllExperiments(),
      contentfulClient.getExperiencesOnPage(
        slug === '/' ? '/' : slug.replace(/^\/+/, '')
      ),
    ]);

    const joinedExperiments = selectActiveExperiments(allExperiments, profile);

    const eligibleExperiences = selectEligibleExperiences({
      experiences: experiencesOnPage,
      activeExperiments: joinedExperiments,
    });

    const matchingExperiences = eligibleExperiences.filter((experience) => {
      return isExperienceMatch({
        experience,
        activeExperiments: joinedExperiments,
        profile,
      });
    });

    const matchingPersonalizations = matchingExperiences.filter(
      (experience) => {
        return experience.type === 'nt_personalization';
      }
    );

    const firstExperiment = matchingExperiences.find((experience) => {
      return experience.type === 'nt_experiment';
    });

    // Get variant index for each matching personalization + first experiment
    const variantSelections: VariantSelection[] = [
      ...matchingPersonalizations.map((experience) => {
        return {
          experienceId: experience.id,
          variantIndex: getVariantIndex(experience, profile),
        };
      }),

      ...(firstExperiment
        ? [
            {
              experienceId: firstExperiment.id,
              variantIndex: getVariantIndex(firstExperiment, profile),
            },
          ]
        : []),
    ];

    const newUrl = new URL(request.url);
    const variantsPath = variantSelections
      .map((selection) => {
        return `${selection.experienceId}=${selection.variantIndex}`;
      })
      .sort()
      .join(',');
    newUrl.pathname = `/;${variantsPath}${newUrl.pathname}`;
    // remove trailing slash
    newUrl.pathname = newUrl.pathname.replace(/\/$/, '');
    const newRequest = new Request(newUrl.href, request);

    console.log(newUrl.href);

    const response = await cachedFetcher.fetch(newRequest);
    const newResponse = new Response(response.body, response);

    newResponse.headers.append(
      'Set-Cookie',
      `${NINETAILED_ANONYMOUS_ID_COOKIE}=${profile.id}`
    );

    return newResponse;
  },
};
// [[...slug]].tsx

import {
  decodeExperienceVariantsMap,
} from '@ninetailed/experience.js-next';

...

export const getStaticProps: GetStaticProps = async ({ params, preview }) => {
  const rawSlug = get(params, 'slug', []) as string[];
  const experienceVariantsSlug = rawSlug[0] || '';
  const isPersonalized = experienceVariantsSlug.startsWith(';');
  const experienceVariantsMap = isPersonalized
    ? decodeExperienceVariantsMap(experienceVariantsSlug.split(';')[1])
    : {};
  const slug = isPersonalized ? rawSlug.slice(1).join('/') : rawSlug.join('/');
  // Function to retrieve data from CMS by slug
  const page = await getPage({
    preview,
    slug
  });

  return {
    props: { page, ninetailed: { experienceVariantsMap } },
    revalidate: 5,
  };
};
// components/ExperienceRenderer.tsx

import {
  ESRLoadingComponent,
  Experience,
} from '@ninetailed/experience.js-next';

import { myComponent } from '@/components/myComponent';

export function ExperienceRenderer(componentProps) {
  // Map experiences here as normal
  ...
  return (
    <Experience
      {...componentProps}
      id={componentProps.sys.id}
      component={myComponent}
      experiences={mappedExperiences}
      loadingComponent={ESRLoadingComponent}
    />
  );
}

Last updated