Multisite support for Sitecore JSS - Next.js using Vercel's Edge Middleware - Demo on Sitecore Demo Portal (XM + Edge)!
Back to home

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

Miguel Minoldo's picture
Miguel Minoldo

Update!! - Sitecore JSS v21

As I noticed ongoing traffic to this blog post, I feel it's crucial to address an important update. Please note that the approach described in this article was developed prior to the introduction of the Next.js JSS SDK's multi-site addon. Therefore, I highly recommend referring to the official SDK implementation from version 21 instead.

For comprehensive details, please consult the official documentation.

However, if you're still intrigued by the approach discussed in this article, feel free to proceed and continue reading!

Update - 2!! - Next.js Middleware upgrade

As of the time of writing this article, the middleware discussed herein was still in its beta phase. However, it's crucial to consider a few key aspects when working with newer versions of Next.js that have reached General Availability (GA).

One significant change to the implementation outlined in this article is the file naming convention. Specifically, the file should no longer start with an underscore (e.g., middleware.ts/js), and it must possess a unique name at the root level. Furthermore, it is advisable to utilize a matcher for path handling.

For detials and an up-to-date information on these changes, please refer to the official documentation.

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

Blog post image
Click to expand

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.

javascript
1import { NextRequest, NextResponse } from 'next/server'
2import { getHostnameDataOrDefault } from './lib/multisite/sites'
3export const config = {
4 matcher: ['/', '/_sites/:path'],
5}
6export default async function middleware(req: NextRequest): Promise<NextResponse> {
7 const url = req.nextUrl.clone();
8 // Get hostname (e.g. vercel.com, test.vercel.app, etc.)
9 const hostname = req.headers.get('host');
10 // If localhost, assign the host value manually
11 // If prod, get the custom domain/subdomain value by removing the root URL
12 // (in the case of "test.vercel.app", "vercel.app" is the root URL)
13 const currentHost =
14 //process.env.NODE_ENV === 'production' &&
15 hostname?.replace(`.${process.env.ROOT_DOMAIN}`, '');
16 const data = await getHostnameDataOrDefault(currentHost?.toString());
17 // Prevent security issues – users should not be able to canonically access
18 // the pages/sites folder and its respective contents.
19 if (url.pathname.startsWith(`/_sites`)) {
20 url.pathname = `/404`
21 } else {
22 // rewrite to the current subdomain
23 url.pathname = `/_sites/${data?.subdomain}${data?.siteName}${url.pathname}`;
24 }
25
26 return NextResponse.rewrite(url);
27}

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)

Blog post image
Click to expand
[[...path]].tsx

[[...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.

Blog post image
Click to expand
normal-mode.ts

normal-mode.ts

Blog post image
Click to expand
normal-mode.ts

fictionary-service-factory.ts

Blog post image
Click to expand
layout-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:

javascript
1// This function gets called at build and export time to determine
2// pages for SSG ("paths", as tokenized array).
3export const getStaticPaths: GetStaticPaths = async (context) => {
4 ...
5 if (process.env.NODE_ENV !== 'development') {
6 // Note: Next.js runs export in production mode
7 const sites = (await getSitesPaths()) as unknown as Site[];
8 const pages = await sitemapFetcher.fetch(sites, context);
9 const paths = pages.map((page) => ({
10 params: { site: page.params.site, path: page.params.path },
11 locale: page.locale,
12 }));
13 return {
14 paths,
15 fallback: process.env.EXPORT_MODE ? false : 'blocking',
16 };
17 }
18 return {
19 paths: [],
20 fallback: 'blocking',
21 };
22};

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

Blog post image
Click to expand
Site.ts

Site.ts





Blog post image
Click to expand
StaticPathExt.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.

javascript
1import { GetStaticPathsContext } from 'next';
2import * as plugins from 'temp/sitemap-fetcher-plugins';
3import { StaticPathExt } from 'lib/type/StaticPathExt';
4import Site from 'lib/type/Site';
5export interface SitemapFetcherPlugin {
6 /**
7 * A function which will be called during page props generation
8 */
9 exec(sites?: Site[], context?: GetStaticPathsContext): Promise<StaticPathExt[]>;
10}
11export class SitecoreSitemapFetcher {
12 /**
13 * Generates SitecoreSitemap for given mode (Export / Disconnected Export / SSG)
14 * @param {GetStaticPathsContext} context
15 */
16 async fetch(sites: Site[], context?: GetStaticPathsContext): Promise<StaticPathExt[]> {
17 const pluginsList = Object.values(plugins) as SitemapFetcherPlugin[];
18 const pluginsResults = await Promise.all(
19 pluginsList.map((plugin) => plugin.exec(sites, context))
20 );
21 const results = pluginsResults.reduce((acc, cur) => [...acc, ...cur], []);
22 return results;
23 }
24}
25export 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 {
let paths = new Array();
for (let i = 0; i ({
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).

Blog post image
Click to expand
Sitecore Demo Portal instance

Sitecore Demo Portal instance

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

Blog post image
Click to expand
Sitecore Demo Portal

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://[sitecore-demo-instance]/sitecore/api/graph/edge/ui

Blog post image
Click to expand
GraphQL playground from XM

GraphQL playground from XM

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

Blog post image
Click to expand
GraphQL playground from Experience Edge

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:

1127.0.0.1 www.multisite_poc_global.localhost
2127.0.0.1 www.multisite_poc_uk.localhost
Blog post image
Click to expand
npm run start:connected

npm run start:connected

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

Blog post image
Click to expand
UK Site

UK Site

Blog post image
Click to expand
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:

Blog post image
Click to expand
npm run next:build

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:
Blog post image
Click to expand
Vercel custom domains

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!

Blog post image
Click to expand
Blog post image
Click to expand
Blog post image
Click to expand

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

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

Blog post image
Click to expand
Global site

Global site

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

Blog post image
Click to expand
UK site

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!