Edge and Server Side Rendering
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:
- [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. - [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
- Github. 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
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
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}${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
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:
- 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 theuse client
directive. Or, - 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.