Framework guides

Next.js 16 custom domains guide

Next.js 16 · Node.js LTS 24
Versions

Link to this edition

Example framework and runtime versions

Next.js
16.3.4
React
19.2.8
TypeScript
7.0.2
Node.js LTS
24.20.0
Node.js Current
26.8.1

You’ve built a Next.js app where customers can publish a site or share their own pages. Now you want those pages to work on domains your customers own. This guide shows you how to add that feature to your app.

We’ll start with two addresses on your laptop and make each one show a different customer’s page. Then we’ll add domain storage and the DNS and HTTPS setup needed for real visitors. You’ll also see how to stop serving a domain when a customer removes it.

The examples use the App Router and build on your existing customer accounts and login. You can keep your current database; the focus here is connecting a domain to the right content.

In this guide

1. Match a hostname to a customer locally

Every browser request includes a Host header. Next.js 16’s proxy.ts is a convenient place to normalize that value once and pass it to the App Router.

Start with two .localhost names, which resolve to the loopback interface. Delete any client-supplied copy of the internal header before setting it yourself.

proxy.ts

The examples call a customer’s account a Tenant. Use your existing account, workspace, or organization model wherever you see that name.

import { NextResponse, type NextRequest } from "next/server"

export function proxy(request: NextRequest) {
  const requestHeaders = new Headers(request.headers)
  requestHeaders.delete("x-tenant-host")

  const hostname = cleanRequestHost(request.headers.get("host"))
  if (hostname) requestHeaders.set("x-tenant-host", hostname)

  return NextResponse.next({ request: { headers: requestHeaders } })
}

function cleanRequestHost(value: string | null) {
  return value?.trim().toLowerCase().replace(/:\d+$/, "") || null
}

Read the header in a server component. The small map below is only for this first local check; step five replaces it with your database.

import { headers } from "next/headers"
import { notFound } from "next/navigation"

const localDomains = new Map([
  ["alpha.localhost", { name: "Alpha Publishing" }],
  ["beta.localhost", { name: "Beta Studio" }],
])

export default async function Home() {
  const hostname = (await headers()).get("x-tenant-host")
  const tenant = hostname ? localDomains.get(hostname) : null
  if (!tenant) notFound()

  return <main><h1>{tenant.name}</h1></main>
}
curl -H 'Host: alpha.localhost:3000' http://127.0.0.1:3000/
curl -H 'Host: beta.localhost:3000' http://127.0.0.1:3000/
curl -i -H 'Host: unknown.localhost:3000' http://127.0.0.1:3000/

Checkpoint: the first two requests show different customer content, and the unknown hostname returns 404. No DNS or certificate work is needed yet.

2. Save each customer’s domains

A domain belongs in your own data model. Attach it to the signed-in customer, store one normalized hostname, and enforce global uniqueness so two customers cannot claim the same name.

SQLite keeps this example small. Use your existing database in a real app and keep the same constraints.

