# Custom domains (https://lazyfrog.dev/docs/guide/custom-domains)

{/* Generated by scripts/generate-docs.ts from services/gateway/src/guide.md. Do not edit. */}

LazyFrog doesn't sell or register domains. The user buys theirs at whichever registrar they like,
or uses one they already own, and LazyFrog connects it. Never suggest LazyFrog sells domains,
and never quote a price: the registrar sets it, and some available names are sold there as
premium at a higher price. The way through, in order:

1. **Check the name.** `domain_read check` with `names`: the one they want first, then up to nine
   alternatives you propose (close spellings, a word added, another extension); the platform only
   checks the names you give it. Each comes back **available**, **taken** (with whether it already
   points to LazyFrog and where its DNS is) or **can't tell**: some extensions, many country
   codes among them, have no public lookup, and then a registrar's search says. The available
   alternatives are listed, up to five. Checks are limited per account per hour, so check a
   short list once rather than name by name. A taken name may be theirs already: ask.
2. **Buy it wherever they like** (or skip this for a name they own). Any registrar works, and
   country domains (`.co.za`, `.co.uk`, `.de`, `.com.au`) work the same way as `.com`. Name no
   registrar as the one to use unless they ask for options.
3. **Connect it with `domain add`** (paid plans: Hobby 1 per project, Pro 5, Business 10),
   with `example.com` or `portal.example.com`. The domain serves production, so it shows the site
   once it is published; it can be added before, and the DNS and certificate steps run meanwhile.
   The plan counts a bare domain with its www once, and each other hostname (`portal.`, `api.`,
   entries included) as one; released hostnames never count. `domain_list` says what counts ("2
   of 5 domains: example.com (with www), portal.example.com").
4. **The exact steps at their DNS provider.** The result says where the domain's DNS is hosted
   ("Your DNS is at Namecheap: add these records there", with the provider's how-to page; it is
   usually where they bought the domain) and lists the records: per hostname one ownership TXT
   and the certificate TXT records listed (usually two; more may appear within a minute, and
   `domain verify` shows them; safe to add any time, the HTTPS certificate is ready before traffic
   moves) and a CNAME to `customers.lazyfrog.app` (an ALIAS or flattening on the bare domain).
   Tell them to keep their existing MX records and the TXT records for email (SPF, DKIM, DMARC):
   only the records listed change. Where their DNS provider has accepted LazyFrog's one-click
   setup (Domain Connect), the result says so and the portal's Domains page shows "Connect
   automatically with" and the provider's name: one approval there adds the records, and it
   sends them back to the page. Everywhere else, the records are added by hand.
5. **The live checklist until it's live.** `domain_read status` shows each record as public DNS
   sees it now: in place, or exactly what is missing or wrong ("the CNAME for www points to
   old-host.com; change it to customers.lazyfrog.app", an A record where the CNAME goes, an AAAA
   still pointing at the old host, a CAA record that keeps the certificate authority out). The
   portal's Domains page shows the same checklist and updates by itself. Relay its words rather
   than guessing. `domain verify` checks now and records the state: waiting for DNS → ownership
   → certificate → live. Changes at a provider can take minutes to an hour. Cloudflare may still
   refuse a name within a minute or so of the add (a well-known brand's, or one that can't be
   connected): `domain verify` then says "Cloudflare refused this name", and nothing more can be
   done for it.

- `example.com` makes `www.example.com` the main address. Where the DNS provider can point the bare
  domain at us (CNAME flattening or ALIAS: Cloudflare, Namecheap, Porkbun, Hostinger, Name.com,
  Gandi, NS1, DNSimple), `example.com` is connected too, as a redirect to www with its own
  certificate.
- At a provider that can't point a bare domain by name (GoDaddy, IONOS, OVHcloud, Squarespace,
  Wix, Network Solutions, DreamHost, Amazon Route 53, Azure DNS), only `www.example.com` is connected: they
  edit the existing www record where the provider made one (not add a second) and forward the
  bare domain (301) to `https://www.example.com` at the provider, as the result says how. The
  bare domain never shows as live; that's expected. At a provider LazyFrog doesn't recognise, or
  one whose bare-domain records it hasn't confirmed, only www is connected too: if it allows a
  CNAME or ALIAS on the bare domain, `domain add example.com` again connects it as a redirect;
  otherwise they forward it.
- **Moving from another host:** the result says what the domain points at now. The CNAME replaces
  the existing A/AAAA (or CNAME) records on that name; every other record stays: MX and the TXT
  records for email (SPF `v=spf1`, DKIM, DMARC) and any verifications. On Cloudflare, the
  CNAME's proxy is off (grey cloud). Keep the old host until the domain is `live`, and don't
  cancel it while it still runs their mailboxes. If the DNS is run by the old site builder (Wix,
  Squarespace), plan moving the DNS before they cancel it.
