Putting Next Multi-Tenant apps in Context

Software is like entropy: It is difficult to grasp, weighs nothing, and obeys the Second Law of Thermodynamics; i.e., it always increases.
As a web engineer, the journey of continuous learning is both thrilling and rewarding. This past week, I delved into the world of Next.js multi-tenant applications, uncovering how this innovative app structure can elevate my personal projects and career growth.
I had encountered the concept of multi-tenancy before, as it is not confined to Next.js alone. What initially sounded complex in JS site infrastructure revealed itself to be fundamentally simple as I set a course for the raging seas of Vercel learning.
Multi-tenancy is the pattern of serving multiple clients from a single codebase. A straightforward example of this might be having two websites for two different versions of games in a franchise. Whilst the content may be different and the aesthetics varied, there are code and components that the two could share, like navigation, utility functions, component behaviour and so on.
Setting it up...
Unless you have lived under a rock whilst coding in Next, you will be very aware of Middleware and its wide range of uses across applications. Middleware allows the app to run code before a request is completed. Then, based on the incoming request, you can modify the response by rewriting, redirecting, modifying the request or response headers, or responding directly. You could apply this to A/B testing, redirects, header modification, and other use cases.
For multi-tenant apps, you can use Middleware to read the host and then, using App Router, funnel the request to a given directory in your app folder.
export async function middleware(request: NextRequest) {
// Get host and path data
const url = request.nextUrl;
const hostname = request.headers.get("host");
const baseDomain = process.env.BASE_DOMAIN;
let pathname = url.pathname;
// Get the host
const currentHost = hostname?.replace(`${baseDomain}`, "");
// [Perform some validation on the hosts / locales etc]
// Rewrite the request to match up with the folder structure inside Next App router
const subdomain = currentHost;
const response = NextResponse.rewrite(new URL(`/${subdomain}${pathname}`, request.url));
return response
}
middleware.ts
The above code does not redirect the visitor but simply tells the Next app router how to service the request. In this example, a request to a.mydomain.com/some-page
will have the path rewritten to /a.mydomain.com/some-page
, and therefore, your Next app router folder structure should match this.
- app/
- (sites)/
- a.mydomain.com/
- some-page/
- page.tsx
- some-page/
- b.mydomain.com/
- some-page/
- page.tsx
- some-page/
- a.mydomain.com/
- (sites)/
- middleware.ts
From here, you can start building your site the same way you would with any standard Next App router approach. Share layouts and wrappers as you see fit. The sites are not required to have the same structure or content, as they can be as independent as you want them to be.
Component Context
As you build the sites out, you might start finding commonalities between them. When this happens, it might be worth considering sharing a component like a button or navigation between the two sites. It is unlikely that these components will be identical, so the component needs to know the context in which it is being used and then adjust itself accordingly.
You could do this by passing a prop, which is the most straightforward implementation, but would quickly become annoying every time you used it.
<MySharedComponent tenant="a" />
export function MySharedComponent({tenant: Tenant}):MySharedComponentProps {
return (
<div className={`${styles.root} ${tenant === "a" ? styles.a : styles.b}`} />
)
}
ComponentWithTenantProp.tsx
What we really need is a Context applied to each tenant that the shared component can read to identify where it is being used. And so we introduce React's useContext.
'use client'
import {createContext, ReactNode, useContext} from 'react'
const TenantContext = createContext<string | null>(null)
export const useTenant = () => useContext(TenantContext)
export function TenantProvider({tenant, children}: { tenant: Tenant, children: ReactNode }) {
return <TenantContext.Provider value={tenant}>{children}</TenantContext.Provider>
}
TenantContext.tsx
From here, we now have a ContextProvider we can wrap the layout.tsx content with, and then the individual components can call using useContext. Now we have:
// layout.tsx
return (
<TenantProvider tenant={tenantId}>
<html lang="en">
<body>
<header></header>
<main>
{children}
</main>
<footer>© Site Owner</footer>
</body>
</html>
</TenantProvider>
)
// MySharedComponent.tsx
export function MySharedComponent({...rest}):MySharedComponentProps {
const tenant = useTenant();
return (
<div className={`${styles.root} ${tenant === "a" ? styles.a : styles.b}`} />
)
}
Using Context Provider
So success?
Not quite—while this will work with Client Components, Server Components will be greeted with "Attempted to call useTenant() from the server, but useTenant is on the client."
We should fix this, as Next now defaults to Server Components. So we need another mechanism to support the server component context so that we return to the Middleware. Essentially, we have two choices: cookies or headers. We can use Middleware to set these, and server components will have access. We can even update the layout file to read them and set the context based on them to reduce duplicated logic. The question we must ask ourselves is, headers or cookies? I will admit that headers sound better off the bat, but I am also using Storybook in this project. And what does Storybook do with server components? Yes, it renders them as client components executing at run-time, meaning that access to headers would be awkward.
At this point, I realised that whilst cookies work in principle, I cannot use Next cookies inside Storybook at run time, so I made a new function for my server components as follows:
// middleware.ts
const response = NextResponse.rewrite(new URL(`/${subdomain}${pathname}`, request.url));
response.cookies.set({ name: 'x-tenant-id', value: currentHost, path: '/' })
return response
// getproduct.ts
import {cookies} from 'next/headers'
export async function getProduct() {
// If we have access to the document use it to return faster
if(typeof document !== "undefined") {
const tenant = getClientCookie("x-tenant-id") || "default";
return tenant;
}
// Otherwise use next cookies and await the response
const cookieStore = await cookies();
const productCookie = cookieStore.get('x-tenant-id');
return productCookie?.value || 'default';
}
function getClientCookie(key: string) {
const b = document.cookie.match("(^|;)\\s*" + key + "\\s*=\\s*([^;]+)");
return b ? b.pop() : "";
}
// MySharedComponent.tsx
export function MySharedComponent({...rest}):MySharedComponentProps {
const tenant = getProduct();
return (
<div className={`${styles.root} ${tenant === "a" ? styles.a : styles.b}`} />
)
}
Server component and function for getting the current tenant context from cookies
From here the server component will now correctly pull the context from the cookie in the app and then all we need to do is add a decorator and toolbar option to Storybook to allow it to work there too.
//preview.ts
import type { Preview } from '@storybook/react'
import {withProduct} from "./decorators/withProduct";
const preview: Preview = {
decorators: [
withProduct
],
globalTypes: {
product: {
name: 'Product',
description: 'Select the product',
toolbar: {
icon: 'wrench',
items: [
{ value: 'a', title: 'Product A' },
{ value: 'b', title: 'Product B' },
],
showName: true,
},
},
},
}
//decorators/withProduct.tsx
import {DecoratorFunction} from "@storybook/core/csf";
import {RsStyles} from "./test";
import {OsStyles} from "./test2";
export const withProduct: DecoratorFunction = (Story, context) => {
const { product } = context.globals;
document.cookie = `x-tenant-id=${product}`;
return (
<>
<Story />
</>
);
};
Storybook cookie context implementation
The above configuration allows the developer to flip context in runtime inside Storybook to view their changes in real-time without navigating away or changing a prop! That, is simply lovely!
There are definitely ways this could be improved and alternative approaches to this problem but I found this a reasonable compromise between effectiveness and simplicity.