Multisite support for Sitecore JSS – Next.js using Vercel’s Edge Middleware – Demo on Sitecore Demo Portal (XM + Edge)!

I’ve come across the requirement for supporting a multi-site Sitecore-SXA approach with a single rendering host (Next.js app).

With this approach, we want to lower the costs by deploying to a single Vercel instance and making use of custom domains or sub-domains to resolve the sites.

If you have a look at the Sitecore Nextjs SDK and/or the starter templates, you’ll notice that there is no support for multi-site, so here I’ll go through a possible solution for this scenario where we need also to keep the SSG/ISR functionality from Next.js/Vercel.

The approach

To make it work we basically need to somehow resolve the site we’re trying to reach (from hostname or subdomain) and then pass it through the LayoutService and DictionaryService to resolve those properly.

As we’ve also enabled SSG, we’ll need to do some customization to the getStaticPaths so it generates the sitemap for each site.

Resolving the site by custom domains or subdomains

As I mentioned in the title of the post, I’ll be using Edge Middleware for that, so I’ve based this on the examples provided by Vercel, check the hostname-rewrites example!

For more details on Edge Middleware, please refer to my previous post!

Dynamic routes

Dynamic Routes are pages that allow you to add custom parameters to your URLs. So, we can then add the site name as a param to then pass it through the layout and dictionary services. For more details on dynamic routing, check the official documentation and the example here!

Demo!

We now know all the basics, let’s move forward and make the needed changes to make it work.

For demoing it, I’m just creating a new Sitecore Next.js JSS app by using the JSS initializer and the just recently released Sitecore Demo Portal! – Check this great blog from my friend Neil Killen for a deep overview of it!

Changes to the Next.js app

To accomplish this, as already mentioned, we have to play with dynamic routing, so we start by moving the [[…path]].tsh to a new folder structure under ‘pages’: pages/_sites/[site]/[[…path]].tsh

Then we’ve to create the middleware.ts file in the root of src. The code here is quite simple, we get the site name from the custom domain and then update the pathname with it to do an URL rewrite.

import { NextRequest, NextResponse } from 'next/server'
import { getHostnameDataOrDefault } from './lib/multisite/sites'

export const config = {
  matcher: ['/', '/_sites/:path'],
}

export default async function middleware(req: NextRequest): Promise<NextResponse> {
  const url = req.nextUrl.clone();
  // Get hostname (e.g. vercel.com, test.vercel.app, etc.)
  const hostname = req.headers.get('host');

  // If localhost, assign the host value manually
  // If prod, get the custom domain/subdomain value by removing the root URL
  // (in the case of "test.vercel.app", "vercel.app" is the root URL)
  const currentHost =
    //process.env.NODE_ENV === 'production' &&
    hostname?.replace(`.${process.env.ROOT_DOMAIN}`, '');
  const data = await getHostnameDataOrDefault(currentHost?.toString());

  // Prevent security issues – users should not be able to canonically access
  // the pages/sites folder and its respective contents.
  if (url.pathname.startsWith(`/_sites`)) {
    url.pathname = `/404`
  } else {
    // rewrite to the current subdomain
    url.pathname = `/_sites/${data?.subdomain}${data?.siteName}${url.pathname}`;
  }
  
  return NextResponse.rewrite(url);
}

You can see the imported function getHostnameDataOrDefault called there, so next, we add this to /lib/multisite/sites.ts

const hostnames = [
  {
      siteName: 'multisite_poc',
      description: 'multisite_poc Site',
      subdomain: '',
      rootItemId: '{8F2703C1-5B70-58C6-927B-228A67DB7550}', 
      languages: [
        'en'
      ],
      customDomain: 'www.multisite_poc_global.localhost|next12-multisite-global.vercel.app',
      // Default subdomain for Preview deployments and for local development
      defaultForPreview: true,
    },
    {
      siteName: 'multisite_poc_uk',
      description: 'multisite_poc_uk Site',
      subdomain: '',
      rootItemId: '{AD81037E-93BE-4AAC-AB08-0269D96A2B49}', 
      languages: [
        'en', 'en-GB'
      ],
      customDomain: 'www.multisite_poc_uk.localhost|next12-multisite-uk.vercel.app',
    },
]
// Returns the default site (Global)
const DEFAULT_HOST = hostnames.find((h) => h.defaultForPreview)

/**
 * Returns the data of the hostname based on its subdomain or custom domain
 * or the default host if there's no match.
 *
 * This method is used by middleware.ts
 */
