Next.js With Routes on GitHub Pages — And Where the Backend Actually Lives
A senior front-end engineer asked me a fair and specific question this week: show me a Next.js app with routes running on GitHub Pages. Not a landing page. Not a README with a theme. A real app, with routing, and a backend.
It's a good question because it has a real edge in it. You cannot run a Node server on GitHub Pages. That part is simply true, and any answer that pretends otherwise deserves the skepticism.
So here is the actual answer, which is better than the question assumes: you split the planes. The app — every route — statically exports to a CDN. The backend lives somewhere else entirely, and the app calls it. That split is not a compromise you accept to fit on Pages. It is the reason the site is close to unkillable, and it's the reason the AI-crawler problem that started this whole conversation doesn't exist for us.
aitherium.com is that architecture, in production, right now.
What's actually being served
Check it yourself before reading further. Everything below is verifiable from outside, with no access to anything of mine:
$ dig +short aitherium.com
185.199.108.153 # GitHub Pages
185.199.109.153
185.199.110.153
185.199.111.153
$ curl -sI https://aitherium.com | grep -i '^server'
Server: GitHub.com
That's GitHub's CDN serving the apex. And what it's serving is a Next.js 16.2.7 application: 783 page routes and 215 blog posts, statically exported. Client-side routing, dynamic segments, the blog index, every post page. Load it and navigate around — the router works, deep links work, refresh on a nested route works.
The same codebase also builds as a long-running server for the dynamic surfaces. One repo, two targets.
Two build targets, one line of config
This is the entire mechanism, and it's smaller than people expect. From next.config.ts:
const isGithubPages = process.env.GITHUB_PAGES === "true";
const nextConfig: NextConfig = {
// Static export for Pages, standalone server for Docker
output: isGithubPages ? "export" : "standalone",
// /tour resolves to /tour/index.html, not /tour.html
trailingSlash: isGithubPages,
// Pages has no image optimizer
images: { unoptimized: isGithubPages },
};
Three lines, one environment variable. GITHUB_PAGES=true in CI produces the static export; unset, the identical source builds the server image.
trailingSlash is the one that matters most and the one people miss. It's what makes a route directory-shaped, so GitHub Pages resolves /tour to /tour/index.html instead of hunting for a /tour.html that was never emitted. That single boolean is most of the "routes don't work on Pages" problem.
Two more mechanics finish it, both in the deploy job:
touch out/.nojekyll # stop Jekyll eating _next/
cp out/index.html out/404.html # the client-routing fallback
.nojekyll keeps GitHub's Jekyll layer from discarding the underscore-prefixed _next/ directory, which would otherwise strip your entire asset bundle. And copying index.html over 404.html is the whole SPA fallback: Pages serves 404.html for any path it has no file for, the app boots, and the client router takes the URL from there. One cp. That's the "one fallback rule."
Where the backend went
The app has 928 API routes. None of them are on Pages, and this is where the honesty has to be exact.
A pre-deploy step moves src/app/api out of the tree before the static build and restores it afterward — with a crash handler and an orphaned-backup check, so an interrupted build can't leave your API directory somewhere else. The static export is genuinely serverless. There is no Node process behind that CDN, and nothing on Pages pretends there is.
The backend lives on a second origin, and the statically exported client calls it cross-origin through a resolved API base rather than a same-origin /api path. You have two good places to put it:
- A Cloudflare Worker. Free to 100,000 requests/day, runs at the edge, no machine to own. This is the right answer for most apps and the one I'd recommend to anyone starting today. Know the free tier's other number too: 10 ms of CPU per request. That is generous for routing, auth checks, and talking to a database, and it is not where you run inference.
- A Cloudflare Tunnel into a machine you already own. A
cloudflareddaemon opens outbound QUIC connections to Cloudflare's edge and serves your hostnames back through them. No port forwarding, no static IP, no inbound firewall hole, TLS terminated at the edge. Ours points at Docker on hardware in my office.
The split has a property worth stating plainly: these two planes fail independently. If my machine is off, the site is up — every page, every post, fully navigable, with the dynamic panels degraded. If Cloudflare has a bad afternoon, GitHub is still serving. There is no single origin whose loss takes the site down, because the thing most people call "the origin" isn't serving the pages at all.
The CI that makes it trustworthy
A static export is easy to ship and easy to ship broken — you get a 200 on a page with no JavaScript and no way to tell. So the deploy is gated. Every push to the release branch runs, in order:
- static-export compatibility check
- dead
/apiusage scan on public pages (a call that would 404 once the routes move out) - ESLint on the website pages
- build with
GITHUB_PAGES=true validate-static-export.js— CNAME, required pages, asset paths,basePath, file sizes, blog post pages- dead internal link crawl across the exported HTML
- minimum page-count gate (a suspiciously small export fails the build rather than deploying)
- secret and internal-IP scan on the final
out/directory, not on source - headless Chromium runtime smoke test against the built output
upload-pages-artifact→deploy-pages
Two of those are worth calling out because they're the ones that catch the failures you can't see. Scanning the final export rather than the source is the only way to know what actually reaches a public CDN — the artifact is the thing that ships, so the artifact is the thing you scan. And the page-count gate turns the quietest possible failure into a red build: a broken export doesn't error, it just emits fewer files, and without a floor it deploys a hollow site that returns 200 on everything.
This has been running since February. The workflow landed 2026-02-08; the export switch and the validator landed together on 2026-02-22.
The crawler math
The thread this came out of was about AI crawlers: 3.2 million requests in 24 hours from one crawler, against a site of roughly 500 pages. Every page fetched every few minutes, forever.
Against this architecture, that traffic never reaches an application server. The pages were not being generated per-request in the first place — they were built once, in CI, and handed to a CDN. There is no origin to crush because there is no origin in the request path. That's the part that actually matters, and it's why nobody here has ever looked at a traffic graph and worried.
The obvious objection arrived quickly, and it is correct: we're on Vercel and it's already 100% cached. Cached is cached. But none of this was ever "Pages instead of your host." It is the same split-plane idea as the rest of this post, and you can run both. Keep the platform you like for everything that needs compute; move the crawler-facing static plane onto something that doesn't meter. Then three million crawler requests land where egress is free and never touch the billed path at all. The question is not which host caches better. It is which plane each request lands on.
Because "cached" and "not billed" are different statements. A cache hit on a metered platform is still metered — transfer and requests are billable units whether or not the origin was consulted. That difference is the entire distance between a shrug and the hosting bill that makes someone take a site offline, which is the thing the original thread was actually worried about.
Now do the arithmetic honestly, because "just put it on Pages" is not a complete answer either. Our apex document is 55 KB. Three point two million requests at that size is roughly 176 GB — and GitHub Pages carries a soft bandwidth limit of 100 GB/month. Pages alone absorbs the load, but at that volume you are having a conversation with GitHub about it.
So the complete answer has a second layer, and it's a genuine trade-off rather than a free win. You can proxy the static site through Cloudflare — orange-cloud the record, let Cloudflare cache the export at its edge, and the crawler is served from Cloudflare's network while GitHub sees a small fraction of the requests. Cache everything, since every file is static by construction; there is nothing to invalidate but a deploy. That turns 176 GB of origin bandwidth into a cache-hit ratio, free.
What it costs you is independence. Our apex is deliberately the other way — plain DNS straight to GitHub Pages, grey cloud, no proxy — because that layer is the one that survives everything, including a problem with our own Cloudflare account. The dynamic hostnames go through Cloudflare; the static floor does not.
Pick per site. If you're absorbing crawler traffic at millions of requests, orange-cloud it. If what you want is a surface that cannot be taken down by any single vendor including your CDN, leave it grey and accept the bandwidth conversation. Knowing which one you chose, and why, is the actual engineering.
And there is one traffic pattern that defeats every layer above, worth knowing before you trust a cache with it. A pagination loop mints new URLs. If a crawler is walking ?page=N with N climbing forever, each request is a distinct cache key — so each one is a MISS, and each one reaches your origin. This is the one shape where caching does not help, and it hides well: a site-wide hit ratio of 99% can sit on top of a 0% hit ratio for exactly the paths doing the damage, because the loop's requests are a rounding error in the total until suddenly they aren't.
So pull the hit ratio for the paginated paths alone, not the site average. If it isn't near 100% there, the cache is not protecting you and never was — and the fix is at the source: a canonical tag and a robots.txt rule on the paging params, so the loop stops being minted rather than being absorbed forever.
The honest scope of all of it: this solves load and cost. It does not stop crawlers from crawling, and it does not fix a loop — that stays a canonical-URL problem. What the architecture removes is the failure mode where a crawler's enthusiasm becomes your downtime and your hosting bill.
What you give up
An engineer will check this section first, so let me be direct. A static export means:
- No per-request SSR. Content that must be fresh at request time comes from the client calling your backend, not from the server rendering it.
- No server-side auth on the static plane. Anything gated authenticates against the other origin. Do not put a secret in code that ships to a CDN — anyone can read it.
- API routes must move. Workers or a tunnel. That's the trade, and it's the one that buys you everything else.
- No ISR. Rebuild and redeploy; for us that's a push.
For a large class of apps — including a 783-route one — that's a good trade. For an app whose every page is personalized at request time, it isn't, and you should build that one differently.
Doing this yourself
We packaged the whole path as skills so it's a conversation with an agent rather than a weekend:
/ship-an-app-free— idea to a public URL on free tiers. Written for someone who has never deployed anything: what to choose, the exact commands, and the settings that silently break a deploy./repo-to-website— turn a repo that greets visitors with a raw README into a real site with a landing page, live on Pages./website-as-code— the full stack: Pages frontend, Cloudflare Tunnel backend from any machine, and a fallback Worker that serves a branded maintenance page during an outage instead of Cloudflare's raw 502.
That last one is the piece most people skip and then regret. When your backend is a machine you own, it will sometimes be off. The Worker sits in front of the dynamic hostnames, passes traffic through when the origin is healthy, keeps a last-good snapshot of the anonymous HTML, and serves that snapshot — bannered and auto-reloading — when the origin isn't answering. Your visitors get your site, slightly stale, instead of an error page with someone else's logo on it.
The skills are public: github.com/Aitherium/aither-skills.
The point
The interesting claim was never "GitHub Pages is powerful." It's that the split — static app on a CDN, backend on an edge worker or a tunnel to your own hardware — gives you a site that costs nothing to serve, survives your infrastructure having a bad day, and treats three million crawler requests as a cache-hit statistic.
It's a Next.js app with 783 routes. It's on GitHub Pages. The config is three lines and the fallback is one cp.
You can check every claim in this post from your own terminal, which is the only kind of claim worth making.