Enhancing Multisite and Market-Specific Handling with Sitecore JSS and Next.js

juan_dvd

Juan Gutierrez

Posted on October 4, 2024

Enhancing Multisite and Market-Specific Handling with Sitecore JSS and Next.js

When building a multisite, multilingual website with Sitecore JSS and Next.js, efficiently managing market-specific content for both editors and users presents unique challenges. While Sitecore provides powerful features for handling multiple languages and sites out of the box, extending these capabilities for a multi-market setup requires custom implementation to ensure a seamless experience.

In this blog, we’ll explore how Sitecore’s default multisite and multilingual handling works, and how we built on top of it to meet Uniworld's market-specific requirements. We'll cover the key plugins and configurations we modified, and the rationale behind each change.

Default Sitecore Multisite and Language Handling

Sitecore's Next.js Multisite Add-on includes a rich set of features designed to simplify multisite and multilingual setups:

Multisite Middleware: Rewrites requests to the correct site based on the incoming hostname.
Site Resolver: Uses GraphQL to fetch site information at build time, including site-specific settings like default language.
Internationalized Routing: Uses Next.js i18n to manage different languages based on the request path, with fallbacks to default settings when necessary.
By default, Sitecore handles language and site-specific routing using a pattern like /site/. This ensures that content is appropriately scoped across different sites hosted under the same Next.js instance.

Sitecore Default Language Behavior

In the default setup, Sitecore and Next.js use the hostname and URL path to determine which site and language to serve. If a language prefix is available in the URL (e.g., /en-us), Sitecore serves content in that language. If the language is not specified, it uses the default language for the given site.

However, this default behavior does not seamlessly accommodate market-specific redirections, requiring customizations to handle:

Serving market-specific content based on the user's country or preferences.
Supporting the Experience Editor and Preview without conflicts.
Integrating market handling into the multisite setup.

Challenges in Multisite and Market Handling

We faced a few challenges while creating a multi-market solution:

Serving Correct Content by Market: Ensuring the correct market was targeted based on the user's location or preferences.
Experience Editor Compatibility: Ensuring that editors could preview and edit content without inappropriate redirections.
Scalable Multisite Integration: Seamlessly integrating market-specific handling with Sitecore’s multisite capabilities.

Mapping Languages and Markets

Our goal was to create a multi-market experience that delivers localized content tailored to users' geographic locations or preferences. Here is how we mapped markets and corresponding locales:

  • Asia-Pacific (AP): en-PH for English spoken in Asia.
  • Australia (AU): en-AU.
  • Canada (CA): en-CA.
  • European Union (EU): en-IE.
  • New Zealand (NZ): en-NZ.
  • United Kingdom (UK): en-GB.
  • United States (US): en-US.
  • South Africa (ZA): en-ZA.

The objective was to ensure that users from these markets receive localized content for their specific market, and we wanted to handle this efficiently at the middleware level using Next.js.

Custom Implementation: Steps Taken

To meet these requirements, we introduced several key changes in Next.js middleware and configuration, as well as Sitecore plugins. Here is a detailed breakdown of the updates.

1. Market Plugin for Middleware

The Market Plugin addresses market-specific content handling by leveraging middleware in Next.js. It ensures users are redirected to the appropriate market URL, with proper locale detection and handling.

class marketPlugin implements MiddlewarePlugin {
  order = 0;

  constructor() {
    console.log('[marketPlugin] Initializing marketPlugin...');
  }
  async exec(req: NextRequest, res?: NextResponse): Promise {
    const { pathname } = req.nextUrl;

    // Exclude assets and other unnecessary routes
    if (this.excludeRoute(pathname)) {
      console.log([marketPlugin] Skipping route: ${pathname});
      return res || NextResponse.next();
    }

    console.log([marketPlugin] Executing marketPlugin for request: ${req.url});

    const cookieLocale = req.cookies.get('NEXT_LOCALE');
    const country = req.geo?.country?.toLowerCase() || 'us';

    let market: string;
    let locale: string;

    const urlmarket = pathname.split('/')[1];

    if (validmarkets.includes(urlmarket)) {
      console.log([marketPlugin] URL market detected: ${urlmarket});
      market = urlmarket;
    } else if (cookieLocale) {
      console.log([marketPlugin] Locale cookie found: ${cookieLocale.value});
      market = localeTomarketMap[cookieLocale.value.toLowerCase()] || 'us';
    } else {
      console.log([marketPlugin] No valid market found, using country: ${country});
      market = determinemarketFromCountry(country);
    }

    locale = determineLanguageFrommarket(market);
    console.log([marketPlugin] Determined locale from market: ${locale});

    if (cookieLocale?.value !== locale) {
      console.log([marketPlugin] Setting locale cookie to: ${locale});
      res = res || NextResponse.next();
      res.cookies.set('NEXT_LOCALE', locale, {
        path: '/',
        maxAge: 60 * 60 * 24 * 30, // 30 days
      });
    }

    if (!pathname.startsWith(/${market})) {
      const newUrl = req.nextUrl.clone();
      newUrl.pathname = /${market}${pathname === '/' ? '' : pathname};
      console.log([marketPlugin] Redirecting to market-specific path: ${newUrl});
      res = NextResponse.redirect(newUrl);
    }

    console.log([marketPlugin] Finished marketPlugin execution for request: ${req.url});
    return res || NextResponse.next();
  }

