Friday, May 27, 2022

Vercel, Next.js middleware and personalization using Sitecore Personalize

When I started working with Vercel middleware I was convinced that I would be able to parse html before returning it to the user, however, to my big surprise you can't do html parsing in middleware which makes complete sense from the caching perspective.

I used Boxever library implementation from Sitecore.Demo.Edge Git repo and added a few things that I needed.

So if html can't be parse, how would one implement personalization? While searching for solution I ran into a YouTube video by Steve Sewel and a solution from https://www.builder.io. The solution that Steve described made perfect sense and seemed to be the only way to implement personalization in middleware. I used the same approach in my POC and here what I was able to do.

Use case

A user from Germany comes to the site and a component on a product listing page shows a greeting stating the user is visiting from Germany.

While the use case is very simple and you don't necessarily need Sitecore Personalize to implement that, it was the easiest scenario to implement to test the waters with middleware.

Solution

Under /pages folder I added _middleware.ts file which mean that the middleware function will be called for all pages. Here is the content of the file:

import { getPersonalizedPagePath } from 'lib/middleware-utils';
import { NextResponse, NextRequest } from 'next/server'

const excludededPrefixes = ['/favicon', '/api']

export async function middleware(req: NextRequest) {
  const url = req.nextUrl.clone();
  const country = req.geo?.country || 'US';

  if(!excludededPrefixes.find((path) => url.pathname?.startsWith(path))) {  
    const browserId = req.cookies[`bid_${process.env.CDP_CLIENT_KEY}`] || process.env.CDP_BROWSER_ID;
    const friendlyId = 'user_geolocation';  

    if (!url?.pathname!.includes(';')) {        
      const rewriteUrl = await getPersonalizedPagePath(friendlyId, url?.pathname!, req.cookies, country, browserId);
      if (rewriteUrl && rewriteUrl.path) {
        url.pathname = rewriteUrl.path;          
        return NextResponse.rewrite(url).cookie(`bx_userAttributes_${friendlyId}`, rewriteUrl[friendlyId]);        
      }
    }

    return NextResponse.rewrite(url).cookie(`bx_userAttributes_${friendlyId}`, friendlyId);    
  }
  return NextResponse.rewrite(url);  
}

As you can see I am rewriting the url of the current page to include country and friendlyId parameters.

If a request is made to /products page, for the user from Germany the rewrite url will look like this:

/products;country=DE;user_geolocation=Germany

Country code is easily retrieved from NextRequest. The second parameter is a little bit more involved. I defined a Full Stack experience in Sitecore Personalize that is called user_geolocation. The experience returns Germany if the latest guest session come from Germany. I added the country code (retrieved from NextRequest) to the VIEW event, so now all guest VIEW events have a country code. I also have a Decision Model attached to my Full Stack experience which gives me flexibility with API response. I can return "Germany" or I can return something else that I can use to display personalized content. The only thing to be aware of is the length of the outcome. Since it is part of the url, it should be short. I can use this response either to hardcode personalization or retrieve personalized content from Sitecore. For the simplicity of this POC, I hardcoded the personalization message.

When the content for rewritten url is being request and for SSG Next.js application it is possible because of the Next.js ISR functionality. The fallback configuration is set to "blocking" as for a usual Sitecore JSS application with SSG and the revalidate for this POC is set to 15 seconds. In real live you would want it to be as high as possible.

When the new version of the pages is being generated getStaticProps function is being called in [[...path]].ts. I added a few lines to read the parameters that I set in middleware and remove them from the actual page path, so it can retrieve the proper page from Sitecore.

export const getStaticProps: GetStaticProps = async (context) => {
  if (context) {    
    let userAttributes:any = {};
    let path:string[] = [];
    if(context.params?.path) {
      (context.params?.path as string[]).forEach((part) => {
        if (part && part.includes(';')) {
          userAttributes = { ...getTargetingValues(part?.split(';').slice(1)) };
          path.push(part?.split(';')[0]);
        } else {
          path.push(part);
        }
      });
    }
    context.params!.userAttributes = userAttributes;
    context.params!.path = path;
  }

  const props = await sitecorePagePropsFactory.create(context);  

  return {
    props,
    // Next.js will attempt to re-generate the page:
    // - When a request comes in
    // - At most once every 15 seconds
    revalidate: 15, // In seconds
    notFound: props.notFound, // Returns custom 404 page with a status code of 404 when true
  };
};

Now in the Next.js component that will render the personalized content I added the following:

