Skip to main content
All posts
  • Next.js
  • Vercel
  • Multi-tenant
  • DNS

Multi-Tenant Subdomains and Custom Domains in Next.js (App Router + Vercel)

How I serve every user's portfolio from their own subdomain or custom domain in one Next.js app — middleware rewrites, DNS verification, Vercel's domains API and the SEO traps.

SK
Santosh Kumar

· 6 min read

On this page

When I turned my portfolio site into a portfolio builder, the hardest requirement wasn't the editor or the themes. It was addresses: every user should get their-name.santoshkumar.site the moment they sign up, and be able to connect their-name.com later, while all of it runs as one Next.js app on one Vercel project.

This post walks through how that works in production: the routing, the domain verification flow, and the SEO mistakes I made along the way so you don't have to.

The shape of the problem#

There are three kinds of hostnames hitting the same deployment:

Host Example Should render
Root domain santoshkumar.site The marketing site, dashboard, auth pages
Wildcard subdomain jane.santoshkumar.site Jane's portfolio
Custom domain janedoe.dev Jane's portfolio

Internally, every portfolio lives at a single dynamic route: app/p/[username]/page.tsx. The job is to map any incoming host onto that route without the visitor ever seeing /p/jane in the address bar.

Step 1: Wildcard DNS and a wildcard domain on Vercel#

Two pieces of setup, done once:

  1. In the DNS provider, add a wildcard record so *.santoshkumar.site points at Vercel.
  2. In the Vercel project, add *.santoshkumar.site as a domain. Vercel issues the wildcard TLS certificate (this requires using Vercel's nameservers for the apex domain).

After that, every subdomain already reaches your app. Nothing in your code has changed yet. It's just that every host currently renders the same homepage.

Step 2: Rewrite subdomains in middleware#

Next.js middleware runs before routing, sees the host header, and can rewrite the request to a different path. A rewrite is invisible to the visitor: the URL stays jane.santoshkumar.site, but Next.js renders /p/jane.

middleware.ts
import { NextRequest, NextResponse } from "next/server";

const ROOT = "santoshkumar.site";

export function middleware(req: NextRequest) {
  const host = (req.headers.get("host") ?? "").split(":")[0];
  const { pathname } = req.nextUrl;

  if (host !== ROOT && host.endsWith(`.${ROOT}`)) {
    const subdomain = host.slice(0, -(ROOT.length + 1));
    if (subdomain !== "www") {
      const url = req.nextUrl.clone();
      url.pathname = `/p/${subdomain}${pathname === "/" ? "" : pathname}`;
      return NextResponse.rewrite(url);
    }
  }
  return NextResponse.next();
}

export const config = {
  // Skip API routes, Next internals and anything with a file extension
  matcher: ["/((?!api|_next/static|_next/image|favicon\\.ico|.*\\..*).*)"],
};

Two details matter more than they look:

  • The matcher excludes /api. Portfolio pages call API routes (contact form, analytics) with relative URLs like /api/contact. If middleware rewrote those to /p/jane/api/contact, every request from a portfolio would 404.
  • The matcher excludes paths with a dot. That means /robots.txt and /sitemap.xml on jane.santoshkumar.site are served by the root handlers, which turns out to be exactly what you want for SEO (more on that below).

Step 3: Custom domains need a database lookup#

Subdomains are easy because the username is in the hostname. A custom domain like janedoe.dev tells you nothing. You have to look up which portfolio claimed it.

The catch: middleware runs on the Edge runtime, and my data lives in MongoDB through Mongoose, which needs Node.js APIs. My solution is a small internal API route that middleware calls:

TypeScript
// in middleware, for hosts that aren't the root or a subdomain
const res = await fetch(
  `https://${ROOT}/api/internal/resolve-domain?host=${encodeURIComponent(host)}`,
  {
    headers: { "x-internal-secret": process.env.INTERNAL_API_SECRET ?? "" },
    signal: AbortSignal.timeout(2000),
  },
);
const { subdomain } = await res.json();
if (subdomain) {
  const url = req.nextUrl.clone();
  url.pathname = `/p/${subdomain}${pathname === "/" ? "" : pathname}`;
  return NextResponse.rewrite(url);
}

The API route queries Portfolio.findOne({ customDomain: host, domainVerified: true }) and returns the subdomain. Three things I'd call non-negotiable here:

  • A timeout. If the lookup hangs, every request to that domain hangs. Two seconds, then fall through.
  • Only verified domains resolve. Otherwise anyone could type your domain into their settings and hijack routing.
  • A shared secret header, so the internal route isn't a public "who owns which domain" oracle.

The extra hop costs a few milliseconds per page view. If that ever matters, the next step is caching the host→subdomain mapping in an edge-friendly store; for a portfolio builder, the simple version has been fine.

Step 4: Prove the user owns the domain#

Before a custom domain goes live, the user has to prove they control its DNS. I use a TXT record with a random token, the same pattern Google Search Console and Vercel use:

  1. User enters janedoe.dev. The server stores it with a random token (crypto.randomBytes(20).toString("hex")) and domainVerified: false.
  2. The dashboard tells them to add three records:
    • TXT _folio-verify.janedoe.dev → folio-verify=<token>
    • A @ → 76.76.21.21 (Vercel)
    • CNAME www → cname.vercel-dns.com
  3. They click Verify, and the server checks the TXT record with Node's built-in resolver:
TypeScript
import { promises as dns } from "dns";

const records = await dns.resolveTxt(`_folio-verify.${domain}`);
const verified = records.some((chunks) => chunks.join("") === `folio-verify=${token}`);

Note the chunks.join(""): TXT records can be split into multiple strings, and resolveTxt returns each record as an array of chunks.

Step 5: Add the domain to Vercel via its API#

Verification in my database doesn't make Vercel serve the domain or issue a certificate. For that, the domain has to be added to the Vercel project, which you can do programmatically:

TypeScript
await fetch(`https://api.vercel.com/v10/projects/${PROJECT_ID}/domains`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.VERCEL_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: domain }),
});