export async function getHostnameDataOrDefault(
  subdomainOrCustomDomain?: string
) {
  if (!subdomainOrCustomDomain) return DEFAULT_HOST

  // check if site is a custom domain or a subdomain
  const customDomain = subdomainOrCustomDomain.includes('.')

  // fetch data from mock database using the site value as the key
  return (
    hostnames.find((item) =>
      customDomain
        ? item.customDomain.split('|').includes(subdomainOrCustomDomain)
        : item.subdomain === subdomainOrCustomDomain
    ) ?? DEFAULT_HOST
  )
}

/**
 * Returns the site data by name
 */
export async function getSiteData(site?: string) {
  return hostnames.find((item) => item.siteName === site);
}

/**
 * Returns the paths for `getStaticPaths` based on the subdomain of every
 * available hostname.
 */
export async function getSitesPaths() {
  // get all sites
  const subdomains = hostnames.filter((item) => item.siteName)

  // build paths for each of the sites
  return subdomains.map((item) => {
    return { site: item.siteName, languages: item.languages, rootItemId: item.rootItemId }
  })
}

export default hostnames

I’ve added the custom domains I’d like to use later to resolve the sites based on those. I’ve defined 2 as I want this to work both locally and then when deployed to Vercel.

Changes to the getStaticProps

We keep the code as it is in the [[…path]].tsx, you’d see that the site name is now part of the context.params (add some logging there to confirm this)

[[…path]].tsx

Changes to page-props-factory/normal-mode.ts

We need now to get the site name from the context parameters and send it back to the Layout and Dictionary services to set it out. I’ve also updated both dictionary-service-factory.ts and layout-service-factory constructors to accept the site name and set it up.

normal-mode.ts
fictionary-service-factory.ts
layout-service-factory.ts

Please note that the changes are quite simple, just sending the site name as a parameter to the factory constructors to set it up. For the dictionary, we are also setting the root item id.

Changes to getStaticPaths

We have now to modify that in order to build the sitemap for SSG taking all sites into account. The change is also quite simple:

// This function gets called at build and export time to determine
// pages for SSG ("paths", as tokenized array).
export const getStaticPaths: GetStaticPaths = async (context) => {
  ...

  if (process.env.NODE_ENV !== 'development') {
    // Note: Next.js runs export in production mode
    const sites = (await getSitesPaths()) as unknown as Site[];
    const pages = await sitemapFetcher.fetch(sites, context);
    const paths = pages.map((page) => ({
      params: { site: page.params.site, path: page.params.path },
      locale: page.locale,
    }));

    return {
      paths,
      fallback: process.env.EXPORT_MODE ? false : 'blocking',
    };
  }

  return {
    paths: [],
    fallback: 'blocking',
  };
};

As you can see, we are modifying the fetcher and sending the site’s data as an array to it so it can process all of them. Please note the site param is now mandatory so needs to be returned in the paths data.

Custom StaticPath type

I’ve defined two new types I’ll be using here, StaticPathExt and Site

Site.ts
StaticPathExt.ts

We need to make some quick changes to the sitemap-fetcher-index.ts now, basically to send back to the plugin the Sites info array and to return the new StaticPathExt type.

import { GetStaticPathsContext } from 'next';
import * as plugins from 'temp/sitemap-fetcher-plugins';
import { StaticPathExt } from 'lib/type/StaticPathExt';
import Site from 'lib/type/Site';

export interface SitemapFetcherPlugin {
  /**
   * A function which will be called during page props generation
   */
  exec(sites?: Site[], context?: GetStaticPathsContext): Promise<StaticPathExt[]>;
}

export class SitecoreSitemapFetcher {
  /**
   * Generates SitecoreSitemap for given mode (Export / Disconnected Export / SSG)
   * @param {GetStaticPathsContext} context
   */
  async fetch(sites: Site[], context?: GetStaticPathsContext): Promise<StaticPathExt[]> {
    const pluginsList = Object.values(plugins) as SitemapFetcherPlugin[];
    const pluginsResults = await Promise.all(
      pluginsList.map((plugin) => plugin.exec(sites, context))
    );
    const results = pluginsResults.reduce((acc, cur) => [...acc, ...cur], []);
    return results;
  }
}

export const sitemapFetcher = new SitecoreSitemapFetcher();

And last, we update the graphql-sitemap-service.ts to fetch all sites and add its info to get returned back to the getStaticPaths