import { Field, withDatasourceCheck, useComponentProps, GetStaticComponentProps } from '@sitecore-jss/sitecore-jss-nextjs';
import { StyleguideComponentProps } from 'lib/component-props';

type PersonalizedNavigationProps = StyleguideComponentProps & {
  fields: {
    friendlyId: Field<string>;
  };
};

type PersonalizedNavigationData = { content: '', country: '' };

const PersonalizedNavigation = ({ rendering }: PersonalizedNavigationProps): JSX.Element => {
  const personalizedContent = rendering.uid ? useComponentProps<PersonalizedNavigationData>(rendering.uid) : undefined;
  let personalizedOutput = '';
  //console.log(personalizedContent);
  if (personalizedContent?.country) {    
    switch(personalizedContent?.country) {
      case 'US': {
        personalizedOutput = 'USA';
        break;
      }
      case 'DE': {
        personalizedOutput = 'Germany';
        break;
      }
      default: {
        //statements;
        break;
      }
    }
  }

  if (personalizedContent?.content) {    
    switch(personalizedContent?.content) {
      case 'US': {
        personalizedOutput = personalizedOutput + ' ::: USA';
        break;
      }
      case 'DE': {
        personalizedOutput = personalizedOutput + ' ::: Germany';
        break;
      }
      default: {
        //statements;
        break;
      }
    }
  }

  return (
    <div>
      <h2>
        Country:
        {personalizedOutput && (
          <> {personalizedOutput}</>
        )}      
      </h2>
    </div>
  );
};

export const getStaticProps: GetStaticComponentProps = async (rendering:any, context:any, params:any) => {
  if (params && rendering && context) {
    const friendlyId = rendering?.fields?.data?.contextItem?.friendlyId.value || 'user_geolocation' as string;
    const country = params?.params?.userAttributes ? params?.params?.userAttributes?.country : '';
    const output = params?.params?.userAttributes ? params?.params?.userAttributes[friendlyId] : '';
    //console.log(params);
    return { country: country || '', content: output || ''};
  }
  return {};
};

export default withDatasourceCheck()<PersonalizedNavigationProps>(PersonalizedNavigation);

And on the page after the code has been deployed to Vercel the following snippet will render:




Below you'll find the files references from the code above. I decided to separate them into a separate section to avoid the flow disruption in my explanation.

Referenced .ts files:

middleware-utils.ts

import { DynamicData, getDynamicData, PersonalizedPath } from "src/services/BoxeverApiService";

const getUrlSegments = (values:any) => {
    const attrs = Object.keys(values);
    return attrs
        .map((attr) => `${attr}=${encodeURIComponent(values[attr])}`)
        .sort();
};

const getTargetingCookies = (cookiesMap:any) => Object.keys(cookiesMap).filter((cookie) => cookie.startsWith("bx_userAttributes"));

export function getTargetingValues(path:any) {
    return path.sort().reduce((acc:any, segment:any) => {
        const [key, value] = segment.split("=");
        if (key) {
            return Object.assign(Object.assign({}, acc), { [key]: decodeURIComponent(value) });
        }
        return acc;
    }, {});
};

export function getPersonalizedRewrite(pathname: string, cookies: Record<string, string>, country: string, experiencePair: string) {
    const attributes = getTargetingCookies(cookies);
    const values = attributes.reduce((acc, cookie) => {
        const value:string = cookies[cookie];
        const key = cookie.split("bx_userAttributes_")[1];
        return Object.assign(Object.assign({}, acc), (typeof value === 'string' && { [key]: value }));
    }, {});
    if (Object.keys(values).length > 0 || country) {
        return `${pathname};${experiencePair};${getUrlSegments(Object.assign(values, { country })).join(";")}`;
    }
    return false;
}

export function getUserAttribute(name:string, path:string) {
    if (name && path) {
        const attr = path.split(';');
        let attrValue = '';
        attr.forEach((part) => {
            const pathAttr = part.split('=');
            if (pathAttr[0] === name) {
                attrValue = pathAttr[1];
            }
        });

        return attrValue;
    }

    return '';
}