Vercel then provisions TLS automatically once the A/CNAME records point at it. When a user removes their domain, a DELETE to the same endpoint cleans it up.

The SEO traps (learn from my mistakes)#

Routing was the easy part. Making each tenant look like its own website to Google took more care.

1. Root layout metadata leaks into every tenant. I had my own Open Graph tags and Person structured data in app/layout.tsx. Since every portfolio renders inside the root layout, every user's page told Google it was about me, and every shared link previewed with my name. Next.js doesn't copy a page's title into og:title, so overriding the title alone isn't enough. Keep the root layout to truly site-wide settings, and have each tenant page set its own openGraph, twitter and JSON-LD.

2. One canonical URL per portfolio. A portfolio is reachable at jane.santoshkumar.site, at janedoe.dev, and technically at santoshkumar.site/p/jane. Pick one (the custom domain if verified, otherwise the subdomain) and set it as alternates.canonical in generateMetadata.

3. Sitemaps are per host. Google only trusts sitemap URLs on the sitemap's own host. Because middleware doesn't rewrite /sitemap.xml, the same app/sitemap.ts answers on every host, so I make it host-aware: the root domain lists the root pages plus every published subdomain portfolio, while a tenant's own host lists only itself. app/robots.ts does the same, pointing at https://<current host>/sitemap.xml.

4. Don't index empty tenants. Unpublished portfolios render a "coming soon" page. Those get robots: { index: false } until the user publishes.

Wrapping up#

The whole system is a handful of pieces: a wildcard domain, a middleware rewrite, a verified lookup for custom domains, Vercel's domains API, and host-aware metadata. None of it needs a separate server per tenant, and it scales with nothing more than another row in the database.

If you're building something similar and get stuck, reach out on LinkedIn. I'm happy to compare notes.