Tutorial

Next.js 15 Static Export to GitHub Pages The Complete Guide

January 25, 20267 min readby Rahul Patel

Deploy Next.js 15 App Router to GitHub Pages with static export. Covers next.config, GitHub Actions, fonts, images, trailing slashes, and common pitfalls.

Next.jsGitHub PagesDeploymentTutorial
Share:

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.
Note

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.

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",         // emit static files to /out instead of running a server
  trailingSlash: true,      // /about -> /about/index.html  required for GH Pages routing
  images: {
    unoptimized: true,      // next/image optimization requires a server; disable it
  },
  // If deploying to https://yourusername.github.io/repo-name/ (not a custom domain):
  // basePath: "/repo-name",
  // assetPrefix: "/repo-name",
};

export default nextConfig;

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 build produces 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/image normally 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/.

next.config.ts
const isProd = process.env.NODE_ENV === "production";

const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
  images: { unoptimized: true },
  basePath: isProd ? "/my-project" : "",
  assetPrefix: isProd ? "/my-project" : "",
};
Tip

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.

.github/workflows/deploy.yml
name: Deploy to GitHub Pages

on:
  push:
    branches: [main]
  workflow_dispatch: # allow manual trigger from the GitHub UI

permissions:
  contents: write

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build
        env:
          NEXT_PUBLIC_SITE_URL: https://rahhuul.github.io

      - name: Deploy to GitHub Pages
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./out
          cname: rahhuul.com  # remove this line if you're not using a custom domain

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.

bash
touch public/.nojekyll

Custom Domain Setup

Two things needed for a custom domain to work:

  1. 1.Add a CNAME file to public/ containing your domain name (no https://, no trailing slash)
  2. 2.Update your DNS: point an A record to GitHub's IP addresses, or a CNAME record to yourusername.github.io
public/CNAME
rahhuul.com

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.

src/app/blog/[slug]/page.tsx
import { BLOG_POSTS } from "@/data/blog-posts";

export async function generateStaticParams() {
  return BLOG_POSTS.map((post) => ({
    slug: post.slug,
  }));
}

export default function BlogPostPage({ params }: { params: { slug: string } }) {
  // params.slug is guaranteed to be one of the values from generateStaticParams
  const post = BLOG_POSTS.find((p) => p.slug === params.slug);
  if (!post) return null;
  return <article>...</article>;
}

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.

src/app/layout.tsx
import { Playfair_Display, Caveat, JetBrains_Mono } from "next/font/google";

const playfair = Playfair_Display({
  subsets: ["latin"],
  variable: "--font-playfair",
  display: "swap",
});

const caveat = Caveat({
  subsets: ["latin"],
  variable: "--font-caveat",
  display: "swap",
});

const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  variable: "--font-mono",
  display: "swap",
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${playfair.variable} ${caveat.variable} ${jetbrainsMono.variable}`}>
      <body>{children}</body>
    </html>
  );
}

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:

yaml
- name: Build
  run: npm run build
  env:
    NEXT_PUBLIC_SITE_URL: https://rahhuul.com
    NEXT_PUBLIC_GA_ID: G-XXXXXXXXXX
Warning

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:

bash
npm run build
npx serve out

# or with a specific port:
npx serve out -l 3001

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. 1.Add output: 'export', trailingSlash: true, images: { unoptimized: true } to next.config.ts
  2. 2.Add public/.nojekyll (empty file)
  3. 3.Add public/CNAME with your domain (if using custom domain)
  4. 4.Add generateStaticParams to all dynamic routes
  5. 5.Replace any revalidate exports with static data
  6. 6.Use next/font/google for fonts (self-hosted at build time)
  7. 7.Set NEXT_PUBLIC_ env vars in GitHub Actions workflow
  8. 8.Create .github/workflows/deploy.yml with peaceiris/actions-gh-pages
  9. 9.Enable GitHub Pages in repo Settings → Pages → Source: gh-pages branch
  10. 10.Test locally with npx serve out before pushing

Static sites are the right default. Add a server only when you have a problem that requires one.

more from the notebook

March 1, 2026Technical

AsyncLocalStorage in Node.js: The Complete Guide with Real Examples

Everything you need to know about AsyncLocalStorage from basic context tracking to building a full request-scoped loggi…

December 20, 2025Tutorial

GSAP ScrollTrigger + Lenis: Building Scroll-Driven Animations in Next.js

How to build smooth scroll-driven animations in Next.js using GSAP ScrollTrigger and Lenis. Character reveals, parallax,…