Adding custom domains to a Next.js app comes down to two jobs: routing a customer's domain to the right tenant, and getting a valid SSL certificate for it. Next.js handles the routing cleanly with middleware; the certificate has to come from whatever sits in front of your app. Here is the complete DIY path, then the one-API-call version.
// middleware.ts — route by the incoming Host header
import { NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest) {
const host = req.headers.get('host') ?? '';
// Your own domains pass straight through.
if (host === 'yourapp.com' || host.endsWith('.yourapp.com')) {
return NextResponse.next();
}
// Everything else is a customer domain -> rewrite to the tenant route.
const url = req.nextUrl.clone();
url.pathname = `/_sites/${host}${url.pathname}`;
return NextResponse.rewrite(url);
}
export const config = { matcher: ['/((?!_next|api|.*\\..*).*)'] };Step 1 — Point DNS at your app
The customer creates a DNS record aimed at infrastructure you control. For a subdomain, a CNAME is best because it lets you change IPs later without touching their DNS:
app.customer.com. CNAME proxy.yourapp.com.For a bare domain you hit the CNAME-at-apex problem and need an A record or ALIAS instead. Verify it resolves with the free CNAME checker before you promise the customer it is live.
Step 2 — Route by Host in middleware
The snippet above reads host and rewrites unknown hosts into a /_sites/[host] segment. In that route you resolve the host to a tenant and render their content:
// app/_sites/[host]/page.tsx
export default async function TenantHome({ params }: { params: Promise<{ host: string }> }) {
const { host } = await params;
const tenant = await getTenantByDomain(host); // your lookup
if (!tenant) return <NotFound />; // fail closed — never a default tenant
return <SiteHome tenant={tenant} />;
}Fail closed. An unknown host must 404, never fall through to a default tenant — that is how you avoid serving one customer's domain another customer's data.
Step 3 — The real client IP
Once a proxy sits in front of Next.js, request.ip and the socket address are the proxy's, not the visitor's. Read the forwarded header instead, and only trust it from your own edge:
const clientIp =
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
req.headers.get('x-real-ip') ??
'unknown';Step 4 — SSL is not Next.js's job
Next.js never terminates TLS itself. Your options:
- On Vercel: add the domain via their API; they issue the cert. Works, but you are tied to their platform and per-domain limits.
- Self-hosted: put Caddy or Nginx in front with ACME. Caddy's on-demand TLS issues a cert per hostname on first request — behind a strict allow-list. See the trade-offs in cnames.dev vs self-hosting Caddy on-demand TLS.
- A custom-domains service: one API call registers the domain and handles the cert; your Next.js app just reads the Host header.
The one-API-call way
With cnames.dev in front of your app, you register the domain and the SSL, edge routing, and real-client-IP forwarding are handled. Your Next.js middleware stays exactly as above — it just receives clean, TLS-terminated requests with the right headers.
curl -X POST https://api.cnames.dev/v1/domains \
-H "Authorization: Bearer sk_live_..." \
-d '{"hostname":"app.customer.com","origin":"your-nextjs-app.internal"}'This is a common pattern for AI app builders and site generators publishing user apps. The full architecture — issuance, SNI, tenant routing — is in the complete guide.