async exec(sites: Site[], _context?: GetStaticPathsContext): Promise<StaticPathExt[]> {
    let paths = new Array<StaticPathExt>();
    for (let i = 0; i < sites?.length; i++) {
      const site = sites[i]?.site || config.jssAppName;
      this._graphqlSitemapService.options.siteName = site;
      this._graphqlSitemapService.options.rootItemId = sites[i].rootItemId;
      if (process.env.EXPORT_MODE) {
        // Disconnected Export mode
        if (process.env.JSS_MODE !== 'disconnected') {
          const p = (await this._graphqlSitemapService.fetchExportSitemap(
            pkg.config.language
          )) as StaticPathExt[];
          paths = paths.concat(
            p.map((page) => ({
              params: { path: page.params.path, site: site },
              locale: page.locale,
            }))
          );
        }
      }
      const p = (await this._graphqlSitemapService.fetchSSGSitemap(
        sites[i].languages || []
      )) as StaticPathExt[];
      paths = paths.concat(
        p.map((page) => ({
          params: { path: page.params.path, site: site },
          locale: page.locale,
        }))
      );
    }
    return paths;
  }

We’re all set up now! Let’s now create some sample sites to test it out. As I already mentioned, I’m not spinning up any Sitecore instance locally or Docker containers but just using the new Demo Portal, so I’ve created a demo project using the empty template (XM + Edge). This is really awesome, I haven’t had to spend time with this part.

Sitecore Demo Portal

I’ve my instance up and running, and it comes with SXA installed by default! Nice ;). So, I’ve just created two sites under the same tenant and added some simple components (from the JSS boilerplate example site).

Sitecore Demo Portal instance

From the portal, I can also get the Experience Edge endpoint and key:

Sitecore Demo Portal

Note: I’ve had just one thing to do and I’ll give feedback back to Sitecore on this, by default there is no publishing target for Experience Edge, even though it comes by default on the template, so I’ve to check the database name used in XM (it was just experienceedge) and then created a new publishing target.

The first thing is to check the layout service response gonna work as expected, so checked the GraphQL query to both XM and Experience Edge endpoints to make sure the sites were properly resolved.

From XM: https://%5Bsitecore-demo-instance%5D/sitecore/api/graph/edge/ui

GraphQL playground from XM

From Experience Edge: https://edge-beta.sitecorecloud.io/api/graphql/ide

GraphQL playground from Experience Edge

All good, also checked that the site ‘multisite_poc_uk‘ is also working fine.

Now, with everything set, we can test this out locally. The first thing is to set the environment variables so those point to our Experience Edge instance.

  • JSS_EDITING_SECRET: (from Demo Portal)
  • SITECORE_API_KEY: (from Demo Portal)
  • SITECORE_API_HOST=https://edge-beta.sitecorecloud.io
  • GRAPH_QL_ENDPOINT=https://edge-beta.sitecorecloud.io/api/graphql/v1
  • FETCH_WITH=GraphQL

Let’s run npm run start:connected in our src/rendering folder!

Note: I’ve added hosts entries to test this out locally:

127.0.0.1 www.multisite_poc_global.localhost
127.0.0.1 www.multisite_poc_uk.localhost
npm run start:connected

If everything went well, you should be able to see that (check the logging we added in the getStaticProps previously).

UK Site
Global Site

Cool! both sites are properly resolved and the small change I’ve made to the content bock text confirms that.

Let’s now run npm run next:build so we test the SSG:

npm run next:build

Deploying to Vercel

We’re all set to get this deployed and tested in Vercel, exciting!

I won’t go through the details on how to deploy to Vercel as I’ve already written a post about it, so for details please visit this post!

Couple of things to take into account:

  • I don’t push my .env file to the GitHub repo, so I’ve set all the environment variables in Vercel itself.
  • I’ve created 2 new custom domains to test this. Doing that is really straightforward, in Vercel got to the project settings, and domains and create those:
Vercel custom domains

I’ve pushed the changes to my GitHub repo that is configured in Vercel so a deployment just got triggered, check build/deployment logs and the output!

Looking good! let’s try out the custom domains now:

https://next12-multisite-global.vercel.app/

Global site

https://next12-multisite-uk.vercel.app/

UK site

I hope you find it interesting, you can find the code I’ve used for this example in this GitHub repo.

If you have a better or different approach to resolve multisite within a single Next.js app, please comment! I’d love to hear about other options.

I’d like also to say thanks to Sitecore for this Portal Demo initiative, it’s really helpful to speed up PoC and demos to customers!

Thanks for reading!

Deliver dynamic content at the speed of static with Next.js Middleware and Vercel Edge!

