Going Live: A Static Next.js App on a Custom Domain
π Going Live: A Static Next.js App on a Custom Domain
Your app has been living at a project URL β yourname.github.io/YourApp β and it's time to give it a real address. This is the runbook: how to go from a project URL to a custom domain for a static-export Next.js Progressive Web App (PWA), without breaking authentication, the installable app, or your search-engine standing on the way.
It's written from a real go-live (a pet-adoption app called Held Paws), so the gotchas are the ones that actually bite, not the ones a generic guide warns about. Follow it top to bottom.
πΊοΈ The shape of the job
A custom-domain launch is five decisions and steps, in order β and the first is a decision, not a command:
- Pick your canonical domain (especially if you own several).
- Choose your host β GitHub Pages or Vercel.
- Point DNS + drop the basePath.
- Update the auth + API redirect URLs (the step everyone forgets).
- Fix the PWA manifest scope.
π― Step 1 β Pick your canonical domain
If you own one domain, skip ahead. If you own several β say heldpaws.com, heldpaws.org, raisedpaws.com, raisedpaws.org β you have a decision to make before you touch DNS, because everything downstream (auth redirects, Open Graph tags, analytics) needs a single source-of-truth Uniform Resource Locator (URL).
> β οΈ Serve the app from exactly one domain. Serving the same content from multiple domains splits your Search Engine Optimization (SEO) β search engines see duplicate content and divide your link authority instead of compounding it β and multiplies the auth-redirect URLs you have to keep in sync. Pick one canonical domain; make the others redirect to it.
How to pick:
- Brand match. Whatever name is already in your code and copy wins on zero-friction grounds. If the app says "Held Paws" everywhere,
heldpaws.is canonical andraisedpaws.becomes a defensive redirect. .comvs.org..orgsignals nonprofit/mission (good trust signal for a cause or a rescue);.comis what people default-type. The clean rule: match your legal status β a registered nonprofit leads with.org; everyone else leads with.comand holds.orgin reserve.- The rest become 301 redirects. Owning all four is smart β it blocks squatters and catches typos β but using all four as live sites is not. Own them all; serve from one; permanently (301) redirect the others.
heldpaws.com, and heldpaws.org + raisedpaws.com + raisedpaws.org will 301-redirect to it.
π Step 2 β GitHub Pages or Vercel?
Both give you a custom domain with automatic HyperText Transfer Protocol Secure (HTTPS). The difference is what your app needs.
GitHub Pages serves static files. If your Next.js app uses output: 'export' β no server-side rendering, no API routes at runtime, no image optimization server, no middleware β Pages is sufficient, free, and already wired to your repo. Its one real limit for this job: it hosts exactly one custom domain and can't redirect the others.
Vercel is built by the Next.js team and runs every Next.js feature natively β server-side rendering, Incremental Static Regeneration, API route handlers, middleware, next/image optimization, per-pull-request preview deployments β and it hosts multiple domains with native 301 redirects.
Here's the honest decision:
> π‘ Choose GitHub Pages if your app is static (it probably is, if you're reading a "static Next.js" guide) and you're fine handling the extra-domain redirects at your registrar or via Cloudflare (free). Choose Vercel if you want server features you're not using yet, per-PR previews, or you want all your domains + redirects managed in one dashboard.
For a fully static app, the multi-domain redirect need is the single most common reason people reach for Vercel at launch β and it's worth knowing that Cloudflare in front of Pages solves the same problem for free. Don't migrate hosts for a feature you can get with a redirect rule. (There's a fuller Pages-vs-Vercel breakdown elsewhere; this post covers both paths below.)
The steps that follow have a Pages path and a Vercel path where they differ.
π Step 3 β DNS + drop the basePath
The basePath trap (read this first)
A Next.js app deployed to github.io/YourApp runs under a basePath of /YourApp β every asset and link is prefixed with it. A custom apex domain serves from the root (/), so the basePath must be dropped. If you forget, every asset 404s at the new domain.
Well-built templates automate this. In ScriptHammer-based apps, the basePath is auto-detected: it's set to /RepoName only when no public/CNAME file exists. So adding the CNAME file is the single switch that both configures the domain and drops the basePath:
// scripts/detect-project.js β the load-bearing line
const basePath =
isGitHubActions && isGitHub && !cnameExists ? `/${projectName}` : '';
Add public/CNAME with one line β your canonical domain:
heldpaws.com
Commit it. On the next build, basePath becomes '' and the app serves from root.
DNS records β Pages path
At your DNS provider for the canonical domain, add GitHub Pages' apex records:
A @ 185.199.108.153
A @ 185.199.109.153
A @ 185.199.110.153
A @ 185.199.111.153
AAAA @ 2606:50c0:8000::153
AAAA @ 2606:50c0:8001::153
AAAA @ 2606:50c0:8002::153
AAAA @ 2606:50c0:8003::153
CNAME www tortoisewolfe.github.io.
Then GitHub β Settings β Pages: set the custom domain, wait for the DNS check to go green, and enable Enforce HTTPS.
The other domains β 301: at your registrar (or Cloudflare), forward heldpaws.org, raisedpaws.com, raisedpaws.org β https://heldpaws.com. Registrar forwarding is simplest; Cloudflare gives you clean redirects with HTTPS on every domain.
DNS records β Vercel path
Import the repo in the Vercel dashboard, then Project β Settings β Domains: add all four domains. Vercel tells you the exact records β typically an A record to 76.76.21.21 for the apex and a CNAME to cname.vercel-dns.com for www. In the same panel, set the three non-canonical domains to redirect to heldpaws.com (Vercel does the 301 natively). Vercel provisions HTTPS on all four automatically.
> β
Verify either path: dig heldpaws.com +short returns your host's IPs; curl -sI https://heldpaws.com/ is a 200 and asset URLs have no /RepoName prefix; curl -sI https://heldpaws.org/ returns a 301 to https://heldpaws.com/.
π Step 4 β Update auth + API redirect URLs (do not skip)
This is the step that silently breaks sign-up on launch day. If your app uses Supabase auth, the redirect URLs it emits β for email confirmation, OAuth (Open Authorization), and password reset β are built from your site's origin. Change the origin, and Supabase's exact-match allow-list no longer matches, so confirmation links bounce.
In the Supabase dashboard β Authentication β URL Configuration:
- Site URL:
https://heldpaws.com - Redirect URLs (add β trailing slashes matter; the allow-list is exact-match and static hosts 301 the slash-less form):
https://heldpaws.com/auth/callback/
- https://heldpaws.com/reset-password/
Keep the old github.io entries until you've verified the cutover, then remove them.
Server-side env (if you use Edge Functions): anything server-side β CORS (Cross-Origin Resource Sharing) allow-lists, payment provider return URLs β reads a site-URL env var independently of the browser. Update it too:
# Supabase Edge Function secret
NEXT_PUBLIC_SITE_URL=https://heldpaws.com
Build-time env (GitHub repo variables or Vercel env): set NEXTPUBLICSITEURL, NEXTPUBLICDEPLOYURL, and NEXTPUBLICBASE_URL (the last drives your blog's canonical + Open Graph tags) to the new domain, then redeploy so they bake into the static build.
> β οΈ Verify all four flows before you announce: sign up with a fresh email β the confirmation link lands on heldpaws.com/auth/callback/; OAuth round-trips back to the domain; password reset lands on /reset-password/; a test-mode payment returns to the domain (not the old URL).
π± Step 5 β Fix the PWA manifest scope
A PWA's manifest.json declares starturl and scope. On github.io/YourApp those need the /YourApp/ prefix; at an apex domain they should be /. If your manifest hardcodes / it was wrong under the basePath and becomes right_ at the apex β so going live fixes it, but confirm rather than assume:
{
"start_url": "/",
"scope": "/"
}
Verify: curl -s https://heldpaws.com/manifest.json shows scope and start_url of /, icons resolve (200), and a Lighthouse PWA audit reports the app installable. Install it from the new domain and launch it β it should open to the app, not a 404.
π§ The invariant that just inverted
One last thing that saves a future headache. If your template auto-detects basePath from the presence of public/CNAME (as above), then your project's own documentation probably says "public/CNAME must NOT exist." That rule was correct while you were on the project URL. The moment you go live, it inverts: public/CNAME must now exist and must survive future merges β or a routine upstream sync could delete it and silently knock you back to the project URL.
Update that note in your CLAUDE.md / README as part of go-live, so nobody (human or agent) "cleans up" the CNAME six months from now.
π― Launch checklist
[ ] Canonical domain chosen; others set to 301-redirect to it
[ ] Host chosen (Pages: static + registrar/Cloudflare redirects | Vercel: all-in-one)
[ ] public/CNAME added β basePath drops to ''
[ ] DNS records live; dig resolves; HTTPS enforced
[ ] Non-canonical domains 301 β canonical
[ ] Supabase Site URL + redirect allow-list updated (trailing slashes!)
[ ] Server + build env vars set to the new domain; redeployed
[ ] All 4 auth/payment flows verified end-to-end on the new domain
[ ] PWA manifest scope = /, app installs from the new domain
[ ] The "CNAME must not exist" note inverted in project docs
Go in order, verify each step before the next, and the launch is boring β which is exactly what you want a launch to be. The single highest-risk item is Step 4: change the domain, forget the Supabase allow-list, and the app looks live while every new sign-up quietly fails. Test it with a real fresh email before you tell anyone the doors are open.
