Skip to content
LazyFrog

Custom domains

How to connect your own domain to your site on LazyFrog: buy it anywhere, add the DNS records, and the certificate follows.

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):

    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.

For your AI: this page as Markdown · llms.txt