Edge and Server Side Rendering

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

The easiest way to get started with Ninetailed is to use our client-side SDKs to select and render the applicable personalization or experiment variants. This ensures that a user's application state is updated in response to submitted Experience API events as soon as possible.

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, or to exclusively render Nientailed variation content on a server.

Methodology

Ninetailed returns an up-to-date representation of a profile and a list of assigned Experiences 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.

You can then pass the list of API-assigned experiences to your route functions. If desired, the stringified list can serve as a cache key so that subsequent visitors can be performantly served the same combination of Experience content.

Depending on whether you want Ninetailed to then also re-render client-side in response to Ninetailed events sent client-side, you can then either:

  1. [Client re-rendering] Use the loadingComponent prop of the <Experience> component to server-render the appropriate 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.

  2. [No client re-rendering] Completely Server- or edge-side render the chosen Experience content in a server-based, non-stateful <Experience> component.

Full ESR Example

Check out the live example of SSR with React Server components and Vercel Middleware: Deployment: https://examples-marekting-contentful-next-app-all-in.vercel.app/ Github: https://github.com/ninetailed-inc/ninetailed-examples/tree/main/marketing-contentful-next-app-all-in. This example is "all in" on ESR/SSR, meaning no client-side re-rendering is performed and each page is generated dynamically.

This example shows how to retrieve a list of assigned Experiences from Vercel Edge Middleware (though any edge function provider can be used) and pass that information to a Next.js route function.

Overview

Some utility functions are used to build the page event and send the request to the Ninetailed Experience API. These are used in the edge middleware, which also extracts the list of experiences our of the Experience API response and passes them in the URL. The Next.js page function then reads this hash to render the right content in child <Experience> components.

Utility Functions

middlewareFunctions
import {
  buildPageEvent,
  GeoLocation,
  NinetailedRequestContext,
  NinetailedApiClient,
  NINETAILED_ANONYMOUS_ID_COOKIE,
  SelectedVariantInfo,
} from '@ninetailed/experience.js-shared';
import { RequestCookies } from 'next/dist/compiled/@edge-runtime/cookies';
import { v4 as uuid } from 'uuid';

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

export type SelectedVariant = { experienceId: string; variantIndex: number };

export const createRequestContext = (
  request: Request
): NinetailedRequestContext => {
  return {
    url: request.url,
    locale: 'en-US', // One would make this dynamic if handling multiple locales
    referrer: request.headers.get('referer') || '',
    userAgent: request.headers.get('user-agent') || '',
  };
};

export const sendPageEvent = async ({
  clientId,
  environment,
  ctx,
  cookies,
  url,
  ip,
  location,
}: SendPagePayload) => {
  const apiClient = new NinetailedApiClient({ clientId, environment, url });
  const ninetailedId = cookies.get(NINETAILED_ANONYMOUS_ID_COOKIE)?.value;

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

  return apiClient.upsertProfile(
    {
      profileId: ninetailedId,
      events: [{ ...pageEvent, context: { ...pageEvent.context, location } }],
    },
    { ip }
  );
};

export const encodeExperienceSelections = (
  selections: SelectedVariantInfo[]
): string => {
  return selections
    .map((selection) => {
      return {
        experienceId: selection.experienceId,
        variantIndex: selection.variantIndex,
      };
    })
    .map((selection) => {
      return `${selection.experienceId}=${selection.variantIndex}`;
    })
    .sort()
    .join(',');
};

export const decodeExperienceSelections = (
  encodedExperienceVariantsMap: string
): Record<string, number> => {
  return encodedExperienceVariantsMap
    .split(encodeURIComponent(','))
    .map((experienceIdWithVariant) => {
      const [experienceId, _variantIndex] = experienceIdWithVariant.split(
        encodeURIComponent('=')
      );

      const variantIndex = parseInt(_variantIndex);

      if (!experienceId || !variantIndex) {
        return null;
      }

      return { experienceId, variantIndex };
    })
    .filter((x): x is SelectedVariant => !!x)
    .reduce(
      (acc, curr) => ({ ...acc, [curr.experienceId]: curr.variantIndex }),
      {}
    );
};