Intro

In this post I’d like to share a topic that we’ve presented together with my friend Ehsan Aslani during the Sitecore User Group France, an event that I’ve also organized with my friends Ugo Quaisse and Ramkumar Dhinakaran in Paris at the Valtech offices, find more details and pictures about the event here.

About Edge Middleware

At the time we presented this topic in the UG, the Edge Functions in Vercel were in beta version, now we got the good news from Vercel that they released Next.js version 12.2 that includes Middleware stable among other amazing new experimental features like:

On top of this new release, Vercel also introduced a new concept that makes a little bit of confusion around it, Edge Functions != Edge Middleware. In the previous version, the middleware was deployed to Vercel as an Edge Function, while now it’s a “Middleware Edge”.

Edge Functions (still in beta)

Vercel Edge Functions allow you to deliver content to your site’s visitors with speed and personalization. They are deployed globally by default on Vercel’s Edge Network and enable you to move server-side logic to the Edge, close to your visitor’s origin.

Edge Functions use the Vercel Edge Runtime, which is built on the same high-performance V8 JavaScript and WebAssembly engine that is used by the Chrome browser. By taking advantage of this small runtime, Edge Functions can have faster cold boots and higher scalability than Serverless Functions.

Edge Functions run after the cache, and can both cache and return responses.

Edge Functions

Middleware Functions

Edge Middleware is code that executes before a request is processed on a site. Based on the request, you can modify the response. Because it runs before the cache, using Middleware is an effective way of providing personalization to statically generated content. Depending on the incoming request, you can execute custom logic, rewrite, redirect, add headers, and more, before returning a response.

Edge Middleware allows you to deliver content to your site’s visitors with speed and personalization. They are deployed globally on Vercel’s Edge Network and enable you to move server-side logic to the Edge, close to your visitor’s origin.

Middleware uses the Vercel Edge Runtime, which is built on the same high-performance V8 JavaScript and WebAssembly engine that is used by the Chrome browser. The Edge Runtime exposes and extends a subset of Web Standard APIs such FetchEventResponse, and Request, to give you more control over how you manipulate and configure a response, based on the incoming requests. To learn more about writing Middleware, see the Middleware API guide.

Edge Middleware

Benefits of Edge Functions

  • Reduced latency: Code runs geographically close to the client. A request made in London will be processed by the nearest edge node to London, instead of Washington, USA.
  • Speed and agility: Edge Functions use Edge Runtime, which, due to its smaller API surface, allows for a faster startup than Node.js
  • Personalized content: Serve personalized cached content based on attributes such as visitor location, system language, or cookies

About nested middleware in beta

With the stable release of middleware in Next.js v12.2, nested middleware is not supported anymore, details here.

In the beta version, it was possible to create different “_middleware.ts” files under specific folders so we can control when those are executed. Now, only one file at the app’s root is allowed and we need to add some logic to handle that by checking the parsed URL, like:

// <root>/middleware.ts
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/about')) {
    // This logic is only applied to /about
  }

  if (request.nextUrl.pathname.startsWith('/dashboard')) {
    // This logic is only applied to /dashboard
  }
}

Common usages

Demo time!

In the demo I’ve prepared for the UG, I used edge functions for doing a bit of geolocation, playing with cookies, A/B testing, rewrites, and feature-flag enablement.

To start I’ve just created an empty project using the nextjs CLI:

npx create-next-app@latest --typescript

Then, inside the recently created app folder, and then check localhost:

npm run dev

We are all set to start testing out the middleware, for doing that, we get started by creating a new file in the root folder, name it “middleware.ts”. Let’s add some code there to test how it works:

import { NextRequest, NextResponse, NextFetchEvent } from "next/server";

export function middleware (req: NextRequest, event: NextFetchEvent) {  
    const response = NextResponse.next()
    response.headers.set('x-sug-country', 'FR')
    return response
}

This will just simply add a response header and return it, refresh your browser and check the headers, our recently added “x-sug-country” should be there:

For this demo, I’ve created some simple pages:

– Pages
|- about
|- aboutnew
|- index
|- featureflag
|- featureflags
|- abtest
|- index

A/B Testing

The idea was to do some A/B testing on the about page, so for doing that I’ve used ConfigCat an easy-to-use tool for managing feature-flags enablement, that also has some options to target audience, so I’ve created my “newAboutPage” flag with 50% option:

