JSS Rule Engine — Website personalization in SSG mode

Jack Spektor
6 min readJun 3, 2024

--

Hi, last time I covered the details of the personalization implementation for the rule engine in SSR mode.

Today I’ll share the details on how the SSG mode was implemented.

As mentioned we managed to implement the SSR personalization previously based on Sitecore rules.

So why SSG is different?

One of the big advantages of SSG is speed.

SSG does build the pages statically on build time, unlike SSR which have to generate the response on every request.

This allows to speed up the response time for the site, especially on frequently accessed pages.

In the same time it brings us to the problem — how can we personalize something that was already rendered?

If we look at Sitecore we’ll find that every component personalization consists of variants. Every variant is based on the rule condition.

That means that if we want to pre-render the page in SSG mode — we need to pre-render all the personalization variants for the page.

But what if we have more than 1 personalized component on the page? In this case we’ll need to pre-render all variant combinations for the page.

Which brings us to the first limitation of the SSG personalization — the number of combinations would amount to 2 combinations where n is the number of rules in personalization.

This means that the more personalization rules are applied — the more combinations would be present and longer the build time would be.

So how can we pre-render the variants in JSS NextJS app?

First we’d like to identify all possible personalization variants on the page.

Unlike SSR, in SSG it’s only possible during the build process when the page is rendered.

That’s why we’ll implement custom sitemap pipeline which in JSS is used to fetch all the pages that need to be rendered during the build.

The sitemap plugin would replace and re-use the existing SSG sitemap pipeline logic.

protected async getTranformedPaths(
siteName: string,
languages: string[],
formatStaticPath: (path: string[], language: string, isStaticRender: boolean) => StaticPath
) {
const paths = new Array<StaticPath>();

for (const language of languages) {
if (language === '') {
throw new RangeError(languageEmptyError);
}

debug.sitemap('fetching sitemap data for %s %s', language, siteName);

const results = await this.fetchLanguageSitePaths(language, siteName);
const transformedPaths = await this.transformLanguageSitePaths(
results,
formatStaticPath,
language
);

paths.push(...transformedPaths);
}

return paths;
}

But would retrieve personalization for each page and would generate additional pages for variant combinations:


protected async transformLanguageSitePaths(
sitePaths: RouteListQueryResult[],
formatStaticPath: (path: string[], language: string, isStaticRender: boolean) => StaticPath,
language: string
): Promise<StaticPath[]> {

const formatPath = (path: string, isStaticRender: boolean) => {
return formatStaticPath(path.replace(/^\/|\/$/g, '').split('/'), language, isStaticRender);
}

const aggregatedPaths: StaticPath[] = [];

sitePaths.forEach((item) => {

if (!item) return;

aggregatedPaths.push(formatPath(item.path, item.route?.isStaticRender?.value == "1"));

// check for type safety's sake - personalize may be empty depending on query type
if (item.route?.personalizationRule?.value?.length &&
item.route?.personalizeOnEdge?.value == "1")
{
const ruleEngineInstance = getRuleEngineInstance();
const ruleEngineContext = ruleEngineInstance.getRuleEngineContext();
const parsedRule = ruleEngineInstance.parseRuleXml(item.route?.personalizationRule?.value, ruleEngineContext);
const personalizationVariationIds = getScPersonalizedVariantIds(parsedRule);
if(personalizationVariationIds)
{
aggregatedPaths.push(
...(personalizationVariationIds.map((varId:string) =>
formatPath(getScPersonalizedRewrite(item.path, varId), item?.route?.isStaticRender?.value == "1")
) || {})
);
}
}
});

return aggregatedPaths;
}

As a result by the end of build we’ll get the following page paths generated:

In this example _scvariant10symbolizes that the path _site_sxastarter has 2 rule combinations, with first rule condition being true, and the second being false.

Now when the build stage of SSG is completed, let’s look at the render stage of SSG

During the render stage we’d like to apply the personalization to the page based on the current personalization variant.

Based on the path _scvariant01 we can identify the rules execution result that needs to be rendered for this variant combination.

And then we can apply the appropriate personalization action based on the personalization setup.