Middleware

middleware
import { NextRequest, NextResponse } from 'next/server';
import { ipAddress } from '@vercel/edge';
import { NINETAILED_ANONYMOUS_ID_COOKIE } from '@ninetailed/experience.js-shared';
import {
  createRequestContext,
  encodeExperienceSelections,
  sendPageEvent,
} from './lib/middlewareFunctions';
import { getContinentCode } from './lib/geolocation';
import { EDGE_URL_DELIMITER } from './lib/constants'; // Our Github example uses a ";' character

export const config = {
  matcher: [
    {
      source: '/((?!api|_next/static|_next/image|favicon).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
};

export default async function middleware(req: NextRequest) {
  const { profile, experiences } = await sendPageEvent({
    ctx: createRequestContext(req),
    clientId: process.env.NEXT_PUBLIC_NINETAILED_CLIENT_ID || '',
    environment: process.env.NEXT_PUBLIC_NINETAILED_ENVIRONMENT || '',
    cookies: req.cookies,
    ip: ipAddress(req),
    location: {
      city: req.geo?.city,
      region: req.geo?.region,
      country: req.geo?.country,
      continent: getContinentCode(req.geo?.country),
    },
  });

  const experienceSelections = encodeExperienceSelections(experiences);

  // Create a rewrite
  const url = req.nextUrl.clone();
  url.pathname = `/${EDGE_URL_DELIMITER}${experienceSelections}${profile.id}${url.pathname}`;
  url.pathname = url.pathname.replace(/\/$/, ''); // Remove any trailing slash
  const res = NextResponse.rewrite(url);
  res.cookies.set(NINETAILED_ANONYMOUS_ID_COOKIE, profile.id);

  return res;
}

Page

app/[[...slug]]/page.ts
import { draftMode } from 'next/headers';

import get from 'lodash/get';

import { BlockRenderer } from '@/components/Renderer';
import { getPages, getPage, getGlobalConfig } from '@/lib/api';

import { setExperiences } from '@/lib/ninetailedServerContext';
import { EDGE_URL_DELIMITER } from '@/lib/constants'; // Set as ";" in our example
import { decodeExperienceSelections } from '@/lib/middlewareFunctions';

export default async function Page({
  params,
}: {
  params: { slug: string[] | undefined };
}) {
  const edgeDelimiter = encodeURIComponent(EDGE_URL_DELIMITER);
  const rawSlug = get(params, 'slug', []) as string[];
  const selectedExperiencesSlug = rawSlug[0] || '';
  const computedEdgeProfile = selectedExperiencesSlug.startsWith(edgeDelimiter); // This will be false in contexts where Edge Middleware is not running
  const selectedExperiences = computedEdgeProfile
    ? decodeExperienceSelections(
        selectedExperiencesSlug.split(edgeDelimiter)[1]
      )
    : null;
    
  const pagePath = computedEdgeProfile
    ? rawSlug.slice(1).join('/')
    : rawSlug.join('/');
    
  // Get the rest of your page and return data based on selectedExperiences
  const { isEnabled } = draftMode();
  const [page, config] = await Promise.all([
    getPage({
      preview: isEnabled,
      slug: pagePath,
    }),
    getGlobalConfig({ preview: isEnabled }),
  ]);

  if (!page) {
    return null;
  }

  const { sections = [] } = page.fields;
  const { banner, navigation, footer } = config.fields;

  return (
    <>
      <div className="w-full h-full flex flex-col">
        {banner && <BlockRenderer block={banner} />}
        {navigation && <BlockRenderer block={navigation} />}
        <main className="grow">
          <BlockRenderer block={sections} />
        </main>
        {footer && <BlockRenderer block={footer} />}
      </div>
    </>
  );
}

Experiences

Finally, when rendering Experiences you would decide whether you want to either:

  1. use the default <Experience> component exported by our React SDKs. In an implementation containing RSCs, you'd want to re-export the default exported Ninetailed <Experience> component to opt-in to the use client directive. Or,

  2. write an <Experience> component that assumes no client-side implementation, like our example, if you want to server-side render and not update content client-side in response to user actions.

Last updated