  private excludeRoute(pathname: string): boolean {
    const isExcluded =
      pathname.includes('.') || // Skip files (e.g., .css, .js, .png)
      pathname.startsWith('/api/') || // Skip Next.js API routes
      pathname.startsWith('/sitecore/'); // Skip Sitecore API routes

    console.log([marketPlugin] Checking if route should be excluded: ${pathname} - Excluded: ${isExcluded});
    return isExcluded;
  }
}
export const marketPlugin = new marketPlugin();  
Enter fullscreen mode Exit fullscreen mode

Why We Modified It:
Path Detection and Exclusion: The excludeRoute function was implemented to skip unnecessary routes like assets, API requests, and Sitecore service requests.
Market Detection: The plugin first checks if a valid market prefix exists in the URL. If not, it attempts to use a cookie (NEXT_LOCALE) to determine the market. If the cookie is unavailable, it falls back to the country code from the request geo headers.
Setting Cookies and Redirects: If the market in the URL does not match the detected or preferred market, the user is redirected to the appropriate market path, and the locale cookie is set for consistent behavior.

2. Updated next.config.js for Market and Locale Management

const jssConfig = require('./src/temp/config');

module.exports = {
  i18n: {
    locales: [
      'en', 'en-PH', 'en-AU', 'en-CA', 'en-IE', 'en-NZ', 'en-ZA', 'en-GB', 'en-US',
    ],
    defaultLocale: jssConfig.defaultLanguage,
    localeDetection: false, // Disabled locale detection since we handle it through middleware
  },
}; 
Enter fullscreen mode Exit fullscreen mode

Why We Modified It:
We disabled automatic locale detection (localeDetection: false) because our custom middleware handled the logic for determining the correct locale and market. This allowed us more granular control over which market a user should see.

3. Update to page-props.ts to Include Market

To support the new changes, the SitecorePageProps interface was updated to include the market property.

export type SitecorePageProps = {
  site: SiteInfo;
  locale: string;
  dictionary: DictionaryPhrases;
  componentProps: ComponentPropsCollection;
  notFound: boolean;
  layoutData: LayoutServiceData;
  headLinks: HTMLLink[];
  market: string; // New property for storing market information
};
Enter fullscreen mode Exit fullscreen mode

Key Changes:
Market Property: Added market to store the market context for each request.

4. Updated Page Props Factory Plugin

class SitePlugin implements Plugin {
  order = 0;
  async exec(props: SitecorePageProps, context: GetServerSidePropsContext | GetStaticPropsContext) {
    if (context.preview) {
      console.log('[SitePlugin] Preview mode detected. Skipping site resolution.');
      return props;
    }
    const path = this.getNormalizedPath(context);
    console.log([SitePlugin] Resolving site for path: ${path});

    const siteData = getSiteRewriteData(path, config.sitecoreSiteName);
    console.log([SitePlugin] Site data extracted from path:, siteData);

    const market = this.getmarketFromParams(context);
    const locale = determineLanguageFrommarket(market);

    context.locale = locale;
    props.site = siteResolver.getByName(siteData.siteName);
    props.locale = locale;
    props.market = market;
    console.log([SitePlugin] Props:, props);
    console.log([SitePlugin] Context:, context);

    return props;
  }

  private getmarketFromParams(context?: GetServerSidePropsContext | GetStaticPropsContext): string {
    if (context?.params && Array.isArray(context.params.path) && context.params.path.length > 1) {
      const [, secondSegment] = context.params.path;
      if (validmarkets.includes(secondSegment)) {
        context.params.path.splice(1, 1);
        return secondSegment;
      }
    }
    return '';
  }

  private getNormalizedPath(context: GetServerSidePropsContext | GetStaticPropsContext): string {
    if (!context.params) return '/';
    return Array.isArray(context.params.path) ? context.params.path.join('/') : context.params.path ?? '/';
  }
}
export const sitePlugin = new SitePlugin();
Enter fullscreen mode Exit fullscreen mode

Why We Modified It:
The SitePlugin helps resolve the current site context based on the request, and modifications were made to include both market and locale. This ensures that the rest of the page-building process is aware of the market-specific context.

Key Changes:
Market and Locale Handling: The SitePlugin was updated to extract market information from the URL and set it within the context, ensuring consistency across the rendering process.

5. Preview Mode Plugin Updates

import { GetServerSidePropsContext, GetStaticPropsContext } from 'next';
import {
  SiteInfo,
  personalizeLayout,
  getGroomedVariantIds,
} from '@sitecore-jss/sitecore-jss-nextjs';
import {
  editingDataService,
  isEditingMetadataPreviewData,
} from '@sitecore-jss/sitecore-jss-nextjs/editing';
import { SitecorePageProps } from 'lib/page-props';
import { graphQLEditingService } from 'lib/graphql-editing-service';
import { Plugin } from '..';
import { determinemarketFromLocale } from 'lib/helpers/marketHelper';

