Ninetailed
Search
K

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 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.
Schematic diagram of Edge Side Rendering using Ninetailed
A schematic diagram of Edge Side Rendering

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.
1
// utils
2
import {
3
buildPageEvent,
4
GeoLocation,
5
NinetailedApiClient,
6
NinetailedRequestContext,
7
Profile
8
} from '@ninetailed/experience.js-shared'
9
import { NINETAILED_ANONYMOUS_ID_COOKIE } from '@ninetailed/experience.js-plugin-ssr';
10
11
type GetServerSideProfileOptions = {
12
ctx: NinetailedRequestContext;
13
cookies: Cookies;
14
clientId: string;
15
environment?: string;
16
url?: string;
17
ip?: string;
18
location?: GeoLocation;
19
};
20
21
export const fetchEdgeProfile = async ({
22
ctx,
23
cookies,
24
clientId,
25
environment,
26
url,
27
ip,
28
location,
29
}: GetServerSideProfileOptions): Promise<Profile> => {
30
const apiClient = new NinetailedApiClient({ clientId, environment, url });
31
const anonymousId = cookies[NINETAILED_ANONYMOUS_ID_COOKIE];
32
33
const pageEvent = buildPageEvent({
34
ctx,
35
messageId: uuid(),
36
timestamp: Date.now(),
37
properties: {},
38
});
39
40
const profile = await apiClient.upsertProfile(
41
{
42
profileId: anonymousId,
43
events: [{ ...pageEvent, context: { ...pageEvent.context, location } }],
44
},
45
{ ip, preflight: true }
46
);
47
48
return profile;
49
};
50
51
export const buildNinetailedEdgeRequestContext = (
52
request: Request
53
): NinetailedRequestContext => {
54
return {
55
url: request.url,
56
locale: getLocale(request),
57
referrer: request.headers.get('referer') || '',
58
userAgent: request.headers.get('user-agent') || '',
59
};
60
};
1
// myEdgeFunction.ts
2
3
import {
4
isExperienceMatch,
5
selectActiveExperiments,
6
selectEligibleExperiences,
7
} from '@ninetailed/experience.js';
8
import { NINETAILED_ANONYMOUS_ID_COOKIE } from '@ninetailed/experience.js-plugin-ssr';
9
// As above
10
import {
11
buildNinetailedEdgeRequestContext,
12
fetchEdgeProfile,
13
} from 'myUtils.ts';
14
import { ContentfulClient } from './contentful';
15
import { CachedFetcher, getVariantIndex } from './utils';
16
17
type Cookies = {
18
[key: string]: string;
19
};
20
21
type Env = {
22
NINETAILED_API_KEY: string;
23
NINETAILED_ENVIRONMENT: string;
24
25
CONTENTFUL_SPACE_ID: string;
26
CONTENTFUL_ENVIRONMENT_ID: string;
27
CONTENTFUL_ACCESS_TOKEN: string;
28
};
29
30
type VariantSelection = {
31
experienceId: string;
32
variantIndex: number;
33
};
34
35
const getCookies = (request: Request): Cookies => {
36
const cookieStr = request.headers.get('Cookie');
37
38
if (!cookieStr) {
39
return {};
40
}
41
42
const cookieEntries = cookieStr.split(';').map((cookie) => {
43
return cookie.trim().split('=');
44
});
45
const cookies: Cookies = Object.fromEntries(cookieEntries);
46
return cookies;
47
};
48
49
const getIP = (request: Request): string => {
50
const ip = request.headers.get('CF-Connecting-IP') || '';
51
return ip;
52
};
53
54
export default {
55
async fetch(
56
request: Request,
57
env: Env,
58
context: ExecutionContext
59
): Promise<Response> {
60
const acceptHeaders = request.headers.get('Accept') || '';
61
if (!acceptHeaders.includes('text/html')) {
62
return fetch(request);
63
}
64
65
const cachedFetcher = new CachedFetcher({
66
context,
67
defaultTtl: 5,
68
});
69
70
const contentfulClient = new ContentfulClient({
71
cachedFetcher,
72
spaceId: env.CONTENTFUL_SPACE_ID,
73
environmentId: env.CONTENTFUL_ENVIRONMENT_ID,
74
apiToken: env.CONTENTFUL_ACCESS_TOKEN,
75
});
76
77
const slug = new URL(request.url).pathname;
78
79
const fetchProfileOptions = {
80
ctx: buildNinetailedEdgeRequestContext({ req: request }),
81
clientId: env.NINETAILED_API_KEY,
82
environment: env.NINETAILED_ENVIRONMENT,
83
cookies: getCookies(request),
84
ip: getIP(request),
85
location: {
86
city: request.cf?.city,
87
region: request.cf?.region,
88
country: request.cf?.country,
89
continent: request.cf?.continent,
90
},
91
};
92
93
const [profile, allExperiments, experiencesOnPage] = await Promise.all([
94
fetchEdgeProfile(fetchProfileOptions),
95
contentfulClient.getAllExperiments(),
96
contentfulClient.getExperiencesOnPage(
97
slug === '/' ? '/' : slug.replace(/^\/+/, '')
98
),
99
]);
100
101
const joinedExperiments = selectActiveExperiments(allExperiments, profile);
102
103
const eligibleExperiences = selectEligibleExperiences({
104
experiences: experiencesOnPage,
105
activeExperiments: joinedExperiments,
106
});
107
108
const matchingExperiences = eligibleExperiences.filter((experience) => {
109
return isExperienceMatch({
110
experience,
111
activeExperiments: joinedExperiments,
112
profile,
113
});
114
});
115
116
const matchingPersonalizations = matchingExperiences.filter(
117
(experience) => {
118
return experience.type === 'nt_personalization';
119
}
120
);
121
122
const firstExperiment = matchingExperiences.find((experience) => {
123
return experience.type === 'nt_experiment';
124
});
125
126
// Get variant index for each matching personalization + first experiment
127
const variantSelections: VariantSelection[] = [
128
...matchingPersonalizations.map((experience) => {
129
return {
130
experienceId: experience.id,
131
variantIndex: getVariantIndex(experience, profile),
132
};
133
}),
134
135
...(firstExperiment
136
? [
137
{
138
experienceId: firstExperiment.id,
139
variantIndex: getVariantIndex(firstExperiment, profile),
140
},
141
]
142
: []),
143
];
144
145
const newUrl = new URL(request.url);
146
const variantsPath = variantSelections
147
.map((selection) => {
148
return `${selection.experienceId}=${selection.variantIndex}`;
149
})
150
.sort()
151
.join(',');
152
newUrl.pathname = `/;${variantsPath}${newUrl.pathname}`;
153
// remove trailing slash
154
newUrl.pathname = newUrl.pathname.replace(/\/$/, '');
155
const newRequest = new Request(newUrl.href, request);
156
157
console.log(newUrl.href);
158
159
const response = await cachedFetcher.fetch(newRequest);
160
const newResponse = new Response(response.body, response);
161
162
newResponse.headers.append(
163
'Set-Cookie',
164
`${NINETAILED_ANONYMOUS_ID_COOKIE}=${profile.id}`
165
);
166
167
return newResponse;
168
},
169
};
1
// [[...slug]].tsx
2
3
import {
4
decodeExperienceVariantsMap,
5
} from '@ninetailed/experience.js-next';
6
7
...
8
9
export const getStaticProps: GetStaticProps = async ({ params, preview }) => {
10
const rawSlug = get(params, 'slug', []) as string[];
11
const experienceVariantsSlug = rawSlug[0] || '';
12
const isPersonalized = experienceVariantsSlug.startsWith(';');
13
const experienceVariantsMap = isPersonalized
14
? decodeExperienceVariantsMap(experienceVariantsSlug.split(';')[1])
15
: {};
16
const slug = isPersonalized ? rawSlug.slice(1).join('/') : rawSlug.join('/');
17
// Function to retrieve data from CMS by slug
18
const page = await getPage({
19
preview,
20
slug
21
});
22
23
return {
24
props: { page, ninetailed: { experienceVariantsMap } },
25
revalidate: 5,
26
};
27
};
1
// components/ExperienceRenderer.tsx
2
3
import {
4
ESRLoadingComponent,
5
Experience,
6
} from '@ninetailed/experience.js-next';
7
8
import { myComponent } from '@/components/myComponent';
9
10
export function ExperienceRenderer(componentProps) {
11
// Map experiences here as normal
12
...
13
return (
14
<Experience
15
{...componentProps}
16
id={componentProps.sys.id}
17
component={myComponent}
18
experiences={mappedExperiences}
19
loadingComponent={ESRLoadingComponent}
20
/>
21
);
22
}