The following code is what we need to place in our middleware, and it will basically get the value flag value from ConfigCat and store it in a cookie. Note the usage here of URL Redirects and Rewrites, cookies management, feature flags, and of course, A/B testing, all running as a middleware function, that when deployed to Vercel will be executed on the edge network, close to the visitor origin, with close to zero latency.

export function middleware (req: NextRequest) {  
    if (req.nextUrl.pathname.startsWith('/about')) {
        const url = req.nextUrl.clone()

        // Redirect paths that go directly to the variant
        if (url.pathname != '/about') {
            url.pathname = '/about'
            return NextResponse.redirect(url)
        }

        const cookie = req.cookies.get(ABOUT_COOKIE_NAME) || (getValue('newaboutpage') ? '1' : '0')

        url.pathname = cookie === '1' ? '/about/aboutnew' : '/about'

        const res = NextResponse.rewrite(url)

        // Add the cookie if it's not there
        if (!req.cookies.get(ABOUT_COOKIE_NAME)) {
            res.cookies.set(ABOUT_COOKIE_NAME, cookie)
        }

        return res
      }
...

Let’s test it and by clicking on “Remove Cookie and Reload” you’ll be getting both variants with 50% probability:

Feature flags

In the demo, I’ve also added the features flag page where I’m rendering or hiding some components depending on their flag enablement, again by using ConfigCat for this:

The “sugconfr” flag, that you can see it’s disabled, and the “userFromFrance” that also checks the country parameter and only returns true if it’s France, so we can see here how easy we can personalize based on geolocation.

Let’s have a look at the code we’ve added to the middleware:

export function middleware (req: NextRequest) {  
    if (req.nextUrl.pathname.startsWith('/about')) {
        ...
      }

      if (req.nextUrl.pathname.startsWith('/featureflag')) {
        const url = req.nextUrl.clone()
  
        // Fetch user Id from the cookie if available 
        const userId = req.cookies.get(COOKIE_NAME_UID) || crypto.randomUUID()
        const country = req.cookies.get(COOKIE_NAME_COUNTRY) || req.geo?.country
        const sugfr = req.cookies.get(COOKIE_NAME_SUGFR) || (getValue(COOKIE_NAME_SUGFR) ? '1' : '0')

        const res = NextResponse.rewrite(url)
        
        // Add the cookies if those are not there
        if (!req.cookies.get(COOKIE_NAME_COUNTRY)) {
            res.cookies.set(COOKIE_NAME_COUNTRY, country)
        }

        if (!req.cookies.get(COOKIE_NAME_UID)) {
            res.cookies.set(COOKIE_NAME_UID, userId)
        }

        if (!req.cookies.get(COOKIE_NAME_SUGFR)) {
            res.cookies.set(COOKIE_NAME_SUGFR, sugfr)
        }

        return res
      }

Again, we get the values and store it in cookies. Then we use the feature flags to show or hide some components, as we can see here:

If we load the page with the “sugconfr” disabled, we will get this:

So, let’s enable it back from ConfigCat, publish the changes and reload the page:

Now the page looks different, the SUGFR component is showing up. As you can see, the other component where we have chosen to enable only for users coming from France is still not showing, this is because we are testing from localhost so of course, there is no geolocation data coming from the request. So, let’s deploy this app to Vercel so we can also test this part and check how it looks running in the Edge.

Deploying the Edge Middleware to Vercel

As I have my Vercel app installed in my GitHub repo, the integration is so simple that we can just push those changes to the repo and wait for Vercel to deploy it. For details on how to set this up, please check my previous post about Deploying a Sitecore JSS-Next.js App with SSG & ISR to Vercel (from zero to live)

Note: make sure you add the ConfigCat API Key to the environment variables in Vercel before deploying:

If you have a look at the deployment logs, you will see that it created the edge function based on our middleware:

If we check the site now, as we are now getting geolocation data from the user’s request, the component is showing up there:

You can check logs by going to functions sections from the Vercel dashboard, which is really cool for troubleshooting purposes:

This was just a quick example of how to start using this middleware feature from Next.js and Vercel’s Edge Network, which enable us to move some backend code from the server to the edge, making those calls super fast with almost no latency. Now that is already stable we can start implementing those for our clients, there are multiple usages, another quick example where we can implement those are for resolving multisite by hostnames for our Sitecore JSS/Next.js implementation.

You can find the example app code here in this GitHub repo. The app is deployed to Vercel and accessible here.

There is a great “getting started” video from Thomas Desmond, check it!

You can find a lot of great examples from Vercel Edge Functions repo as well and of course the official documentation.

I hope you enjoyed this reading and don’t wait, go and have fun with middleware!