CREATE TABLE custom_domains (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  tenant_id INTEGER NOT NULL REFERENCES tenants(id),
  hostname TEXT NOT NULL UNIQUE,
  status TEXT NOT NULL DEFAULT 'pending',
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Normalize configured domains more carefully than the quick local helper: lowercase them, remove one trailing dot, convert internationalized labels to ASCII, and reject URLs, ports, IP addresses, wildcards, local names, and malformed labels.

Create the row from a Server Action or route handler that gets the customer from your existing session. Never accept a customer ID from the form.

"use server"

export async function addDomain(formData: FormData) {
  const tenant = await requireCurrentTenant()
  const hostname = normalizeConfiguredHostname(
    String(formData.get("hostname") || ""),
  )

  await db.prepare(
    "INSERT INTO custom_domains " +
    "(tenant_id, hostname, status) VALUES (?, ?, 'pending')",
  ).run(tenant.id, hostname)
}

Keep this form on your normal app hostname. Next.js Server Actions compare the request origin with the host to help prevent cross-site form submissions; configure serverActions.allowedOrigins only when a trusted reverse proxy requires an additional control-panel origin.

Checkpoint: each signed-in customer can reserve a valid hostname, duplicates are rejected, and no customer can choose another customer’s ID.

3. Connect DNS and HTTPS

The hostname is reserved in your app. Now it needs a path to the server or service that receives traffic for Next.js. Give the customer the exact DNS record from that setup.

  • Subdomains: docs.example.com can usually use a CNAME pointing at a stable hostname you operate.
  • Apex (root) domains: example.com needs an A record for IPv4. If you support these domains, give customers a stable IPv4 address that keeps routing to your app.
  • CNAME flattening: many DNS providers don't support it at the apex. It's best to support A records so customers don't need that feature. Cloudflare's flattening documentation explains how this provider-specific option works.
  • IPv6: add an AAAA record as well if your receiving service offers stable IPv6.

A reverse proxy is a server in front of Next.js that accepts browser traffic and forwards it to your app. Whether you use one or a hosting platform, the receiving service needs to accept the customer hostname and preserve the original Host header.

You’ll probably want to check that DNS points directly or indirectly to the service receiving traffic for your app. Tools and managed services can make DNS setup and HTTPS much easier; see the bonus section below for one option.

HTTPS comes next. The browser completes TLS before Next.js sees the request, so the receiving service must issue a certificate for every customer hostname and renew it before it expires. Once DNS reaches that service and its certificate is ready, your server-side integration can move the saved row from pending to active.

curl -I https://customer.example

Checkpoint: the customer’s hostname reaches your receiving service over HTTPS, and Next.js still receives the original hostname.

4. Serve the customer’s pages

Replace both shortcuts from step one. Move hostname parsing into lib/domain.ts so incoming hosts and saved domains share lowercase, IDNA-to-ASCII, label validation, and trailing-dot handling. The request helper may strip a valid port and allow .localhost only in development; the configured-domain helper must still reject ports and local names.

Import that canonical request helper from proxy.ts in place of cleanRequestHost:

import { normalizeRequestHost } from "@/lib/domain"

// After deleting any client-supplied x-tenant-host:
const hostname = normalizeRequestHost(request.headers.get("host"))
if (hostname) requestHeaders.set("x-tenant-host", hostname)

Then replace the demo map with an exact database lookup. The db and CustomerSite imports below point to your existing database module and customer page component.

app/page.tsx

import { headers } from "next/headers"
import { notFound } from "next/navigation"
import { CustomerSite } from "@/app/components/customer-site"
import { db } from "@/lib/db"

export const dynamic = "force-dynamic"
export const revalidate = 0

export default async function CustomerPage() {
  const hostname = (await headers()).get("x-tenant-host")
  if (!hostname) notFound()

  const tenant = db.prepare([
    "SELECT tenants.* FROM custom_domains",
    "JOIN tenants ON tenants.id = custom_domains.tenant_id",
    "WHERE custom_domains.hostname = ?",
    "AND custom_domains.status = 'active'",
  ].join(" ")).get(hostname)

  if (!tenant) notFound()
  return <CustomerSite tenant={tenant} />
}

Keep management and login pages on your normal app hostname. In proxy.ts, return 404 when a custom hostname requests control paths such as /dashboard or /api/domains; check against an explicit set of normal app hosts. Leave public page and asset paths available. A custom hostname selects public content and never authorizes account changes.

If a trusted ingress cannot preserve Host, have it overwrite a dedicated hostname header and authenticate the connection before your app accepts that header.

Dynamic rendering avoids putting one customer’s page in a shared full-route cache. If you add caching later, include the normalized hostname in every cache key. Build absolute links from the saved domain, and keep session cookies host-only unless you have a specific reason to widen their scope.

Checkpoint: active domains render the right customer, while unknown, pending, and removing domains return 404.

5. Remove a domain safely

Stop routing before asking your hosting layer to detach the hostname and certificate. Keep the database row while that work is in progress so another customer cannot claim the same name during a retry.

export async function beginDomainRemoval(hostnameInput: string) {
  const tenant = await requireCurrentTenant()
  const hostname = normalizeConfiguredHostname(hostnameInput)
  const row = await findOwnedDomain(tenant.id, hostname)
  if (!row) throw new Error("Domain not found")

  const alreadyRemoving = row.status === "removing"
  const changed = alreadyRemoving || await markRemovingIfCurrent({
    id: row.id,
    tenantId: tenant.id,
    hostname,
    from: ["pending", "active"],
  })
  if (!changed) throw new Error("Domain changed; reload and try again")

  // Your hosting adapter owns this retry-safe operation.
  await hosting.removeHostname(hostname)
  return { status: "removing" }
}

The hosting call above is an explicit integration point, not a Next.js API. Implement it with your chosen platform’s hostname-removal operation, and make that operation safe to retry.

Treat timeouts as unknown outcomes. Leave the row in removing and retry the same cleanup. After the hosting layer confirms it no longer accepts the hostname, delete with a condition on the original row ID, customer ID, hostname, and removing status.

Those conditions protect a replacement row from a delayed response. Never turn routing back on just because a removal request failed.

Checkpoint: removal immediately returns 404, retries keep the hostname reserved, and the row disappears only after infrastructure cleanup is confirmed.

Bonus: let Approximated handle DNS onboarding, HTTPS, and forwarding

The walkthrough above works with any infrastructure that can accept customer hostnames. Approximated can fill that role: it accepts the hostname, forwards traffic to your app, manages certificates, reports connection status, and returns DNS instructions for the customer.

Your app still owns customer authorization, the domain-to-content mapping, and the rule for when a domain becomes public. Approximated calls its domain-to-app mapping a virtual host.

Create the mapping from the server

After the hostname is reserved, add a nullable provider_id to that row and call the fixed API endpoint below. Keep the key on the server, set a timeout, and use an app hostname that Approximated can reach.

async function createMapping(domain: {
  id: number
  tenantId: number
  hostname: string
}) {
  const hostname = normalizeConfiguredHostname(domain.hostname)
  const apiKey = process.env.APPROXIMATED_API_KEY
  const appOrigin = process.env.APP_ORIGIN
  if (!apiKey || !appOrigin) throw new Error("Missing server config")

  const response = await fetch(
    "https://cloud.approximated.app/api/vhosts",
    {
      method: "POST",
      signal: AbortSignal.timeout(10_000),
      headers: {
        "Api-Key": apiKey,
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify({
        incoming_address: hostname,
        target_address: appOrigin,
        target_ports: process.env.APP_ORIGIN_PORT || "443",
        keep_host: true,
      }),
    },
  )
  if (!response.ok) throw new Error("Could not create domain mapping")
  const payload: unknown = await response.json()
  if (!payload || typeof payload !== "object" || !("data" in payload)) {
    throw new Error("Invalid domain mapping response")
  }
  const data = (payload as Record<string, unknown>).data
  if (!data || typeof data !== "object") {
    throw new Error("Invalid domain mapping response")
  }
  const virtualHost = data as Record<string, unknown>
  if (
    typeof virtualHost.id !== "number" ||
    !Number.isInteger(virtualHost.id) ||
    !(virtualHost.id > 0) ||
    typeof virtualHost.incoming_address !== "string" ||
    normalizeConfiguredHostname(virtualHost.incoming_address) !== hostname
  ) throw new Error("Unexpected domain mapping identity")

  const attached = await db.prepare([
    "UPDATE custom_domains SET provider_id = ?",
    "WHERE id = ? AND tenant_id = ? AND hostname = ?",
    "AND status = 'pending' AND provider_id IS NULL",
  ].join(" ")).run(virtualHost.id, domain.id, domain.tenantId, hostname)
  if (attached.changes !== 1) throw new Error("Domain changed; reload and try again")
  return virtualHost
}

The conditional update attaches the confirmed ID only to the same row, customer, normalized hostname, pending state, and empty provider slot. If the call fails, times out, returns an invalid body, or loses that race, keep the hostname reserved for a deliberate check. Never adopt or delete an uncertain remote mapping by hostname.

Show DNS and connection progress

Approximated returns the DNS instructions in user_message; display that field as plain text. Read GET /api/vhosts/{id} with the confirmed stored ID for the latest automatic DNS and HTTPS checks. This request returns the saved results; it does not start a fresh check. Treat missing monitor values as unknown:

  • is_resolving means an HTTP response was observed, possibly from another destination.
  • apx_hit means the request reached Approximated.
  • has_ssl means TLS is ready for the hostname.

Optional fields may be absent, but reject them when present with the wrong type: status and message fields must be strings, monitor fields must be boolean or null, and dns_pointed_at must be a string or null.

Mark the main row active only when the mapping still has its confirmed ID and hostname, and both apx_hit === true and has_ssl === true. Keep is_resolving as a separate diagnostic: it means an HTTP response was observed somewhere, while apx_hit confirms that traffic reached Approximated.

Remove the same mapping

Set the local row to removing, then send DELETE /api/vhosts/{id} with the confirmed stored ID. A successful plain-text response accepts asynchronous removal; keep the row until GET for that same ID returns 404.

On an error or timeout, keep routing disabled and retry DELETE for the same ID. Apply refreshes and final deletion only when the row ID, customer, hostname, provider ID, and status still match.

Complete Approximated example

The Next.js custom domains companion includes the App Router UI, SQLite store, local transport stub, tenant guards, status handling, and retry-safe removal.

git clone https://github.com/Approximated-Inc/nextjs-custom-domains.git
cd nextjs-custom-domains
cp .env.example .env
npm ci
npm test
npm run dev

See the virtual hosts API documentation for the request and response fields used by the adapter.

Checkpoint: the optional adapter supplies customer DNS instructions, checks the connection, and manages HTTPS while your Next.js app keeps authorization and routing decisions.