Edge and Server Side Rendering
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.
By using the appropriate data from a request to end edge worker or server, you can build and send a page view attached to the profile sent along via cookie. Ninetailed returns an up-to-date representation of a profile whenever an event is sent to the Ninetailed Experience API, facilitated by using Ninetailed's SDKs.
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.
A schematic digram of Edge Side Rendering
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 Cloudlfare 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 modified 8d ago