Framework guides

Hono 4 custom domains guide

Hono 4 · Node.js 24
Versions

Link to this edition

Example framework and runtime versions

Node.js
24.20.0
Hono
4.13.7
TypeScript
7.0.2

A customer-facing status page is more useful when a business can share it at its own domain. If your Hono app serves those pages, you can offer custom domains while keeping the content and application in one place.

This guide takes you through that feature using Hono on Node.js. We’ll start with two local addresses and show how to load the right customer’s page for each one. Then we’ll save domains, connect DNS and HTTPS, and remove a domain when it’s no longer needed.

The examples build on your existing customer accounts, login, and status-page content. The same approach works for other pages your app hosts for customers. We’ll explain the networking concepts as they come up.

In this guide

1. Match a hostname to a customer locally

Every HTTP request includes a Host header such as atlas.test. Hono exposes the underlying request, so your route can use that value to choose a customer page.

Start with two development-only names and small stand-ins for your existing customer records:

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

// src/app.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { html } from 'hono/html'

const tenants = new Map([
  ['atlas.test', { name: 'Atlas Status', headline: 'All systems steady' }],
  ['juniper.test', { name: 'Juniper Status', headline: 'Services are up' }],
])

function hostnameFrom(request: Request) {
  const value = (request.headers.get('host') ?? '').trim().toLowerCase()
  if (value.startsWith('[')) {
    const end = value.indexOf(']')
    return end > 0 ? value.slice(1, end) : ''
  }
  return value.replace(/:\d+$/, '').replace(/\.$/, '')
}

const app = new Hono()
app.get('/', (c) => {
  const tenant = tenants.get(hostnameFrom(c.req.raw))
  if (!tenant) return c.html(html`<h1>Unknown domain</h1>`, 404)
  return c.html(html`<main><h1>${tenant.name}</h1>
    <p>${tenant.headline}</p></main>`)
})

serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 3000 })

Hono’s Node server accepts these Host values without an extra development allow-list. Keep the listener on loopback while you use fixture data.

curl -H 'Host: atlas.test' http://127.0.0.1:3000/
curl -H 'Host: juniper.test' http://127.0.0.1:3000/
curl -i -H 'Host: unknown.test' http://127.0.0.1:3000/

Never fall back to the first customer. A typo or unknown domain must not expose someone else’s page.

Checkpoint: the first two requests show different customers, and the unknown hostname returns HTTP 404. You do not need DNS, HTTPS certificates, or a provider account yet.

2. Save each customer’s domains

Replace the temporary map with a table owned by your app. Each normalized hostname belongs to one signed-in customer and has a small local status: pending, active, or removing.

This SQLite schema keeps the example compact. Use your app’s normal database and migrations in production.

CREATE TABLE customer_domains (
  id INTEGER PRIMARY KEY,
  reservation_key TEXT NOT NULL UNIQUE,
  tenant_id INTEGER NOT NULL REFERENCES tenants(id),
  hostname TEXT NOT NULL UNIQUE,
  status TEXT NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'active', 'removing'))
);

Generate reservation_key with randomUUID(). It identifies this claim even if the database later reuses a numeric row ID.

Normalize before the unique insert and every lookup. Accept a hostname only: no scheme, port, path, wildcard, IP address, or local name. Convert internationalized names to ASCII and lowercase them.

import { isIP } from 'node:net'
import { domainToASCII } from 'node:url'

