Multi-Tenant Platform Quickstart
Watch the walkthrough on .
Start by building a Next.js app that serves two tenants, Acme and Globex, on your own computer. You don't need a domain for this part, only Node.js 22 or later. The app uses Next.js 16, which create-next-app@latest installs for you.
npx create-next-app@latest my-platform --yes
cd my-platformThis command creates the app in a new my-platform folder, so you can run it from any folder, even one that already has files in it.
If you already have a Next.js 16 app that uses the App Router, you can skip this step and add the three files from steps 2 to 4 to your app. The files use relative imports, so they need no other setup. If your app uses a src folder, put proxy.ts and lib/ in src.
Next, add a list of tenants. Create lib/tenants.ts. Both the proxy and the tenant page read from this file:
// We keep the tenants in a Map to keep this example small. In your platform,
// you'd replace this with a database or Global Config lookup.
// Note that we use a Map instead of a plain object, so that a host such as
// "constructor" doesn't match a built-in object property.
const tenants = new Map([
['acme', { name: 'Acme' }],
['globex', { name: 'Globex' }],
]);
export function getTenant(name: string) {
return tenants.get(name) ?? null;
}Next, create proxy.ts in the project root, next to package.json (or in src/ if your app uses a src folder). Proxy is the Next.js 16 name for Middleware, so on Next.js 15 and earlier, name the file middleware.ts and the function middleware instead.
The proxy runs before every request and decides where the request goes. It sends the root domain to your own pages, returns a 404 for an unknown tenant, and rewrites a tenant's request to that tenant's page:
import { NextRequest, NextResponse } from 'next/server';
import { getTenant } from './lib/tenants';
// This is your platform's own domain, such as yourapp.com.
// We default to localhost:3000 so that the app works locally out of the box.
const ROOT_DOMAIN = process.env.ROOT_DOMAIN ?? 'localhost:3000';
export function proxy(request: NextRequest) {
const host = request.headers.get('host') ?? '';
const { pathname } = request.nextUrl;
// The /sites routes are internal, so we return a 404 when a visitor
// tries to open them directly.
if (pathname === '/sites' || pathname.startsWith('/sites/')) {
return new NextResponse('Not found', { status: 404 });
}
// Requests to the root domain go to your own pages, such as your
// marketing site and dashboard.
if (host === ROOT_DOMAIN || host === `www.${ROOT_DOMAIN}`) {
return NextResponse.next();
}
// Here we find the tenant name. For acme.yourapp.com, the tenant is "acme".
// For a custom domain, such as acme.com, we use the full host as the name.
const suffix = `.${ROOT_DOMAIN}`;
const tenant = host.endsWith(suffix) ? host.slice(0, -suffix.length) : host;
// Note that we return the 404 here in the proxy, before any page renders.
// A page that calls notFound() inside Suspense still responds with status 200.
if (!getTenant(tenant)) {
return new NextResponse('Not found', { status: 404 });
}
// We clone nextUrl instead of building a new URL, so that the query
// string, such as ?page=2, stays on the request.
const url = request.nextUrl.clone();
url.pathname = `/sites/${tenant}${pathname}`;
return NextResponse.rewrite(url);
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};The proxy rewrites each tenant's request to /sites/<tenant>, so the next step adds the page for that route. Create app/sites/[tenant]/page.tsx:
import { Suspense } from 'react';
import { getTenant } from '../../../lib/tenants';
async function TenantName({ params }: { params: Promise<{ tenant: string }> }) {
const { tenant } = await params;
return <h1>Welcome to {getTenant(decodeURIComponent(tenant))?.name}</h1>;
}
// We don't check for an unknown tenant here, since the proxy already
// returned a 404 for those.
// Note that we wrap the content in Suspense, since this page reads params.
// When Cache Components is on, next build fails without it.
export default function TenantPage({
params,
}: {
params: Promise<{ tenant: string }>;
}) {
return (
<Suspense>
<TenantName params={params} />
</Suspense>
);
}With the three files in place, run npm run dev, then open these addresses in your browser. Chrome and Firefox send every *.localhost name to your computer, so these addresses work with no extra setup. In other browsers, add the names to /etc/hosts first, for example 127.0.0.1 acme.localhost.
| Address | Expected result |
|---|---|
http://localhost:3000 | Your landing page |
http://acme.localhost:3000 | The page shows "Welcome to Acme" |
http://globex.localhost:3000 | The page shows "Welcome to Globex" |
http://nope.localhost:3000 | 404 |
You can also check from a terminal by sending the host name in a Host header. The first command should print 200, and the second should print Acme. Note that we search for Acme alone, since React splits the sentence in the HTML as Welcome to <!-- -->Acme.
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: acme.localhost:3000" http://127.0.0.1:3000/
curl -s -H "Host: acme.localhost:3000" http://127.0.0.1:3000/ | grep -o Acme | head -1Note that the proxy compares the full host name, including the port, so use the addresses exactly as above. If you run the app on another port, set ROOT_DOMAIN to match, for example ROOT_DOMAIN=localhost:3001.
When the app works locally, you're ready to deploy it. First, link the folder to a Edgenivo project with npx vercel link (it asks you to log in first if needed). Then set ROOT_DOMAIN to your domain with npx vercel env add ROOT_DOMAIN production, and deploy with npx vercel deploy --prod.
Note that the deployment's *.vercel.app URL returns a 404, since that host is neither your domain nor a tenant. You'll test the app with your own domain after you set up domains in the next section.
After the app works locally, set up domains for your tenants. There are two main approaches:
If you want to offer subdomains like tenant.yourapp.com, you'll need to:
- Configure wildcard domains for subdomains like
*.yourapp.com
If you want to allow tenants to use their own domains, you'll need to:
- Enable custom domain support for your tenants
- Add domains programmatically using the Edgenivo SDK
- Verify domain ownership with TXT records
- Configure domain redirects and handle SEO
- Clean up domains when tenants leave
You can implement either approach or both depending on your needs.
If you plan on offering subdomains like *.yourapp.com, add a wildcard domain to your Edgenivo project. Edgenivo needs access to DNS challenges to issue and renew wildcard SSL certificates. To configure the domain with Edgenivo's nameservers:
- Point your domain to Edgenivo's nameservers (
ns1.vercel-dns.comandns2.vercel-dns.com). - In your Edgenivo project settings, add the apex domain (e.g.,
yourapp.com). - Add a wildcard domain:
*.yourapp.com.
If you can't change your domain's nameservers, follow Use wildcard domains with an external DNS provider to delegate certificate validation and configure wildcard traffic routing.
Now, any tenant subdomain you create, such as tenant1.yourapp.com or tenant2.yourapp.com, resolves to your Edgenivo deployment. Edgenivo issues one wildcard certificate for *.yourapp.com that covers every tenant subdomain at that level, so you don't need a certificate per tenant. A wildcard certificate covers only one subdomain level. To serve a deeper subdomain such as docs.tenant1.yourapp.com over HTTPS, add a wildcard domain for that level, such as *.tenant1.yourapp.com, which gets its own wildcard certificate.
If tenants can publish content or run code on your subdomains, submit your shared domain to the Public Suffix List so browsers isolate cookies between tenants for improved security. Follow the steps for protecting tenant subdomains with the Public Suffix List.
The following steps guide you through setting up custom domain support for your tenants. This allows them to use their own domains (e.g., customacmesite.com) instead of subdomains.
You can also give tenants the option to bring their own domain. In that case, you'll want your code to:
- Provision and assign the tenant's domain to your Edgenivo project.
- Verify the domain (to ensure the tenant truly owns it).
- Automatically generate an SSL certificate.
You can add a new domain through the Edgenivo SDK. For example:
import { VercelCore as Edgenivo } from '@vercel/sdk/core.js';
import { projectsAddProjectDomain } from '@vercel/sdk/funcs/projectsAddProjectDomain.js';
const vercel = new Edgenivo({
bearerToken: process.env.VERCEL_TOKEN,
});
// The 'idOrName' is your project name in Edgenivo, for example: 'multi-tenant-app'
await projectsAddProjectDomain(vercel, {
idOrName: 'my-multi-tenant-app',
teamId: 'team_1234',
requestBody: {
// The tenant's custom domain
name: 'customacmesite.com',
},
});To serve the tenant's content on that domain, add it to the tenant list in lib/tenants.ts, for example ['customacmesite.com', { name: 'Acme' }]. Once the domain is added, Edgenivo attempts to issue an SSL certificate automatically. Each custom domain gets its own certificate, separate from your wildcard certificate.
If the domain is already in use on Edgenivo, the user needs to set a TXT record to prove ownership of it.
You can check the verification status and trigger manual verification:
import { VercelCore as Edgenivo } from '@vercel/sdk/core.js';
import { projectsGetProjectDomain } from '@vercel/sdk/funcs/projectsGetProjectDomain.js';
import { projectsVerifyProjectDomain } from '@vercel/sdk/funcs/projectsVerifyProjectDomain.js';
const vercel = new Edgenivo({
bearerToken: process.env.VERCEL_TOKEN,
});
const domain = 'customacmesite.com';
const [domainResponse, verifyResponse] = await Promise.all([
projectsGetProjectDomain(vercel, {
idOrName: 'my-multi-tenant-app',
teamId: 'team_1234',
domain,
}),
projectsVerifyProjectDomain(vercel, {
idOrName: 'my-multi-tenant-app',
teamId: 'team_1234',
domain,
}),
]);
const { value: result } = verifyResponse;
if (!result?.verified) {
console.log(`Domain verification required for ${domain}.`);
// You can prompt the tenant to add a TXT record or switch nameservers.
}
// Note that `verified` only proves that the tenant owns the domain. Before you
// tell the tenant that the domain works, we also check that its DNS points to Edgenivo:
// import { domainsGetDomainConfig } from '@vercel/sdk/funcs/domainsGetDomainConfig.js';
// const { value: config } = await domainsGetDomainConfig(vercel, { domain, teamId: 'team_1234' });
// When config?.misconfigured is false, the domain serves your app.Some tenants might want www.customacmesite.com to redirect automatically to their apex domain customacmesite.com, or the other way around.
- Add both
customacmesite.comandwww.customacmesite.comto your Edgenivo project. - Configure a redirect for
www.customacmesite.comto the apex domain by settingredirect: customacmesite.comthrough the API or your Edgenivo dashboard.
This ensures a consistent user experience and prevents issues with duplicate content.
If you offer both tenant.yourapp.com and customacmesite.com for the same tenant, you may want to redirect the subdomain to the custom domain (or vice versa) to avoid search engine duplicate content. Alternatively, set a canonical URL in your HTML <head> to indicate which domain is the "official" one.
If a tenant cancels or no longer needs their custom domain, you can remove it from your Edgenivo account using the SDK:
import { VercelCore as Edgenivo } from '@vercel/sdk/core.js';
import { projectsRemoveProjectDomain } from '@vercel/sdk/funcs/projectsRemoveProjectDomain.js';
import { domainsDeleteDomain } from '@vercel/sdk/funcs/domainsDeleteDomain.js';
const vercel = new Edgenivo({
bearerToken: process.env.VERCEL_TOKEN,
});
await Promise.all([
projectsRemoveProjectDomain(vercel, {
idOrName: 'my-multi-tenant-app',
teamId: 'team_1234',
domain: 'customacmesite.com',
}),
domainsDeleteDomain(vercel, {
teamId: 'team_1234',
domain: 'customacmesite.com',
}),
]);The first call disassociates the domain from your project, and the second removes it from your account entirely.
Was this helpful?