Including multi-tenant config into tRPC context
Hey all,
I've been working on upgrading my app to support multi-tenancy, inspired by Vercel's Platforms starter kit.
The core of the relevant logic comes down to this Next.js middleware:
This middleware is set up to explicitly exclude requests to
Now, within my tRPC router, it is helpful in certain places to be able to access the
I have been thinking about how to get this be part of tRPC's context, but I'm a bit stuck
Currently my context looks like this (i have a mix of HTTP and WebSockets)
Since the middleware ignores requests to
I've been working on upgrading my app to support multi-tenancy, inspired by Vercel's Platforms starter kit.
The core of the relevant logic comes down to this Next.js middleware:
export const config = {
matcher: [
/*
* Match all paths except for:
* 1. /api routes
* 2. /_next (Next.js internals)
* 3. /_static (inside /public)
* 4. all root files inside /public (e.g. /favicon.ico)
* 5. All files inside subdirectories of /public/static
*/
'/((?!api/|_next/|_static/|static/|_vercel|[\\w-]+\\.\\w+).*)',
],
};
export default function middleware(req: NextRequest) {
const url = req.nextUrl;
// Get hostname of request (e.g. demo.vercel.pub, demo.localhost:3000)
const hostname = req.headers
.get('host')!
.replace('.localhost:3000', `.${ROOT_DOMAIN}`);
const searchParams = req.nextUrl.searchParams.toString();
const path = `${url.pathname}${
searchParams.length > 0 ? `?${searchParams}` : ''
}`;
// Rewrite root to `/app`
if (hostname === 'localhost:3000' || hostname === ROOT_DOMAIN) {
return NextResponse.rewrite(
new URL(`/app${path === '/' ? '' : path}`, req.url),
);
}
const tenantSlug = hostname.replace(`.${ROOT_DOMAIN}`, '');
// Rewrite everything else to `/[tenantSlug]`
return NextResponse.rewrite(new URL(`/${tenantSlug}${path}`, req.url));
}export const config = {
matcher: [
/*
* Match all paths except for:
* 1. /api routes
* 2. /_next (Next.js internals)
* 3. /_static (inside /public)
* 4. all root files inside /public (e.g. /favicon.ico)
* 5. All files inside subdirectories of /public/static
*/
'/((?!api/|_next/|_static/|static/|_vercel|[\\w-]+\\.\\w+).*)',
],
};
export default function middleware(req: NextRequest) {
const url = req.nextUrl;
// Get hostname of request (e.g. demo.vercel.pub, demo.localhost:3000)
const hostname = req.headers
.get('host')!
.replace('.localhost:3000', `.${ROOT_DOMAIN}`);
const searchParams = req.nextUrl.searchParams.toString();
const path = `${url.pathname}${
searchParams.length > 0 ? `?${searchParams}` : ''
}`;
// Rewrite root to `/app`
if (hostname === 'localhost:3000' || hostname === ROOT_DOMAIN) {
return NextResponse.rewrite(
new URL(`/app${path === '/' ? '' : path}`, req.url),
);
}
const tenantSlug = hostname.replace(`.${ROOT_DOMAIN}`, '');
// Rewrite everything else to `/[tenantSlug]`
return NextResponse.rewrite(new URL(`/${tenantSlug}${path}`, req.url));
}This middleware is set up to explicitly exclude requests to
/api/api Now, within my tRPC router, it is helpful in certain places to be able to access the
tenantSlugtenantSlug value. I could go and change all my tRPC queries/mutations to take that as input and change client-side code everywhere but that would be hundreds of functions and even more calls in the client code.I have been thinking about how to get this be part of tRPC's context, but I'm a bit stuck
Currently my context looks like this (i have a mix of HTTP and WebSockets)
export async function createContext(
ctx: CreateWSSContextFnOptions | CreateNextContextOptions,
): Promise<{
session: Session | null;
req: IncomingMessage | NextApiRequest;
res: ws | NextApiResponse;
prisma: ExtendedPrismaClient;
}> {
const { req, res } = ctx;
let session: Session | null;
try {
// @ts-expect-error -- This will fail if called client side
session = await getServerSession(req, res, getAuthOptions(req));
} catch (error) {
session = await getSession(ctx);
}
const contextInner = await createContextInner();
return {
...contextInner,
session,
req,
res,
};
}export async function createContext(
ctx: CreateWSSContextFnOptions | CreateNextContextOptions,
): Promise<{
session: Session | null;
req: IncomingMessage | NextApiRequest;
res: ws | NextApiResponse;
prisma: ExtendedPrismaClient;
}> {
const { req, res } = ctx;
let session: Session | null;
try {
// @ts-expect-error -- This will fail if called client side
session = await getServerSession(req, res, getAuthOptions(req));
} catch (error) {
session = await getSession(ctx);
}
const contextInner = await createContextInner();
return {
...contextInner,
session,
req,
res,
};
}Since the middleware ignores requests to
/api/api - the reqreq object in context (in case of HTTP calls) doesn't include the subdomain/slug in the rquest headers. I figured someone here had to have run into this in the past and may have figured out a solutin already - so any help is appreciated!Solution
For anyone who stumbles upon this later, my fix for now is to do this:
export async function createContext(
ctx: CreateWSSContextFnOptions | CreateNextContextOptions,
): Promise<{
session: Session | null;
req: IncomingMessage | NextApiRequest;
res: ws | NextApiResponse;
prisma: ExtendedPrismaClient;
tenant: string | null;
}> {
const { req, res } = ctx;
const rewriteHeader = req.headers['x-nextjs-rewrite'] as string | undefined;
const tenant = rewriteHeader?.split('tenant=')[1] ?? null;
let session: Session | null;
try {
// @ts-expect-error -- This will fail if called client side
session = await getServerSession(req, res, getAuthOptions(req));
} catch (error) {
session = await getSession(ctx);
}
const contextInner = await createContextInner();
return {
...contextInner,
session,
req,
res,
tenant: tenant ?? contextInner.tenant,
};
}
/** Use this helper for:
* - trpc's `createSSGHelpers` where we don't have req/res
* */
export async function createContextInner(opts?: CreateInnerContextOptions) {
if (!opts) {
return {
session: null,
prisma,
tenant: null,
};
}
if (opts.ctx) {
const rewriteHeader = opts.ctx.req.headers['x-nextjs-rewrite'] as
| string
| undefined;
const tenant = rewriteHeader?.split('tenant=')[1] ?? null;
let session: Session | null;
try {
session = await getServerSession(
opts.ctx.req,
opts.ctx.res,
// @ts-expect-error -- This will fail if called client side
getAuthOptions(opts.ctx.req),
);
} catch (error) {
session = await getSession(opts.ctx);
}
return {
session,
prisma,
tenant,
};
}
return {
session: null,
prisma,
tenant: null,
};
}export async function createContext(
ctx: CreateWSSContextFnOptions | CreateNextContextOptions,
): Promise<{
session: Session | null;
req: IncomingMessage | NextApiRequest;
res: ws | NextApiResponse;
prisma: ExtendedPrismaClient;
tenant: string | null;
}> {
const { req, res } = ctx;
const rewriteHeader = req.headers['x-nextjs-rewrite'] as string | undefined;
const tenant = rewriteHeader?.split('tenant=')[1] ?? null;
let session: Session | null;
try {
// @ts-expect-error -- This will fail if called client side
session = await getServerSession(req, res, getAuthOptions(req));
} catch (error) {
session = await getSession(ctx);
}
const contextInner = await createContextInner();
return {
...contextInner,
session,
req,
res,
tenant: tenant ?? contextInner.tenant,
};
}
/** Use this helper for:
* - trpc's `createSSGHelpers` where we don't have req/res
* */
export async function createContextInner(opts?: CreateInnerContextOptions) {
if (!opts) {
return {
session: null,
prisma,
tenant: null,
};
}
if (opts.ctx) {
const rewriteHeader = opts.ctx.req.headers['x-nextjs-rewrite'] as
| string
| undefined;
const tenant = rewriteHeader?.split('tenant=')[1] ?? null;
let session: Session | null;
try {
session = await getServerSession(
opts.ctx.req,
opts.ctx.res,
// @ts-expect-error -- This will fail if called client side
getAuthOptions(opts.ctx.req),
);
} catch (error) {
session = await getSession(opts.ctx);
}
return {
session,
prisma,
tenant,
};
}
return {
session: null,
prisma,
tenant: null,
};
}