export function normalizeHostname(input: string) {
  const raw = input.trim().replace(/\.$/, '')
  if (!raw || raw.includes('://') || /[/?#:@*\s]/.test(raw)) {
    throw new Error('Enter a hostname without a URL, port, or path.')
  }

  const hostname = domainToASCII(raw).toLowerCase()
  const labels = hostname.split('.')
  if (!hostname || hostname.length > 253 || isIP(hostname) ||
      labels.length < 2 || hostname.endsWith('.localhost') ||
      hostname.endsWith('.test') || labels.some((part) =>
        part.length > 63 ||
        !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(part))) {
    throw new Error('Enter a valid public hostname.')
  }
  return hostname
}

Derive tenant_id from the server-side session in every management handler. A form may submit a hostname or local row ID, but it must never choose the trusted tenant ID.

Replace the map with a query that joins customer_domains.tenant_id to your customer table and requires status = 'active'. Seed the two .test rows as explicit active fixtures only in development; they bypass the public-domain form.

Checkpoint: two signed-in customers can save different domains, the same normalized hostname cannot be claimed twice, and the route now reads its customer from the database.

3. Get the domain to your app over HTTPS

DNS is the public address book for hostnames. Give the customer the record supplied by the service that receives traffic for your app. That may be your hosting platform or a reverse proxy: a server that handles the public connection and passes the request to Hono.

An apex is the root domain, such as example.com; status.example.com is a subdomain. Use a stable IPv4 address and an A record for an apex. Many DNS providers cannot put a normal CNAME there, so do not rely on CNAME flattening for onboarding. Use a CNAME for subdomains, and add AAAA only when your receiving service offers stable IPv6.

  1. Register the customer hostname with the server or hosting service that receives public traffic.
  2. Show the exact A, AAAA, or CNAME value that service gives you. Do not copy an example IP into production.
  3. Have the customer add the record at their DNS provider.
  4. Wait until the receiver confirms both routing and a valid HTTPS certificate for that hostname.
  5. Mark your row active when DNS reaches the receiver and HTTPS is ready.

DNS gets the request to the receiving server; the HTTP Host selects the customer. HTTPS happens first, before Hono receives the request, so the receiver must obtain and renew the certificate and pass along the original Host value.

curl -I https://customer.example

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.

Checkpoint: the customer domain reaches your receiver over HTTPS, preserves its Host value, and has moved from pending to active in your app.

4. Serve the customer’s page

Replace the step-1 route while keeping your normal app-host branch. This public branch reads an actual customer row through the stored active-domain lookup and returns 404 for every unmatched host:

// Register before your existing /control routes.
app.use('/control/*', async (c, next) => {
  if (hostnameFrom(c.req.raw) !== process.env.APP_HOST) return c.notFound()
  await next()
})

app.get('/', async (c) => {
  const hostname = hostnameFrom(c.req.raw)

  if (hostname === process.env.APP_HOST) {
    return renderExistingAppHome(c)
  }

  c.header('Cache-Control', 'private, no-store')
  c.header('Vary', 'Host')

  const tenant = await domains.findActiveTenantByHost(hostname)
  if (!tenant) return c.html(html`<h1>Unknown domain</h1>`, 404)
  return c.html(tenantPage(tenant, hostname))
})

Use your existing home handler for renderExistingAppHome and customer HTML template for tenantPage. The new repository query joins the globally unique hostname to the customer and requires status = 'active'.

Set APP_HOST to your usual app hostname. Keep login, billing, and domain management there, with the same host check shown for /control. Recheck the session and tenant access in every form handler; a public Host match selects content and never authorizes an action.

Read the direct Host by default. Trust X-Forwarded-Host only when direct access to the app server is blocked and your known proxy overwrites and authenticates that header. Keep session cookies host-only, and build absolute links from a trusted app URL or the validated customer hostname.

Keep your existing CSRF defense on browser mutations. For form posts, also require the expected form content type and an Origin that exactly matches the configured control-panel origin.

Checkpoint: active customer domains render the right page; unknown, pending, and removing domains return HTTP 404; management remains on the normal app hostname.

5. Remove a domain safely

Removal has two phases. First change the local row to removing, which makes step 4 return 404 immediately. Keep the unique hostname reserved while your hosting layer removes its route and certificate.

The repository methods below stand for atomic updates that check the signed-in tenant, random reservation key, and current state as they write:

type DomainReceiver = {
  detach(hostname: string): Promise<void>
  isDetached(hostname: string): Promise<boolean>
}

app.post('/control/domains/:id/remove', async (c) => {
  const tenant = requireSessionTenant(c)
  const saved = await domains.markRemovingIfOwned(
    tenant.id, Number(c.req.param('id')),
  )
  if (!saved) return c.notFound()

  await receiver.detach(saved.hostname) // retry on failure
  if (await receiver.isDetached(saved.hostname)) {
    await domains.releaseIfStillRemoving(saved)
  }
  return c.redirect('/control', 303)
})

DomainReceiver is the boundary to your hosting platform; implement it with that platform’s API or operations workflow. A timeout is not confirmation, so leave the row in removing and retry.

The final release must still match the original tenant, hostname, reservation key, and removing state. A numeric ID can be reused after deletion; the random key prevents a delayed response from changing a newer reservation.

Checkpoint: removal stops public routing at once, retries safely after a temporary failure, and releases the hostname only after the receiver confirms it is detached.

Optional bonus: let Approximated handle DNS onboarding, HTTPS, and forwarding

The five steps above work with any hosting setup. If you do not want to build the hostname receiver and certificate automation yourself, Approximated can accept customer hostnames, check their connection, return DNS instructions, issue and renew certificates, and forward requests to your app.

Your app still owns customer authentication, the hostname reservation, page content, and the decision to activate. Here, a virtual host is the API record that maps one customer domain to your reachable Hono server.

Create the mapping from server code

Keep the API key in a server-only environment variable. Add an optional provider_id to your domain row, then call POST /api/vhosts after reserving the hostname:

type VirtualHostIdentity = {
  id: number
  incoming_address: string
  status?: string
  status_message?: string
  user_message?: string
  is_resolving: boolean | null
  apx_hit: boolean | null
  has_ssl: boolean | null
}

function signal(value: unknown, name: string) {
  if (value === undefined || value === null) return null
  if (typeof value !== 'boolean') throw new Error(`${name} has the wrong type`)
  return value
}

function parseVirtualHostEnvelope(value: unknown): VirtualHostIdentity {
  if (!value || typeof value !== 'object') throw new Error('Invalid response')
  const data = (value as { data?: unknown }).data
  if (!data || typeof data !== 'object') throw new Error('Missing data')
  const record = data as Record<string, unknown>
  if (!Number.isInteger(record.id) || Number(record.id) < 1 ||
      typeof record.incoming_address !== 'string') {
    throw new Error('Invalid virtual-host identity')
  }
  for (const name of ['status', 'status_message', 'user_message']) {
    if (record[name] !== undefined && typeof record[name] !== 'string') {
      throw new Error(`${name} has the wrong type`)
    }
  }
  return {
    id: record.id as number,
    incoming_address: record.incoming_address,
    ...(typeof record.status === 'string' ? { status: record.status } : {}),
    ...(typeof record.status_message === 'string'
      ? { status_message: record.status_message } : {}),
    ...(typeof record.user_message === 'string'
      ? { user_message: record.user_message } : {}),
    is_resolving: signal(record.is_resolving, 'is_resolving'),
    apx_hit: signal(record.apx_hit, 'apx_hit'),
    has_ssl: signal(record.has_ssl, 'has_ssl'),
  }
}

const response = await fetch(
  'https://cloud.approximated.app/api/vhosts',
  {
    method: 'POST',
    signal: AbortSignal.timeout(5_000),
    headers: {
      accept: 'application/json',
      'content-type': 'application/json',
      'api-key': process.env.APPROXIMATED_API_KEY!,
    },
    body: JSON.stringify({
      incoming_address: hostname,
      target_address: process.env.APP_ORIGIN_HOST,
      target_ports: '443',
      keep_host: true,
    }),
  },
)

if (!response.ok) {
  throw new Error(`Create failed: ${response.status} ${await response.text()}`)
}
if (!(response.headers.get('content-type') ?? '').includes('application/json')) {
  throw new Error('Create returned a non-JSON success response')
}
const payload: unknown = await response.json()
const data = parseVirtualHostEnvelope(payload)
if (normalizeHostname(data.incoming_address) !== hostname) {
  throw new Error('Unexpected virtual-host identity')
}
await domains.attachProviderIdIfStillPending(domain, data.id)

parseVirtualHostEnvelope is a server-side validator: it requires a { data: virtualHost } object, a positive integer ID, and a string hostname. The final helper conditionally attaches the ID only if the same tenant, hostname, reservation key, and pending row still exist.

APP_ORIGIN_HOST must be a hostname that reaches your Hono server. keep_host: true preserves the customer Host for step 4. If create times out, conflicts, or returns malformed data, keep the hostname reserved and unavailable. Never look up by hostname and adopt or delete an uncertain remote record.

Show DNS guidance and connection status

Show user_message as escaped text, never raw HTML. Approximated checks the connection automatically. Create and get use the JSON envelope above. For status responses, accept optional string status fields and optional boolean-or-null monitoring fields; reject any present field with the wrong type. Missing or null monitoring values mean unknown:

  • is_resolving means an HTTP response was observed, even if it came from somewhere else.
  • apx_hit means the request reached Approximated.
  • has_ssl means HTTPS is ready.
const response = await fetch(
  `https://cloud.approximated.app/api/vhosts/${domain.providerId}`,
  {
    signal: AbortSignal.timeout(5_000),
    headers: {
      'api-key': process.env.APPROXIMATED_API_KEY!,
      accept: 'application/json',
    },
  },
)
if (!response.ok) throw new Error(`Refresh failed: ${response.status}`)
const data = parseVirtualHostEnvelope(await response.json())
if (data.id !== domain.providerId ||
    normalizeHostname(data.incoming_address) !== domain.hostname) {
  throw new Error('Unexpected virtual-host identity')
}

const ready = data.apx_hit === true && data.has_ssl === true
await domains.saveStatusIfCurrent(domain, data, ready ? 'active' : 'pending')

GET reads the latest stored result of Approximated’s automatic check; it does not start a fresh check. Compare the returned ID and hostname before saving. apx_hit confirms the request reached Approximated and has_ssl confirms HTTPS is ready, so both must be true before routing. Keep is_resolving as a diagnostic because HTTP may be answering somewhere else.

Remove by the confirmed ID

Disable local routing first, then send DELETE /api/vhosts/{id}. A successful delete returns HTTP 200 with plain text, but removal still takes time. Keep the reservation and retry the same stored ID after a timeout or error.

Release the hostname only after GET /api/vhosts/{id} returns 404. A 200, malformed response, or identity mismatch must leave a removal-pending row disabled. Bind every delayed update or release to the original tenant, hostname, reservation key, provider ID, and local state.

See the complete Hono example

The snippets above are intentionally small adaptation points. The companion repository contains the full Hono integration, including two SQLite tenants, server-rendered forms, hashed sessions, Host routing, the HTTP client, lifecycle guards, and local tests.

npm ci
cp .env.example .env
npm run build
npm run seed
npm start

Useful references