class PreviewModePlugin implements Plugin {
  order = 1;

  async exec(props: SitecorePageProps, context: GetServerSidePropsContext | GetStaticPropsContext) {
    if (!context.preview) {
      console.log('[PreviewModePlugin] Not in preview mode. Skipping preview logic.');
      return props;
    }
    console.log('[PreviewModePlugin] Preview mode detected.');
    // If we're in Pages preview (editing) Metadata Edit Mode, prefetch the editing data
    if (isEditingMetadataPreviewData(context.previewData)) {
      console.log('[PreviewModePlugin] Detected Pages preview in Metadata Edit Mode.');

      const { site, itemId, language, version, variantIds, layoutKind } = context.previewData;
      console.log([PreviewModePlugin] Preview data received:, context.previewData);

      try {
        const data = await graphQLEditingService.fetchEditingData({
          siteName: site,
          itemId,
          language,
          version,
          layoutKind,
        });

        if (!data) {
          throw new Error(
            Unable to fetch editing data for preview ${JSON.stringify(context.previewData)}
          );
        }

        console.log('[PreviewModePlugin] Editing data successfully fetched:', data);

        const locale = context.previewData.language;
        const market = determinemarketFromLocale(locale);
        props.site = data.layoutData.sitecore.context.site as SiteInfo;
        props.layoutData = data.layoutData;
        props.dictionary = data.dictionary;
        props.headLinks = [];
        props.locale = locale;
        props.market = market;
        console.log([PreviewModePlugin] Props:, props);
        console.log([PreviewModePlugin] Context:, context);
        const personalizeData = getGroomedVariantIds(variantIds);
        console.log('[PreviewModePlugin] Groomed variant IDs:', personalizeData);

        personalizeLayout(
          props.layoutData,
          personalizeData.variantId,
          personalizeData.componentVariantIds
        );

        console.log('[PreviewModePlugin] Personalized layout data applied.');
      } catch (error) {
        console.error('[PreviewModePlugin] Error fetching editing data:', error);
        throw error;
      }

      return props;
    }

    // If we're in preview (editing) Chromes Edit Mode, use data already sent along with the editing request
    console.log(
      '[PreviewModePlugin] Detected preview mode using Experience Editor (Chromes Edit Mode).'
    );

    try {
      const data = await editingDataService.getEditingData(context.previewData);
      if (!data) {
        throw new Error(
          Unable to get editing data for preview ${JSON.stringify(context.previewData)}
        );
      }

      console.log(
        '[PreviewModePlugin] Editing data successfully retrieved from Experience Editor:',
        data
      );
      const locale = data.language;
      const market = determinemarketFromLocale(locale);
      props.site = data.layoutData.sitecore.context.site as SiteInfo;
      props.layoutData = data.layoutData;
      props.dictionary = data.dictionary;
      props.headLinks = [];
      props.locale = locale;
      props.market = market;
      console.log([PreviewModePlugin] Props:, props);
      console.log([PreviewModePlugin] Context:, context);
    } catch (error) {
      console.error('[PreviewModePlugin] Error getting editing data:', error);
      throw error;
    }

    return props;
  }
}

export const previewModePlugin = new PreviewModePlugin();
Enter fullscreen mode Exit fullscreen mode

Why We Modified It:
We modified the PreviewModePlugin to set market-specific information during the preview process. This ensured that editors using the Experience Editor would see the correct variant of the content corresponding to the specified market.

Key Changes:
Personalization Handling: The plugin handles market and locale during preview to provide editors with a preview that is consistent with what end users will experience.

Final thoughts

Setting up a multisite and multilingual website for Uniworld with Sitecore XM Cloud and Next.js involved overcoming challenges around market-specific content targeting and editor experience. Here’s a summary of the key steps taken:

MarketPlugin Middleware: Handled market path redirection and cookie management to ensure users were served the correct content.
Updated next.config.js: Configured locales, rewrites, and disabled locale detection for more control.
Page Props Plugins: Updated site.ts and previewMode.ts to properly handle market and locale information, making it consistent across different page states.
Updated Page Props: Added the market context to SitecorePageProps for easy access.
This custom solution allows Sitecore XM Cloud to deliver the right content to the right users, ensuring an optimal experience for both visitors and content editors. It is modular and easily extendable, making it a strong foundation for future growth.

https://www.asuinnovation.com/blog/sitecore-xm-middleware-multi-market/

References

A Full Guide to Creating Multi-Language Sites with Sitecore XM Cloud and Next.js

Internationalization in the JSS Sample App for Next.js

Guide to Implementing Multilingual Support in Sitecore XM Cloud

The Next.js Multisite Add-On

Multisite Support with Sitecore Next.js and Edge Middleware

💖 💪 🙅 🚩
juan_dvd
Juan Gutierrez

Posted on October 4, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related