export async function getPersonalizedPagePath(friendlyId:string, path:string, cookies:any, country:string, browserId?:string):Promise<PersonalizedPath> {
    let response:PersonalizedPath = {};
    const outputValue = cookies[`bx_userAttributes_${friendlyId}`];
    if (browserId) {
        await getDynamicData('en', friendlyId, browserId).then((content: DynamicData) => {
            const result = content && content.decisionValues ? content.decisionValues.filter((i) => { return i.decisionType === 'programmable'})[0].outputValue : ''
            if (result) {    
                response[friendlyId] = result;
                response.path = getPersonalizedRewrite(
                    path,
                    cookies,
                    country,
                    `${friendlyId}=${response[friendlyId]}`
                ) as string;

                return response;
            }      
            return response;
        });
    } else if (outputValue) {
        response[friendlyId] = outputValue;
        response.path = getPersonalizedRewrite(
            path,
            cookies,
            country,
            `${friendlyId}=${response[friendlyId]}`
        ) as string;

        return response;
    }
   
    return response;
}

BoxeverApiService.ts

import BoxeverServiceConfig from './BoxeverApiServiceConfig';

// ***** TYPES *****

type GuestRef = string;

interface GuestRefResponse {
  guestRef: GuestRef;
}


// ***** API *****

const CDP_CLIENT_KEY = process.env.CDP_CLIENT_KEY || '';
const CDP_API_TARGET_ENDPOINT = process.env.CDP_API_TARGET_ENDPOINT || '';
const CDP_API_ENDPOINT = process.env.CDP_API_ENDPOINT || '';
export const isCdpConfigured = !!CDP_CLIENT_KEY && !!CDP_API_TARGET_ENDPOINT;

// ****************************************************************************
// Delaying calls is required due to some race conditions where a guest
// identification call was executed before the Boxever library had finished
// initializing.
// ****************************************************************************

function callFlows(flowConfig: Record<string, unknown>) {
  return new Promise(function (resolve, reject) {
    try {
      boxeverGet('callFlows', flowConfig)
      .then((result) => { resolve(result) })
      .catch((error) => console.log(error));      
    } catch (err) {
      reject(err);
    }
  });
}

// ****************************************************************************
// Gets the current guest ref by calling a CDP flow.
// This is a workaround needed when an anonymous guest identify itself as a
// previously known guest, CDP is merging the 2 guests. The browser keeps the
// anonymous guest ref. From that moment, calls to the authenticated CDP APIs
// like get/set for data extensions are failing when using this old anonymous
// guest ref. These calls require the previously known guest ref which is
// returned by our flow.
// ****************************************************************************
export function getGuestRef(): Promise<GuestRefResponse> {
  return callFlows({
    friendlyId: 'get_guestref',
  }) as Promise<GuestRefResponse>;
}

export async function getBrowserId() {
  let browserId = '';
  const url = `${CDP_API_TARGET_ENDPOINT}/browser/create.json?client_key=${BoxeverServiceConfig.clientKey}?message={}`;
  const response = await fetch(url, { headers: {
    'Content-Type': 'application/json',
  } });
  response.json().then((response) => { browserId = response.ref || '' }).catch((error) => console.log(error));
  return browserId;
}

async function boxeverGet(action: string, payload?: Record<string, unknown>): Promise<unknown> {  
  let message = {...BoxeverServiceConfig, ...payload};
  const url = `${CDP_API_ENDPOINT}/${action}?message=${JSON.stringify(message)}`;
  const response = await fetch(url, { headers: {
    'Content-Type': 'application/json',
  } });
  return response.json().catch((error) => console.log(error));      ;
}

export interface DynamicData {
  decisionValues: [
    {
      decisionType: string,
      trackingUrl: string,
      decisionName: string,
      outputName: string,
      outputValue: string,
    }
  ],
  decisionOffers: [
    {
      ref: string,
      name: string,
      description: string,
      status: string,
      attributes: {
        link: string,
        name: string,
        imageUrl: string
      }
    }
  ]
}

export interface UserAttributes {
  urlPath:string,
  countr:string,
  browserId:string
}

export interface CreateResponse {
  status: string,
  version: string,
  client_key: string,
  ref: string,
  customer_ref: string
}

export interface PersonalizedPath {
  [key: string]: any
}

export function getDynamicData(
  language: string,
  friendlyId:string,
  browserId: string
): Promise<DynamicData> {
 
  return callFlows({
    friendlyId: friendlyId,
    language: language,
    browserId: browserId
  }) as Promise<DynamicData>;
}

BoxeverApiServiceConfig.ts

const POINT_OF_SALE = process.env.CDP_POINT_OF_SALE;
const CURRENCY = 'USD';
const CDP_CLIENT_KEY = process.env.CDP_CLIENT_KEY || '';

const BoxeverApiServiceConfig = {
  channel: 'WEB',
  clientKey: CDP_CLIENT_KEY,
  pointOfSale: POINT_OF_SALE,
  currencyCode: CURRENCY
};

export default BoxeverApiServiceConfig;