Framework guides
Nuxt 4 custom domains guide
Versions
Example framework and runtime versions
- Node.js
- 24.20.0
- Nuxt
- 4.5.2
- Vue
- 3.5.42
- TypeScript
- 6.0.3
A customer has a booking page in your Nuxt app and wants to share it using their own domain. The page and its bookings still live in your app; the customer gets an address that belongs to their business.
This guide shows how to add that option. We’ll first make two local addresses load different booking pages, then build up to saving customer domains and connecting them over HTTPS. We’ll also cover how DNS and HTTPS get visitors to your app, and how to remove a domain when a customer stops using it.
Start with your existing Nuxt app, customer accounts, and booking data. You can use the same approach for other customer pages, with your own database and sign-in flow.
In this guide
1. Match a hostname to a customer locally
Every HTTP request includes a Host header such as northwind.localhost. Start with a development-only map in a Nitro server route. This proves that one Nuxt process can serve different customer data before you touch DNS or HTTPS.
The examples use tenant for a customer’s account. Use your existing account, team, or organization model wherever that name appears.
// server/utils/direct-hostname.js
export function directHostname(event) {
const host = getRequestHeader(event, 'host') ?? ''
if (!/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(host)) return null
return host.replace(/:\d+$/, '').replace(/\.$/, '').toLowerCase()
}
// server/api/booking-page.get.js
const localHosts = new Map([
['northwind.localhost', 'northwind'],
['contoso.localhost', 'contoso']
])
export default defineEventHandler((event) => {
const tenantSlug = localHosts.get(directHostname(event))
if (!tenantSlug) {
throw createError({ statusCode: 404, statusMessage: 'Unknown domain' })
}
return loadExistingBookingPage(tenantSlug)
})
loadExistingBookingPage stands for the query your app already uses to load a customer's public content. It must return no data for an unknown customer. This Host match selects content only; it does not authorize account changes.
curl -H 'Host: northwind.localhost' http://127.0.0.1:3000/api/booking-page
curl -H 'Host: contoso.localhost' http://127.0.0.1:3000/api/booking-page
curl -i -H 'Host: unknown.localhost' http://127.0.0.1:3000/api/booking-page
Checkpoint: the first two requests return different customer data, and the unknown hostname returns 404.
2. Save each customer's domains
Move the map into a table owned by your app. A globally unique normalized hostname prevents two customers from reserving the same name. A small local state keeps pending and removing domains out of public routing.
CREATE TABLE custom_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
hostname TEXT NOT NULL UNIQUE,
state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending', 'active', 'removing'))
) STRICT;
The schema works with SQLite, keeping the example small. Use your app's current database and migration system. Normalize before insert: trim spaces and the final dot, lowercase, convert Unicode labels to ASCII, then reject schemes, ports, paths, wildcards, literal IP addresses, and invalid DNS labels.
// server/utils/request-security.js
export function requireControlHost(event) {
const controlHost = useRuntimeConfig(event).controlHost
if (typeof controlHost !== 'string' || controlHost.length === 0 ||
directHostname(event) !== controlHost) {
throw createError({ statusCode: 404, statusMessage: 'Not found' })
}
}
// server/api/control/domains.post.js
export default defineEventHandler(async (event) => {
requireControlHost(event)
const tenant = await requireTenant(event)
assertBrowserMutation(event)
const body = await readBody(event)
const hostname = normalizeHostname(body?.hostname)
database.prepare(`
INSERT INTO custom_domains (tenant_id, hostname)
VALUES (?, ?)
`).run(tenant.id, hostname)
return { hostname, state: 'pending' }
})
Set private runtimeConfig.controlHost to the exact lowercase app hostname without a scheme or port. Nitro auto-imports exports from server/utils, so the guard reuses directHostname and runs before authentication.
requireTenant is your existing signed-session and membership check. assertBrowserMutation is your existing CSRF boundary; for JSON routes it should require the exact control-page Origin, JSON content type, and a token tied to the session. Never accept tenant_id from the request body.
Checkpoint: a signed-in customer can reserve a normalized hostname, while duplicates, bad names, and cross-customer requests are rejected.
3. Connect DNS and HTTPS
A reverse proxy is a server in front of Nuxt that receives requests and forwards them to Nitro. Your hosting platform may provide one, or you can run one yourself. Register the customer hostname there first, then show the customer the DNS record that points to it.
For a subdomain such as book.example.com, a CNAME can point to a hostname owned by your receiving service.
An apex, or root domain, is the shorter example.com. Offer a stable IPv4 address and an A record for apex domains because many DNS providers cannot place a normal CNAME there and do not support CNAME flattening. You can offer an AAAA record as an additional IPv6 route when your service supports it.
- Add the exact customer hostname to the server, load balancer, or hosting platform that will receive it.
- Show the matching CNAME for subdomains or stable IPv4 A record for apex domains.
- Have the customer copy that record into the DNS provider that manages their domain.
- Issue a valid HTTPS certificate for the hostname and arrange automatic renewal.
- Forward the original
Hostheader to Nitro so the app can select the customer.
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.
DNS routes the request to your receiving service. HTTPS is negotiated before Nitro sees the request, so certificate issuance and renewal belong at that service. Mark the saved domain active only after the hostname is connected there and HTTPS is ready.
Checkpoint: the hostname reaches your receiving service over HTTPS and arrives at Nitro as the original Host.
4. Serve the customer's pages
Keep the directHostname helper from step one, then replace the route’s map and handler with this query. Here, database is your existing SQLite connection. Only an active row can select content.
// server/api/booking-page.get.js
export default defineEventHandler((event) => {
const hostname = directHostname(event)
const booking = hostname && database.prepare(`
SELECT tenants.slug, tenants.name, tenants.booking_title,
custom_domains.hostname
FROM custom_domains
JOIN tenants ON tenants.id = custom_domains.tenant_id
WHERE custom_domains.hostname = ?
AND custom_domains.state = 'active'
`).get(hostname)
if (!booking) {
throw createError({ statusCode: 404, statusMessage: 'Unknown domain' })
}
setResponseHeader(event, 'cache-control', 'private, no-store')
return booking
})
Keep management routes on your normal app hostname, and keep their session cookie host-only. Apply requireControlHost to every control handler before authentication. A Host match can select public content but cannot grant access to the control API.
Add this to your customer-facing page and reuse your normal app-home branch for the main app hostname. Set cache headers on the outer server-rendered page as well as its API request:
<!-- app/pages/index.vue -->
<script setup>
useResponseHeader('cache-control').value = 'private, no-store'
useResponseHeader('vary').value = 'Host'
const requestFetch = useRequestFetch()
const { data: booking, error } = await useAsyncData(
'booking-page',
() => requestFetch('/api/booking-page')
)
if (error.value) throw createError({ statusCode: 404 })
</script>
<template>
<main v-if="booking">
<p>{{ booking.name }}</p>
<h1>{{ booking.booking_title }}</h1>
</main>
</template>
Read the direct Host by default. Trust a forwarded hostname only when a known proxy overwrites it and direct access is blocked or authenticated. Use a trusted app URL for absolute dashboard links. If you add shared caching, key it by hostname or customer.
Checkpoint: active hostnames render the right customer, the normal app hostname still serves management, and every other hostname returns 404.
5. Remove a domain safely
Stop public routing before asking the receiving infrastructure to detach the hostname and certificate. Keep the unique reservation after a timeout or unclear error so another customer cannot claim it too early.
export function startRemoval(database, domain) {
const result = database.prepare(`
UPDATE custom_domains SET state = 'removing'
WHERE id = ? AND tenant_id = ? AND hostname = ?
AND state IN ('pending', 'active', 'removing')
`).run(domain.id, domain.tenant_id, domain.hostname)
return result.changes === 1
? { id: domain.id, tenantId: domain.tenant_id,
hostname: domain.hostname }
: null
}
The same saved identity is returned for a retry while removal is in progress. Pass it to your hosting cleanup adapter. Delete the row only after that system confirms the hostname and certificate are gone, and only while ID, customer, hostname, and removing still match.
A delayed readiness check should update only the same saved domain while its state still matches. That prevents an older result from changing removing back to active.
Checkpoint: removal immediately returns 404, retries keep the reservation, and confirmed infrastructure cleanup releases it.
Bonus: let Approximated handle HTTPS and forwarding
The five steps above work with any hosting setup. Approximated can accept each customer hostname, issue and renew its certificate, forward traffic to your app server, and return the DNS instructions for your dashboard.
Your app still owns customer authorization, hostname reservation, content lookup, and activation. In the API, a virtual host is the mapping from a customer hostname to your reachable app-server hostname.
Keep the API key in private Nuxt runtime configuration. In nuxt.config.ts, map runtimeConfig.approximatedApiKey and runtimeConfig.originHost from server environment variables; do not put either under runtimeConfig.public. For this adapter, add a nullable provider_id to the domain table.
After the hostname is reserved, send POST /api/vhosts with keep_host: true.
export async function connectWithApproximated({ event, database, domain }) {
const config = useRuntimeConfig(event)
if (typeof config.approximatedApiKey !== 'string' ||
config.approximatedApiKey.trim().length === 0 ||
typeof config.originHost !== 'string' ||
config.originHost.trim().length === 0) {
throw new Error('Approximated server configuration is missing')
}
const originHost = normalizeHostname(config.originHost)
const response = await fetch(
'https://cloud.approximated.app/api/vhosts',
{
method: 'POST',
headers: {
'api-key': config.approximatedApiKey,
'content-type': 'application/json'
},
body: JSON.stringify({
incoming_address: domain.hostname,
target_address: originHost,
target_ports: '443',
keep_host: true
}),
signal: AbortSignal.timeout(5000)
}
)
const envelope = await response.json()
const remote = envelope?.data
if (!response.ok || !Number.isSafeInteger(remote?.id) ||
remote.id <= 0 || remote.incoming_address !== domain.hostname) {
throw new Error('Domain creation was not confirmed')
}
return database.prepare(`
UPDATE custom_domains SET provider_id = ?
WHERE id = ? AND tenant_id = ? AND hostname = ?
AND state = 'pending' AND provider_id IS NULL
`).run(remote.id, domain.id, domain.tenant_id, domain.hostname)
}
Native fetch does not automatically retry this POST. If creation times out, conflicts, or returns bad data, keep the customer's reservation but do not search for, adopt, or delete a remote resource by hostname.
Approximated runs its DNS and HTTPS checks automatically. A later GET returns the latest stored results from those checks, along with the setup message you can show the customer.
Treat missing status as pending and missing status_message as empty. Show these API values in plain language:
user_message: exact DNS instructions, displayed as textis_resolving: the hostname returns an HTTP response somewhereapx_hit: traffic reaches Approximatedhas_ssl: HTTPS is ready
Accept missing monitor fields as unknown. Reject a present monitor value unless it is Boolean or null, and reject present status text unless it is a string.
Fetch GET /api/vhosts/{id} with the confirmed ID you stored. The response reports Approximated’s stored automatic-check results rather than starting a fresh check.
Activate only when it still contains the same ID and hostname, apx_hit is true, and has_ssl is true. Keep is_resolving as a diagnostic because an HTTP response may come from somewhere other than Approximated.
For removal, change the local state before DELETE /api/vhosts/{id}. Its plain-text 200 response means accepted, not finished. Keep the reservation after an error or accepted deletion, retry the same ID, and release only after GET /api/vhosts/{id} returns 404.
A delayed check should update only the same customer, hostname, provider ID, and expected state.
Checkpoint: the dashboard shows the returned DNS instructions, and the confirmed mapping becomes active only after Approximated reports connection and HTTPS ready.
Run the complete Nuxt example
The Nuxt custom-domains example includes two booking-page tenants, SQLite storage, signed sessions, explicit CSRF checks, a local provider stub, readiness checks, and confirmed removal.
git clone https://github.com/Approximated-Inc/nuxt-custom-domains-example.git
cd nuxt-custom-domains-example
cp .env.example .env
docker build -t nuxt-custom-domains-example:local .
docker run --rm --env-file .env \
-p 127.0.0.1:3087:3087 \
nuxt-custom-domains-example:local