I host this portfolio on GitHub Pages. Zero cost, global CDN, no server to babysit at 2 AM. If your Next.js project doesn't need server-side rendering at runtime and most portfolios, marketing sites, and docs sites don't static export is the right call.
This guide covers everything I figured out setting up Next.js 15 App Router for static export and GitHub Pages deployment. I'll skip the theory and show you the exact config, workflow file, and the gotchas that will waste your afternoon if you don't know about them.
Why Static Export Over Vercel or a VPS
- Free forever GitHub Pages has no bandwidth caps for public repos
- CDN included GitHub's CDN serves your assets from edge nodes globally
- No cold starts static files serve instantly, no Lambda spin-up
- Zero maintenance no Node.js process to keep alive, no memory leaks, no uptime monitoring
- Git-native deploys push to main, site updates. That's the whole workflow.
Static export means no getServerSideProps, no Route Handlers that run at request time, no ISR (Incremental Static Regeneration), and no Next.js Image Optimization API. Everything must be deterministic at build time. For a portfolio or blog, that's a non-issue.
The next.config.ts You Actually Need
Start here. This is the minimal config for a static export deployed at the root of a custom domain (or yourusername.github.io). I'll cover the basePath variant for project repos below.
The three options that matter most:
- `output: "export"` tells Next.js to write static files to
out/instead of starting a server. Without this,npm run buildproduces a Node.js app, not static HTML. - `trailingSlash: true` GitHub Pages serves
/about/by loading/about/index.html. Without trailing slashes, navigating directly to a route returns a 404. - `images: { unoptimized: true }`
next/imagenormally uses a server-side optimization endpoint (/_next/image). That endpoint doesn't exist in a static export. This disables it so your images still render, just without on-the-fly resizing.
Deploying to a Project Repo (Not a Custom Domain)
If your site lives at https://rahhuul.github.io/my-project/ instead of https://rahhuul.github.io/, you need basePath and assetPrefix set to /my-project. Without them, your JS bundles and images will 404 because they'll be requested from / instead of /my-project/.
Using a custom domain (like rahhuul.com)? Keep basePath empty. The custom domain maps directly to your repo root, so no path prefix is needed.
GitHub Actions Deployment Workflow
Create this file at .github/workflows/deploy.yml. It triggers on every push to main, builds the site, and deploys the out/ directory to the gh-pages branch.
After the first successful run, go to your repo's Settings → Pages → Source and set it to the gh-pages branch, / (root). GitHub will show you the live URL.
The .nojekyll File
GitHub Pages runs Jekyll by default on any branch it serves. Jekyll ignores directories and files that start with an underscore like _next/, which is where all your JavaScript bundles live. Your site will be blank.
Fix: add an empty .nojekyll file to public/. Next.js copies everything in public/ to out/ during build. The peaceiris/actions-gh-pages action also adds this automatically, but putting it in public/ ensures it's there even if you swap deployment tools.
Custom Domain Setup
Two things needed for a custom domain to work:
- 1.Add a
CNAMEfile topublic/containing your domain name (nohttps://, no trailing slash) - 2.Update your DNS: point an
Arecord to GitHub's IP addresses, or aCNAMErecord toyourusername.github.io
GitHub's current IP addresses for A records: 185.199.108.153, 185.199.109.153, 185.199.110.153, 185.199.111.153. Add all four. DNS propagation takes up to 48 hours but is usually minutes.
Common Pitfalls
1. Dynamic Routes Require generateStaticParams
Any [slug] or [id] route must export generateStaticParams that returns the list of all possible values. Without it, Next.js doesn't know what pages to render, and the build either skips them or throws.
2. ISR / On-Demand Revalidation Silently Fails
If you have revalidate = 60 on a page or use revalidatePath() in a Server Action, the build will succeed but the revalidation never runs there's no server. All content is frozen at build time. Remove revalidate exports from static export pages or accept that you need a new deploy to update content.
3. Font Loading Google Fonts vs Self-Hosted
Next.js next/font/google downloads fonts at build time and self-hosts them this works perfectly with static export. The font files end up in out/_next/static/media/. No runtime Google Fonts request, no privacy issues, no FOUT.
4. Environment Variables Are Baked at Build Time
There's no server to read process.env at runtime. Only NEXT_PUBLIC_ variables are inlined into the JavaScript bundle at build time. Pass them to the GitHub Actions env: block:
Never put secrets (API keys, tokens) in NEXT_PUBLIC_ variables. They are literally embedded in your JavaScript bundle and visible to anyone who views source. Secrets only make sense in server-only contexts which static export doesn't have.
Testing the Static Output Locally
Before pushing, verify the out/ directory actually works. Don't open out/index.html directly in a browser file:// protocol breaks relative paths. Serve it over HTTP:
Navigate through the site, check that dynamic routes work, confirm images load, verify that hard-refreshing a page like /blog/my-post/ doesn't 404. If it 404s locally, it'll 404 on GitHub Pages too.
The Complete Setup Checklist
- 1.Add
output: 'export',trailingSlash: true,images: { unoptimized: true }tonext.config.ts - 2.Add
public/.nojekyll(empty file) - 3.Add
public/CNAMEwith your domain (if using custom domain) - 4.Add
generateStaticParamsto all dynamic routes - 5.Replace any
revalidateexports with static data - 6.Use
next/font/googlefor fonts (self-hosted at build time) - 7.Set
NEXT_PUBLIC_env vars in GitHub Actions workflow - 8.Create
.github/workflows/deploy.ymlwithpeaceiris/actions-gh-pages - 9.Enable GitHub Pages in repo Settings → Pages → Source:
gh-pagesbranch - 10.Test locally with
npx serve outbefore pushing
“Static sites are the right default. Add a server only when you have a problem that requires one.”