async exec(props: any, context: GetServerSidePropsContext | GetStaticPropsContext) {
var doRun =
!this.isServerSidePropsContext(context) &&
!context.preview &&
!this.isDisconnectedMode(props) &&
!this.isPageEditing(props);

if (!doRun) {
return props;
}

if(props.layoutData.sitecore.route &&
props.layoutData.sitecore.route.fields)
{

var routeFields = props.layoutData.sitecore.route.fields;
var personalizationRule = routeFields["PersonalizationRules"];
var personalizeOnEdge = routeFields["PersonalizeOnEdge"];

let staticPropsContext = context as GetStaticPropsContext;

if(personalizationRule && staticPropsContext && personalizeOnEdge && personalizeOnEdge.value == "1")
{

console.log('### CONTEXT:');
console.log('Active variant id:', props.activeVariantId);

let activeVariantId = props.activeVariantId;

if(activeVariantId)
{
console.log('Extracting rule actions')
var ruleActions = this.extractRuleActions(activeVariantId);
var personalizationHelper = new PersonalizationHelper(this.graphQLEndpoint, this.sitecoreApiKey);
await personalizationHelper.runRuleActions(this.ruleEngine, props, personalizationRule, ruleActions);
}
}
}

return props;
}

To teach NextJS to resolve the right personalized path when URL is requested we’ll implement the custom middleware.

The middleware would retrieve the personalization for the page using GraphQL, and then would run the personalization rule for the page.

Now based on rule execution result we’ll know the rule-combination that needs to be resolved for the request and we’d return the appropriate pre-rendered path in the response.


/**
* Get personalize information for a route
* @param {string} itemPath page route
* @param {string} language language
* @param {string} siteName site name
* @returns {Promise<PersonalizeInfo | undefined>} the personalize information or undefined (if itemPath / language not found)
*/
async getPersonalizeInfo(
itemPath: string,
language: string,
siteName: string
): Promise<PersonalizeInfo | undefined> {

debug.personalize('fetching personalize info for %s %s %s', siteName, itemPath, language);
console.log('fetching personalize info for %s %s %s', siteName, itemPath, language);

const cacheKey = this.getCacheKey(itemPath, language, siteName);
let data = this.cache.getCacheValue(cacheKey);

if (!data) {
try {
//console.log('personalize info cache is empty - making graphQL call.', this.query);
data = await this.graphQLClient.request<PersonalizeQueryResult>(this.query, {
siteName,
itemPath,
language,
});
this.cache.setCacheValue(cacheKey, data);
} catch (error) {
if (isTimeoutError(error)) {
return undefined;
}

throw error;
}
} else {
//console.log('Found personalize info in the cache - ', data);
}

let variantIds: any = null;
let activeVariantid: any = null;

const personalizeOnEdge = data.layout?.item?.personalizeOnEdge?.value;

if (personalizeOnEdge == "1") {
console.log("Personalizing on edge", personalizeOnEdge)

const ruleEngineInstance: any = new JssRuleEngine();

if (ruleEngineInstance) {
console.log('Registering NextJS commands for rule engine')
registerNextJS(ruleEngineInstance);

const ruleXml = data.layout?.item?.personalizationRule?.value;

//console.log("Rule xml", ruleXml, ruleXml.replace)

if (this.personalizeContext) {
console.log('Current url - ', this.personalizeContext.url);
ruleEngineInstance.setRequestContext({
url: this.personalizeContext.url
});
}

const ruleEngineContext = ruleEngineInstance.getRuleEngineContext();

console.log('Parsing rule');

ruleEngineInstance.parseAndRunRule(ruleXml, ruleEngineContext);

if (ruleEngineContext.ruleExecutionResult &&
ruleEngineContext.ruleExecutionResult.parsedRule) {
console.log('Rule parsed - getting variant ids');
activeVariantid = this.getActiveVariantId(ruleEngineContext.ruleExecutionResult);
console.log('Active variant id - ', activeVariantid)
variantIds = getScPersonalizedVariantIds(ruleEngineContext.ruleExecutionResult.parsedRule);
console.log('Variant ids - ', variantIds)
}
}
} else {
console.log('Edge personalization is disabled for this item in Sitecore.')
}

const result: PersonalizeInfo = {
contentId: itemPath,
variantIds: variantIds,
activeVariantid: activeVariantid
}

console.log('=====')

return result;
}

protected getActiveVariantId(ruleExecutionResults: any): any {

if (!ruleExecutionResults?.ruleResults) {
return null;
}

const ruleResults = ruleExecutionResults.ruleResults;

let result = "";
let isAnyRuleTrue = false;
if (ruleResults && ruleResults.forEach) {
ruleResults.forEach((ruleRes: any) => {
result += ruleRes ? "1" : "0";
if(ruleRes) isAnyRuleTrue = true;
});
}

return isAnyRuleTrue ? result : null;
}

To summarize:

Personalization in SSG is much more complex to achieve, yet still possible, not only with OOTB tools like CDP/Personalize, but with custom personalization provider as well.

--

--

Jack Spektor

Sitecore MVP 2018–2020, Sitecore Developer in AKQA, traveler and lover of life.