JSS Rule Engine — Website personalization on XM Cloud in FE and SSR mode.
Hello friends.
Last time I presented to you the JSS-Rule-Engine
package that allows us to re-use Sitecore rule engine on XM to run rules on FE.
Today i’m going to cover how we can use Sitecore rules engine to run personalization in JSS app both on FE and edge.
Motivation: In all fairness Sitecore JSS already provides OOTB with the option of Sitecore Personalize personalization on SSG and SSR modes. And there is probably no good reason why not to use this well-tested and already existing Sitecore-provided solution for JSS websites.
However with me writing custom JS Sitecore rule-engine, and having written before on the topic of SSG personalization in Sitecore JSS I’ve decided that Sitecore rule personalization would be the ideal case-study for the rule-engine.
Let’s start with the fact that Sitecore doesn’t expose the layout XML with personalization in GraphQL, so unfortunately we can’t use the OOTB Sitecore personalization dialogue.
This wouldn’t stop us though in our quest to achieve personalization with Sitecore rule engine in XM Cloud!
Behold 2 new personalization fields that we are going to add to SXA Page template:
This would work in a similar way Sitecore personalization is working by transforming the final renderings with some personalization rules.
We are going to add 3 new custom actions that would drive our personalization:
- Add rendering
- Hide rendering
- Set datasource
This should be enough to perform basic custom personalization scenarios.
But how are we going to make actual personalization?
For that we would reuse the existing solution we did previously for JSS SSG personalization.
We would create a custom PersonalizedPlaceholder
class that would perform evaluate the personalization rule on FE and apply the personalization transformation to the JSON layout
import React from 'react';
import { withSitecoreContext, Placeholder } from '@sitecore-jss/sitecore-jss-nextjs';
import { PersonalizationHelper } from "../lib/index";
//@ts-ignore
import {JssRuleEngine} from "sitecore-jss-rule-engine"
class PersonalizedPlaceholder extends React.Component<any,any> {
graphQLEndpoint:string;
sitecoreApiKey:string;
ruleEngine:JssRuleEngine;
constructor(props:any) {
super(props);
this.graphQLEndpoint = props.endpointUrl;
this.ruleEngine = props.ruleEngine;
this.sitecoreApiKey = props.sitecoreApiKey;
this.state = {
elements: null
};
}
private updatingState: boolean = false;
async componentDidMount() {
var personalizeOnEdge = this.props.rendering.fields["PersonalizeOnEdge"]
if(personalizeOnEdge && personalizeOnEdge.value)
{
return;
}
const personalizedRenderings = await this.personalizePlaceholder();
if (personalizedRenderings) {
console.log('Set personalized renderings');
this.updatingState = true;
this.setState({
elements: personalizedRenderings
});
}
}
shouldComponentUpdate() {
if (this.updatingState) {
this.updatingState = false;
return false;
}
return true;
}
render() {
const rendering = {
...this.props.rendering
};
rendering.placeholders[this.props.name] = this.state.elements ? this.state.elements :
this.props.hideInitialContents ? [] : rendering.placeholders[this.props.name];
const placeholderProps = {
...this.props,
rendering
}
return <Placeholder name={this.props.name} {...placeholderProps} />
}
isClientside() {
return typeof window !== 'undefined';
}
isDisconnectedMode() {
const disconnectedMode = this.props.sitecoreContext.site.name === 'JssDisconnectedLayoutService';
return disconnectedMode;
}
isPageEditing() {
const isEditing = this.props.sitecoreContext.pageEditing;
return isEditing;
}
async personalizePlaceholder() {
var doRun =
this.isClientside() &&
!this.isDisconnectedMode() &&
!this.isPageEditing();
if (!doRun) {
return null;
}
var elementPlaceholderRenderings = this.props.rendering.placeholders[this.props.name];
var personalizationRule = this.props.rendering.fields["PersonalizationRules"]
console.log('Running personalization on FE for renderings', elementPlaceholderRenderings);
var ruleEngineContext = this.ruleEngine.getRuleEngineContext();
this.ruleEngine.parseAndRunRule(personalizationRule.value, ruleEngineContext);
var placeholderPersonalizationRule = ruleEngineContext.personalization?.placeholders[this.props.name]
console.log("Rule parsed")
var personalizationHelper = new PersonalizationHelper(this.graphQLEndpoint, this.sitecoreApiKey);
var elementPlaceholderRenderings =
await personalizationHelper.doPersonalizePlaceholder(placeholderPersonalizationRule, elementPlaceholderRenderings);
console.log("Personalized renderings", elementPlaceholderRenderings);
return elementPlaceholderRenderings;
}
}
export default withSitecoreContext()(ClientSidePlaceholder);
Now we can replace the default JSS placeholder with our Personalized one and this would run the rule-engine personalization logic on client-side.
<main>
<div id="content">{route && <PersonalizedPlaceholder name="headless-main" rendering={route}
endpointUrl={config.edgeQLEndpoint} ruleEngine={ruleEngineInstance}
sitecoreApiKey={config.sitecoreApiKey}/>}</div>
</main>
But how can we make it even better?
All we do is transforming the component props
, so why not do this at the page-props-factory
pipeline?
For this we’ll create a new plugin that would run the personalization logic before we render layout.
import { GetServerSidePropsContext, GetStaticPropsContext } from 'next';
import { PersonalizationHelper } from '../../lib/index';
//@ts-ignore
import { JssRuleEngine } from 'sitecore-jss-rule-engine'
import {
DictionaryPhrases,
ComponentPropsCollection,
LayoutServiceData,
SiteInfo,
HTMLLink,
} from '@sitecore-jss/sitecore-jss-nextjs';
export type SitecorePageProps = {
site: SiteInfo;
locale: string;
dictionary: DictionaryPhrases;
componentProps: ComponentPropsCollection;
notFound: boolean;
layoutData: LayoutServiceData;
headLinks: HTMLLink[];
};
interface Plugin {
/**
* Detect order when the plugin should be called, e.g. 0 - will be called first (can be a plugin which data is required for other plugins)
*/
order: number;
/**
* A function which will be called during page props generation
*/
exec(
props: SitecorePageProps,
context: GetServerSidePropsContext | GetStaticPropsContext
): Promise<SitecorePageProps>;
}
export class RulesSSRPersonalizationPlugin implements Plugin {
graphQLEndpoint:string;
sitecoreApiKey:string;
ruleEngine:JssRuleEngine;
constructor(endpointUrl:string, sitecoreApiKey: string, ruleEngine:JssRuleEngine)
{
this.graphQLEndpoint = endpointUrl;
this.sitecoreApiKey = sitecoreApiKey;
this.ruleEngine = ruleEngine;
}
order = 3;
isDisconnectedMode(props:any) {
const disconnectedMode = props.layoutData.sitecore.context.site?.name === 'JssDisconnectedLayoutService';
return disconnectedMode;
}
isPageEditing(props:any) {
const isEditing = props.layoutData.sitecore.context.pageEditing;
return isEditing;
}
async exec(props: any, context: GetServerSidePropsContext | GetStaticPropsContext) {
var doRun =
!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"];
var serverSideProps = <GetServerSidePropsContext>context;
if(serverSideProps && personalizeOnEdge && personalizeOnEdge.value == "1")
{
console.log('Personalizing SSR');
//check if we are running in SSR mode - then pass the request url
if(serverSideProps.req?.url)
{
this.ruleEngine.setRequestContext({
url: serverSideProps.req.url
});
}
var personalizationHelper = new PersonalizationHelper(this.graphQLEndpoint, this.sitecoreApiKey);
await personalizationHelper.personalize(this.ruleEngine, props, personalizationRule);
}
}
return props;
}
}
Now let’s test!
For this test we’ll setup a page with a simple RichText
component with a datasource.
By default the page would look like this:
Now let’s configure some personalization:
And let’s test the page with a request parameter!
It works. If you have PersonalizeOnEdge
field not checked in Sitecore you might see the flickering as GraphQL is happening on the page. Otherwise the personalized version would be returned right from the NextJS server.
This is not the end of the story though!
The above implementation would only work correctly in SSR and client-side modes.
But what if we’d like to personalize the SSG implementation?
The SSR approach wouldn’t fully work over there as unfortunately we don’t have query string available at the time the SSG build is running.
How can we overcome this challenge? This would be the topic of the next article.
Stay tuned!