Framework guides
SvelteKit 2 custom domains guide
Versions
Example framework and runtime versions
- Node.js
- 24.20.0
- SvelteKit
- 2.70.3
- Svelte
- 5.57.0
- TypeScript
- 6.0.3
If your SvelteKit app hosts project pages or client portals, customers may want to use their own domain to share them. This guide shows how to connect each domain to the right customer’s content, with your app still responsible for serving it.
We’ll start locally, so you can see two addresses load different pages in the same app. From there, we’ll save customer domains, connect DNS and HTTPS, and use those saved rows for routing. Each step builds on the last, including what happens when a customer removes a domain.
You’ll work with your existing customer accounts and login. The examples use SvelteKit’s server features and a small database table that you can adapt to your current app.
In this guide
1. Match a hostname to a customer locally
SvelteKit runs handle from src/hooks.server.ts for dynamic requests. Start with a two-name map, attach the match to event.locals, and reject unknown hosts without choosing a default customer.
Vite checks hostnames during development. Allow the three names used by this checkpoint:
vite.config.ts
The examples call a customer’s account a Tenant. Use your existing account, workspace, or organization model wherever you see that name.
import { sveltekit } from "@sveltejs/kit/vite"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [sveltekit()],
server: {
host: "127.0.0.1",
port: 5173,
strictPort: true,
allowedHosts: ["alpha.test", "beta.test", "unknown.test"],
},
})
src/hooks.server.ts
import type { Handle } from "@sveltejs/kit"
const localTenants = new Map([
["alpha.test", { id: 1, name: "Alpha Studio" }],
["beta.test", { id: 2, name: "Beta Works" }],
])
export const handle: Handle = async ({ event, resolve }) => {
const hostname = event.url.hostname.toLowerCase().replace(/\.$/, "")
const tenant = localTenants.get(hostname)
if (!tenant && hostname !== "127.0.0.1") {
return new Response("Not found", { status: 404 })
}
event.locals.tenant = tenant ?? null
return resolve(event)
}
Add a nullable tenant: { id: number; name: string } | null field to your existing App.Locals interface in src/app.d.ts. Then pass it from the page loader to your Svelte component:
// src/routes/+page.server.ts
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = ({ locals }) => ({
tenant: locals.tenant,
})
<!-- src/routes/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
</script>
{#if data.tenant}
<h1>{data.tenant.name}</h1>
{:else}
<h1>Your app</h1>
{/if}
Replace the final branch with your normal homepage. Start the development server in one terminal and run the curl checks in another:
npm run dev
curl -H 'Host: alpha.test' http://127.0.0.1:5173/
curl -H 'Host: beta.test' http://127.0.0.1:5173/
curl -i -H 'Host: unknown.test' http://127.0.0.1:5173/
Checkpoint: Alpha and Beta return different page data, while the unknown host returns 404. No DNS or certificate work is needed.
2. Save each customer’s domains
Move the map into your own database. Each normalized hostname is globally unique and belongs to one tenant. A UUID generation gives delayed work a stable identity across removal and replacement.
This small schema works with SQLite. Use your app’s existing database in production and keep the same constraints.
CREATE TABLE domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
generation TEXT NOT NULL UNIQUE,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
hostname TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending'
);
Canonicalize configured domains before saving: lowercase, remove one trailing dot, convert internationalized labels to ASCII, and reject schemes, paths, ports, IP addresses, wildcards, local names, and malformed labels.
Use a server action on your existing account page. It gets the tenant from locals, not from the form. The illustrative $lib/server/domain and $lib/server/store modules stand for your existing server-only hostname canonicalizer and database adapter:
src/routes/account/domains/+page.server.ts
import { randomUUID } from "node:crypto"
import { error } from "@sveltejs/kit"
import { normalizeConfiguredHostname } from "$lib/server/domain"
import { store } from "$lib/server/store"
import type { Actions } from "./$types"
export const actions = {
default: async ({ request, locals }) => {
if (!locals.session) error(401, "Sign in required")
const form = await request.formData()
const hostname = normalizeConfiguredHostname(
String(form.get("hostname") || ""),
)
store.reserveDomain({
generation: randomUUID(),
tenantId: locals.session.tenantId,
hostname,
status: "pending",
})
return { saved: true }
},
} satisfies Actions
Keep this route on your normal app hostname. SvelteKit checks the request Origin for form submissions by default; preserve the correct public scheme and host through trusted infrastructure rather than weakening that check.
Checkpoint: a signed-in customer can reserve a valid hostname, duplicates fail at the database, and a submitted tenant ID cannot change ownership.
3. Connect DNS and HTTPS
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.
The apex is the root of a domain, such as example.com; docs.example.com is a subdomain. For an apex, give customers an A record that points to a stable IPv4 address for your receiving service. Supporting customer apex domains means keeping that address consistent. Many DNS providers do not offer apex CNAME flattening, so it is better not to depend on it in your onboarding flow.
For a subdomain, give customers a CNAME target such as domains.your-app.example. You can also offer an AAAA record when your receiving service has a stable IPv6 address.
-
DNS connected
The hostname points directly or indirectly at your receiving service.
-
HTTPS ready
A valid certificate covers this hostname and can renew automatically.
You will probably want to check that the customer’s DNS change has reached the expected receiving service before marking the domain ready.
TLS finishes before SvelteKit receives the request. The receiving server must accept the customer hostname, issue and renew its certificate, and preserve the original Host.
curl -I https://customer.example
With adapter-node, leave ORIGIN unset when serving multiple domains. Let each request supply its hostname, and have your trusted proxy overwrite x-forwarded-proto with the public scheme. Block direct access to this app server, then start the built app with:
HOST_HEADER=host PROTOCOL_HEADER=x-forwarded-proto node build
This keeps SvelteKit's URL and form-origin checks aligned with the domain in the browser. The adapter-node documentation explains these settings.
Once DNS points to the receiving service and its certificate is ready, set the saved domain to active.
Checkpoint: the customer domain reaches your receiving server over HTTPS with its original hostname intact.
4. Serve the customer’s pages
Replace the local map with a lookup in your saved domains. Reuse the server modules from step 2 to query the database and clean up the request hostname. Apply the same rules, including converting internationalized names to ASCII and removing a final dot.
src/hooks.server.ts
import { env } from "$env/dynamic/private"
import { normalizeRequestHost } from "$lib/server/domain"
import { store } from "$lib/server/store"
import type { Handle } from "@sveltejs/kit"
export const handle: Handle = async ({ event, resolve }) => {
const hostname = normalizeRequestHost(
event.request.headers.get("host"),
)
if (!hostname) return new Response("Not found", { status: 404 })
const controlHost = hostname === env.CONTROL_HOST
const controlPath = event.url.pathname.startsWith("/account")
if (controlPath && !controlHost) {
return new Response("Not found", { status: 404 })
}
event.locals.tenant = null
if (!controlHost) {
const domain = store.findActiveDomain(hostname)
if (!domain) {
return new Response("Not found", { status: 404 })
}
event.locals.tenant = store.tenant(domain.tenantId)
if (!event.locals.tenant) return new Response("Not found", { status: 404 })
}
const response = await resolve(event)
response.headers.set("cache-control", "private, no-store")
response.headers.append("vary", "Host")
return response
}
Let public page and asset paths continue through the hook. Keep account and domain-management paths on the configured control host. If a trusted reverse proxy cannot preserve Host, have it overwrite a dedicated hostname header and authenticate the connection before the hook accepts that header.
Keep the loader and component from step one. The hook now rejects unknown customer domains before rendering and leaves tenant empty only for your normal app homepage. Set CONTROL_HOST to that hostname, and apply the same host check to login and other private routes.
Private, no-store responses are a safe default for customer portals. If you later add caching, include the normalized hostname in every key. Build absolute URLs from the saved domain and keep session cookies host-only.
Checkpoint: active domains render the correct Svelte page; 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 unique row during cleanup so another customer cannot claim it between retries.
if (!locals.session) error(401, "Sign in required")
const domain = store.findOwnedDomain(
locals.session.tenantId,
normalizeConfiguredHostname(input),
)
if (!domain) error(404, "Domain not found")
const started = domain.status === "removing"
|| store.markRemovingIfCurrent({
id: domain.id,
generation: domain.generation,
tenantId: domain.tenantId,
hostname: domain.hostname,
from: ["pending", "active"],
})
if (!started) error(409, "Domain changed; reload and try again")
// Explicit integration point; make this operation safe to retry.
await hosting.removeHostname(domain.hostname)
hosting is your adapter for the chosen platform, not a SvelteKit API. Treat timeouts as unknown outcomes, leave the row at removing, and retry the same operation.
After the hosting layer confirms it no longer accepts the hostname, delete only when row ID, generation, tenant, hostname, and removing state still match. Delayed responses then cannot affect a replacement.
Checkpoint: removal immediately returns 404, retries preserve the reservation, and the row is released only after cleanup is confirmed.
Bonus: let Approximated handle DNS onboarding, HTTPS, and forwarding
The walkthrough above works with any infrastructure that accepts customer hostnames. Approximated can handle that part: accepting the domain, forwarding requests, issuing and renewing certificates, checking the connection, and returning DNS instructions.
Your SvelteKit app still owns customer authorization, the domain-to-content mapping, and the final routing decision. Approximated calls its domain-to-app mapping a virtual host.
Create the virtual host on the server
After the row is reserved, add a nullable provider ID to it and call the fixed endpoint from a server-only module. Validate the private configuration and set a timeout.
src/lib/server/approximated.ts
import { env } from "$env/dynamic/private"
import { normalizeConfiguredHostname } from "$lib/server/domain"
import { store } from "$lib/server/store"
import { error } from "@sveltejs/kit"
type ReservedDomain = {
id: number
generation: string
tenantId: number
hostname: string
}
export async function createMapping(domain: ReservedDomain) {
const hostname = normalizeConfiguredHostname(domain.hostname)
const apiKey = env.APPROXIMATED_API_KEY
const targetAddress = env.TARGET_ADDRESS
if (!apiKey || !targetAddress) error(500, "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: targetAddress,
target_ports: "443",
keep_host: true,
}),
},
)
if (!response.ok) error(502, "Could not create domain mapping")
const payload: unknown = await response.json()
if (!payload || typeof payload !== "object" || !("data" in payload)) {
error(502, "Invalid domain mapping response")
}
const data = (payload as Record<string, unknown>).data
if (!data || typeof data !== "object") {
error(502, "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
) error(502, "Unexpected domain mapping identity")
const attached = store.attachProviderIdIfStillPending({
id: domain.id,
generation: domain.generation,
tenantId: domain.tenantId,
hostname,
expectedStatus: "pending",
expectedProviderId: null,
providerId: virtualHost.id,
})
if (!attached) error(409, "Domain changed; reload and try again")
return virtualHost
}
The named store method is one conditional write bound to the original row, generation, tenant, normalized hostname, pending state, and empty provider slot. If creation fails, times out, returns malformed data, or loses that race, keep the reservation. Never adopt or delete an uncertain resource by hostname.
Show DNS and connection progress
Render user_message as text. Poll the stored ID with GET /api/vhosts/{id}; each response reports the latest automatic checks rather than starting a fresh check. Missing status and status_message become pending and empty text; missing monitors remain unknown. Reject present status or message fields unless they are strings, monitor fields unless they are boolean or null, and dns_pointed_at unless it is a string or null.
is_resolvingmeans an HTTP response was observed, possibly from another destination.apx_hitmeans traffic reached Approximated.has_sslmeans TLS is ready for the hostname.
Activate only when the confirmed mapping still matches the stored provider ID and hostname and both apx_hit === true and has_ssl === true. Keep is_resolving as a diagnostic.
Remove by the confirmed ID
Set the local row to removing, then call DELETE /api/vhosts/{id}. Its successful plain-text response starts asynchronous removal. Keep the row until GET for the same ID returns 404.
On an error or timeout, keep routing disabled and retry DELETE for that ID. Bind delayed refreshes and final deletion to the original row, generation, tenant, hostname, provider ID, and status.
Complete Approximated example
The SvelteKit custom domains companion includes a two-tenant portal, SQLite store, local transport, server sessions, exact control-origin checks, status handling, and retry-safe removal.
git clone https://github.com/Approximated-Inc/sveltekit-custom-domains-example.git
cd sveltekit-custom-domains-example
cp .env.example .env
npm ci
PROVIDER_MODE=local npm test
PROVIDER_MODE=local npm run check
npm run dev
See the virtual hosts API documentation for the adapter’s request and response fields.
Checkpoint: Approximated supplies DNS instructions and managed HTTPS while SvelteKit keeps customer authorization and routing decisions.