- **Roles:** every hostname is one of
  - **main**: exactly one, the site's address and `SITE_URL` (www by default; `domain
    set_canonical` picks another, the bare domain included, and the old main one then redirects);
  - **redirect**: a 308 to the main address, path and query kept (the bare domain and
    `<slug>.lazyfrog.app`, which can't be anything else);
  - **entry**: served by the same app as it is, no redirect (`portal.example.com`,
    `api.example.com`). A hostname other than the bare domain and www added after the main one is
    an entry; `domain set_role` with `role: "entry"` or `"redirect"` changes one.
- Once the main address is live, the site lives there: `<slug>.lazyfrog.app` and the redirect
  hostnames send visitors to it. `SITE_URL` follows on the next publish or wake: use it, not a
  hard-coded address, for links, OAuth callbacks and emails. Update webhook addresses you gave
  other services (Stripe, GitHub) to the new domain too.
- **Several entry points, one app:** requests arrive with their own `Host`, and `SITE_HOSTS` lists
  the hostnames production answers on as they are, comma-separated: `SITE_URL`'s host first, then
  a main hostname not live yet (it opens the site as it is until then), then the entry hostnames,
  pending ones too (`www.example.com,portal.example.com,api.example.com`; before www is live
  `<slug>.lazyfrog.app,www.example.com,portal.example.com,…`). Never the redirect ones. Like
  `SITE_URL` it is set at a publish or wake: a hostname going live doesn't restart the app, so the
  next publish brings `SITE_URL` to the live domain. In the preview it is the preview's one
  address. Route by the exact hostname in the app; in Next.js (`proxy.ts` in Next 16,
  `middleware.ts` before it):

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

  // Each entry hostname, exactly, and the folder its pages live under (app/portal, app/api).
  // Exact hosts: host.startsWith("portal.") would also catch portal.lazyfrog.app, another site.
  const ENTRIES: Record<string, string> = {
    "portal.example.com": "/portal",
    "api.example.com": "/api",
  };

  export function proxy(req: NextRequest) {
    const host = (req.headers.get("host") ?? "").replace(/:\d+$/, "").toLowerCase();
    const base = ENTRIES[host];
    const path = req.nextUrl.pathname;
    if (!base || path === base || path.startsWith(`${base}/`)) return;
    const url = req.nextUrl.clone();
    url.pathname = path === "/" ? base : `${base}${path}`;
    return NextResponse.rewrite(url);
  }

  // Skips Next's own files, /api, static files by their extension (icon.svg, public/images,
  // robots.txt, sitemap.xml; not any dotted path: /invoices/INV-2026.10 is a page) and the
  // generated icons, social images and manifest: rewritten under /portal on an entry host
  // they'd 404. Each is anchored, so /icons and /api-docs are pages too. api.example.com/orders
  // becomes /api/orders and api.example.com/ is /api (add app/api/route.ts for it), while
  // api.example.com/api/orders passes through as it is: both answer.
  export const config = {
    matcher: [
      "/((?!_next/|api/|api$|favicon\\.ico$|(?:apple-)?icon\\d*(?:\\.\\w+)?$|(?:opengraph|twitter)-image(?:\\.\\w+)?$|manifest\\.(?:json|webmanifest)$|.*\\.(?:svg|png|jpe?g|gif|webp|avif|ico|txt|xml|webmanifest|css|js|map|woff2?)$).*)",
    ],
  };
  ```

  `req.url` (and `req.nextUrl.origin`) is `https://0.0.0.0:<port>` in production, never the
  visitor's address: build an absolute URL (a redirect, a link in an email) from the `Host`
  header or `SITE_URL`.
- **Test an entry hostname before its DNS:** the app routes by `Host`, so send one. In the
  preview, `exec` `curl -s -H 'Host: portal.example.com' http://localhost:<web port>/` (the
  port from services.json); after a publish, `http_get` with `env: "production"` and
  `headers: {host: "portal.example.com"}`. A sign-in cookie shared across hostnames can only be
  checked in production, on the real domain once it is live: browsers refuse a
  `Domain=example.com` cookie on any other address.
- **A sign-in shared across entry points** needs its cookie on the parent domain:
  `SITE_COOKIE_DOMAIN` is it (`example.com`, or `example.co.uk` by the Public Suffix List, from the
  main domain; absent in the preview and without a domain). Set `Domain=` to it, so no `__Host-`
  prefix, only when the request's host is it or ends with `.` + it, never on `lazyfrog.app` (every
  site there shares it); otherwise each hostname signs in on its own. An entry hostname under
  another registered domain can't share it. Add every entry hostname to the framework's host
  check, CSRF trusted origins and OAuth redirect URLs. One app serves them all: a different
  service or port per hostname isn't possible yet.
