Back to all posts
Next.jsGitHub PagesSSGCI/CDDevOps

Next.js Static Export to GitHub Pages: A Production Setup Guide

Feb 25, 2026
10 min read

Static export remains the simplest deployment model for content-focused sites. This article documents a real Next.js 15 setup deployed to GitHub Pages, covering the configuration decisions, deployment pipeline, and edge cases that arise when hosting a personal site with a blog.

Context

The goal: a personal site with static content and a blog, deployed to GitHub Pages with a custom domain. Requirements are low ops overhead, no monthly costs, and good reliability. GitHub Pages handles all of this well for static content.

GitHub Pages constraints shape the architecture:

Static hosting only. No server-side rendering, no API routes that execute at runtime, no middleware. Everything must be pre-rendered at build time.

Project Page path prefix. If you deploy to https://<user>.github.io/<repo>/, all routes are prefixed with /<repo>/. This breaks absolute paths and requires explicit configuration. With a custom domain, this prefix disappears — the site serves at root.

No redirects or rewrites. GitHub Pages serves files as-is. You cannot configure server-side redirects. Client-side routing works, but direct navigation to a non-existent file returns GitHub's 404 page unless you provide a 404.html.

404 handling. GitHub Pages serves 404.html from the root when a file isn't found. Next.js static export generates this file from your not-found.tsx, but only if you have one.

Core Concept

Next.js SSG vs SSR/ISR

Next.js supports multiple rendering strategies. For GitHub Pages, only Static Site Generation (SSG) works:

  • SSR (Server-Side Rendering): Renders pages on each request. Requires a Node.js server. Not compatible with static hosting.
  • ISR (Incremental Static Regeneration): Pre-renders at build, then revalidates on a schedule. Requires a server. Not compatible with static hosting.
  • SSG (Static Site Generation): Pre-renders all pages at build time. Outputs plain HTML/CSS/JS files. Works on any static host.

Static Export in Next.js 15

In Next.js 13+, static export is enabled via output: 'export' in your config. The build process generates a folder (default: out/) containing all HTML files, assets, and client-side JavaScript bundles.

This mode disables features that require a server runtime:

  • API routes (unless they're static JSON files)
  • Server Actions
  • Middleware
  • Dynamic rendering with cookies() or headers()
  • Redirects and rewrites in next.config.ts
  • Image Optimization API (requires a server)

basePath for Project Pages

When deploying to https://<user>.github.io/<repo>/, the site lives at a subpath, not root. Without configuration, asset URLs and links break:

Expected: /styles/main.css
Actual location: /<repo>/styles/main.css
→ 404

The basePath config prepends the prefix to all routes and asset imports:

// next.config.ts const nextConfig: NextConfig = { basePath: '/my-repo', output: 'export', };

Next.js automatically handles:

  • <Link href="/about"> becomes /<repo>/about
  • Static imports and public folder assets
  • CSS/JS bundle paths

With a custom domain, basePath should not be set. The domain points directly to the repo's Pages site, so routes serve at root.

trailingSlash

Controls whether routes end with /:

  • trailingSlash: false/blog/my-post (outputs /blog/my-post.html)
  • trailingSlash: true/blog/my-post/ (outputs /blog/my-post/index.html)

GitHub Pages handles both, but trailingSlash: true is more predictable for some edge cases with direct file navigation. The trade-off is URL aesthetics. This repo uses false.

assetPrefix

Separate from basePath, assetPrefix controls where JS/CSS bundles are loaded from. Useful when assets are served from a CDN. For most GitHub Pages deployments, it's not needed — basePath handles everything.

next/image on Static Hosts

The default next/image component uses Next.js Image Optimization API, which requires a server. On static export, you must either:

  1. Set images: { unoptimized: true } to disable optimization and use raw image URLs
  2. Use a third-party loader (Cloudinary, Imgix, etc.)
  3. Avoid next/image entirely and use <img> tags

This repo uses native <img> tags with a custom fallback wrapper, avoiding the issue entirely.

Implementation Example

1) Configure Next.js for static export (SSG)

The current next.config.ts in this repo:

import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'export', images: { unoptimized: true, }, trailingSlash: false, }; export default nextConfig;

This configuration:

  • Enables static export to out/ directory
  • Disables image optimization (required for static hosting)
  • Uses clean URLs without trailing slashes

For a Project Page deployment (without custom domain), add basePath:

// Proposed change for Project Page deployment const nextConfig: NextConfig = { output: 'export', basePath: '/ma-x.im', // Repository name images: { unoptimized: true, }, trailingSlash: false, };

Limitations to understand:

With output: 'export', these features throw build errors or are ignored:

  • redirects and rewrites in config
  • Middleware (middleware.ts)
  • API routes with runtime logic
  • Server Actions
  • cookies(), headers(), searchParams in Server Components (without force-static)

2) Make routing and links survive basePath (Project Page)

The failure mode: During development, the site runs at localhost:3000/. Links like <Link href="/blog"> work. After deploying to https://user.github.io/repo/, the same link points to https://user.github.io/blog (missing the repo prefix), returning 404.

How Next.js handles it: When basePath is configured, next/link and next/router automatically prepend it. You don't need to change your code — just avoid hardcoded absolute paths.

From this repo's src/app/blog/page.tsx:

import Link from 'next/link'; // This works correctly with or without basePath <Link href={\`/blog/\${post.id}\`}> <h2>{post.title}</h2> </Link>

What breaks:

  1. Hardcoded absolute paths in JavaScript/CSS:
// Bad: Ignores basePath const logoUrl = '/images/logo.png'; // Good: Use relative paths or public folder imports import logo from '@/public/images/logo.png';
  1. Metadata URLs (OpenGraph, Twitter, canonical):

From this repo's src/app/blog/[slug]/page.tsx:

export async function generateMetadata({ params }: BlogPostPageProps): Promise<Metadata> { const { slug } = await params; const post = blogPosts.find((p) => p.id === slug); return { openGraph: { url: \`https://ma-x.im/blog/\${post.id}\`, // Hardcoded full URL }, }; }

This works because the site uses a custom domain at root. For a Project Page, you'd need:

// Proposed change for Project Page const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://user.github.io/repo'; url: \`\${BASE_URL}/blog/\${post.id}\`,
  1. Sitemap and RSS links:

This repo generates a sitemap via app/sitemap.ts. All URLs must use the full absolute base. Note the export const dynamic = 'force-static' — it is required for output: 'export', otherwise the build fails:

// app/sitemap.ts import type { MetadataRoute } from 'next'; import { blogPosts } from '@/data/posts'; export const dynamic = 'force-static'; const BASE_URL = 'https://ma-x.im'; export default function sitemap(): MetadataRoute.Sitemap { const posts = blogPosts.map((post) => ({ url: \`\${BASE_URL}/blog/\${post.id}\`, lastModified: new Date(post.date), changeFrequency: 'monthly' as const, priority: 0.7, })); return [ { url: BASE_URL, lastModified: new Date(), changeFrequency: 'monthly', priority: 1 }, { url: \`\${BASE_URL}/blog\`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 }, { url: \`\${BASE_URL}/contact\`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.5 }, ...posts, ]; }

New blog posts are picked up automatically — the sitemap is built from the same blogPosts array used by the blog pages.

Pair the sitemap with app/robots.ts so search engines can discover it. Same rule applies — export const dynamic = 'force-static' is required:

// app/robots.ts import type { MetadataRoute } from 'next'; export const dynamic = 'force-static'; export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: '*', allow: '/', }, sitemap: 'https://ma-x.im/sitemap.xml', }; }

3) Blog pipeline: Markdown + react-markdown (repo-specific)

This repo stores blog posts as TypeScript files with embedded markdown content, not as .md files. This approach has trade-offs:

Advantages:

  • Type safety on post metadata
  • No build-time file system reading
  • Posts are bundled with the application

Disadvantages:

  • Content editing requires code changes
  • Larger JavaScript bundles if content grows
  • No separation between content and code

Post structure in src/data/posts/types.ts:

export interface BlogPost { id: string; title: string; excerpt: string; tags: string[]; date: string; readTime: string; content: string; // markdown content }

Static route generation in src/app/blog/[slug]/page.tsx:

import { blogPosts } from '@/data/posts/index'; // Generate static pages for all posts at build time export async function generateStaticParams() { return blogPosts.map((post) => ({ slug: post.id, })); } // Return 404 for slugs not in the list (required for output: 'export') export const dynamicParams = false;

The dynamicParams = false is critical. Without it, Next.js assumes dynamic routes might be handled at runtime — incompatible with static export.

Rendering markdown via src/components/MarkdownContent.tsx:

'use client'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; export function MarkdownContent({ content }: { content: string }) { return ( <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ code: ({ className, children, ...props }: any) => { const match = /language-(\w+)/.exec(className || ''); const codeString = String(children).replace(/\n$/, ''); if (match) { return ( <SyntaxHighlighter style={oneDark} language={match[1]} PreTag="div"> {codeString} </SyntaxHighlighter> ); } return <code className="px-2 py-0.5 bg-gray-100 rounded text-sm" {...props}>{children}</code>; }, }} > {content} </ReactMarkdown> ); }

Common pitfalls:

  1. Missing generateStaticParams: Dynamic routes without static params fail on export with a build error.

  2. Forgetting dynamicParams = false: The build succeeds, but routes not in generateStaticParams cause errors.

  3. Runtime file system access: If you read .md files with fs, ensure it happens only in generateStaticParams or generateMetadata, not in component render functions. In App Router, this works correctly since those functions run at build time.

  4. Syntax highlighter SSR issues: Libraries like react-syntax-highlighter can have hydration mismatches. Marking the component with 'use client' and ensuring consistent rendering helps.

4) GitHub Actions deployment to GitHub Pages

The existing workflow at .github/workflows/deploy.yml:

name: Deploy to GitHub Pages on: push: branches: [master, new-version-v2] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: "pages" cancel-in-progress: false jobs: build: 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: Lint run: npm run lint - name: Type check run: npx tsc --noEmit - name: Build run: npm run build - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ./out deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4

Key elements:

  1. Permissions: pages: write and id-token: write are required for the official deploy action. contents: read allows checkout.
  2. Lint and type check before build: Catches issues before producing artifacts. Lint validates code style, tsc --noEmit verifies types without emitting files.
  3. Concurrency: Prevents overlapping deployments with cancel-in-progress: false (lets the current deployment finish rather than canceling mid-deploy).
  4. Artifact upload: The upload-pages-artifact action packages ./out (Next.js static export directory) for deployment.
  5. Two-job structure: Build and deploy are separate jobs. The deploy job waits for build with needs: build.
  6. Environment: The github-pages environment enables GitHub's deployment tracking and status checks.

Repo settings checklist:

  1. Navigate to repository Settings → Pages
  2. Under "Build and deployment", set Source to "GitHub Actions"
  3. Do not select a branch — the workflow handles deployment
  4. If using a custom domain, enter it under "Custom domain"
  5. Enforce HTTPS (checkbox)

5) Custom domain on GitHub Pages

This repo uses the custom domain ma-x.im. Setup involves three parts:

1. CNAME file in the repository:

The file public/CNAME contains:

ma-x.im

This file must exist in the deployed output. Since it's in public/, Next.js copies it to out/ during build. The GitHub Pages deploy action then includes it in the deployment artifact.

2. Repository settings:

In Settings → Pages → Custom domain, enter ma-x.im. GitHub validates DNS and provisions an SSL certificate.

3. DNS configuration:

For an apex domain like ma-x.im, configure DNS at your registrar:

Option A: A records (IPv4)

A     @     185.199.108.153
A     @     185.199.109.153
A     @     185.199.110.153
A     @     185.199.111.153

Option B: ALIAS/ANAME record (if supported)

ALIAS    @    <user>.github.io

For a subdomain like www.ma-x.im:

CNAME    www    <user>.github.io

HTTPS certificate provisioning:

After DNS propagates, GitHub automatically requests a certificate from Let's Encrypt. This can take minutes to hours depending on DNS propagation. During this time, the site may show a certificate warning.

Keeping CNAME stable:

The current setup (CNAME in public/) works correctly. An alternative for repos where you can't commit to public/:

- name: Build run: npm run build - name: Add CNAME run: echo "ma-x.im" > ./out/CNAME - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ./out

6) Local verification and "it works on my machine" traps

After building, verify the static output locally:

# Build the site npm run build # Serve the output directory npx serve out

This serves at http://localhost:3000 by default. All routes should work, including direct navigation to /blog/building-design-systems-that-scale.

Testing basePath locally:

If deploying as a Project Page with basePath: '/my-repo', the local test needs to simulate the path prefix:

npx serve out -l 3000 # Then access at http://localhost:3000/my-repo/

"Works locally, broken in production" scenarios:

  1. Missing CNAME: Site deploys but custom domain doesn't work.
  2. Absolute paths in code: Work at root locally, break with basePath or different domain.
  3. Environment variables: NEXT_PUBLIC_* variables are baked in at build time. If you test with .env.local but CI doesn't have the same values, behavior differs.
  4. Browser caching: Hard refresh or incognito mode when debugging deployment issues.

Where It Breaks in Enterprise

Static export works for content sites but hits walls in enterprise contexts:

No server features. Auth, personalization, A/B testing, and anything requiring runtime logic must move to external services or client-side JavaScript.

Preview and drafts. Editorial workflows typically need preview deployments of unpublished content. Static export requires rebuilding for every preview — doable but slow.

Search. Client-side search works for small sites (load a JSON index, search in JavaScript). For larger content, you need an external search service.

Comments. Disqus, Giscus, or similar third-party embeds. No server means no self-hosted comment storage.

Build time scaling. As content grows, build times grow. A 1000-post blog might take 10+ minutes to build. This is manageable but affects development velocity.

Image optimization. Without the Next.js optimization API, you handle image resizing externally or serve unoptimized assets. On fast connections, this is fine. On mobile, it hurts.

Multi-locale with basePath. Supporting multiple languages with static export works but gets complex with custom domains per locale or locale prefixes combined with basePath.

Analytics and privacy. Client-side analytics (Google Analytics, Plausible) work. Server-side analytics (for privacy) require a proxy or separate service.

Common Mistakes

Forgetting basePath for Project Pages. Routes and assets return 404. The site looks broken on GitHub but works locally.

Using next/image without unoptimized: true. Build fails with an error about Image Optimization requiring a server.

Expecting redirects/rewrites to work. They're silently ignored on static export. Use client-side navigation or restructure URLs.

Missing generateStaticParams for dynamic routes. Build error: "Page with output: export has dynamic route but no generateStaticParams."

Using dynamicParams = true with static export. Build might succeed but unknown slugs cause issues. Always set dynamicParams = false.

Runtime environment variables. process.env.MY_VAR in client components doesn't work — it's replaced at build time. Use NEXT_PUBLIC_ prefix for client-visible values.

Deploying the wrong folder. Next.js outputs to out/ by default, but if you forget to check the workflow, you might deploy .next/ or the root folder.

Broken canonical/OG URLs. Hardcoded URLs that don't account for basePath or custom domain result in wrong social previews and SEO issues.

When NOT To Use This

Static export to GitHub Pages is wrong when:

  • You need authentication or user sessions
  • Content is personalized per user
  • You need middleware (geo-redirects, A/B tests at the edge)
  • You need API routes with runtime logic
  • Build times exceed acceptable CI limits (hundreds of pages or more)
  • You need ISR for frequently changing content

For these cases, consider Vercel, Netlify, or Cloudflare Pages — platforms with server/edge support that handle these features natively with Next.js.

How I Apply This in Real Projects

For personal sites and blogs, I keep the stack minimal: static export, GitHub Pages, and a custom domain. No external services beyond what's required.

I automate the deployment pipeline once and don't touch it. The workflow in this repo hasn't changed significantly since initial setup.

I intentionally don't build: comments, search (for small sites), analytics (unless required), or complex CMS integrations. Each adds maintenance burden disproportionate to value for personal projects.

When a project genuinely needs server features, I move to a platform that supports them rather than working around static export limitations.

Practical Recommendations

Config checklist:

  • output: 'export' in next.config.ts
  • basePath set if deploying to /<repo>/ (omit for custom domain)
  • images: { unoptimized: true } if using next/image
  • trailingSlash set based on URL preference
  • dynamicParams = false on all dynamic routes

Content pipeline:

  • generateStaticParams returns all valid slugs
  • Metadata URLs use full absolute URLs for social sharing
  • CNAME file in public/ for custom domains

CI/CD checks:

  • Lint runs before build (npm run lint)
  • Type check runs before build (npx tsc --noEmit)
  • Build artifact is correct directory (out/)
  • Workflow triggers on correct branches

Regression tests:

  • Manually verify a few routes after deployment
  • Check 404 page appears for invalid routes
  • Verify assets load (images, CSS, JS)
  • Test OG images in social share debuggers

Summary

Next.js static export to GitHub Pages is a production-ready approach for content sites. The setup requires understanding the constraints: no server runtime, basePath for Project Pages, and explicit static generation for dynamic routes.

This repo demonstrates a working configuration: Next.js 15 with App Router, TypeScript-based blog posts rendered with react-markdown, and GitHub Actions deployment to a custom domain. The key decisions — output: 'export', images: { unoptimized: true }, generateStaticParams with dynamicParams = false — address the common failure modes.

The approach scales well for small to medium sites. For larger content needs or server-dependent features, migration to a full hosting platform is the right move rather than fighting static export limitations.

If you have feedback, alternative approaches, or just want to discuss this topic — feel free to reach out.

Found this helpful?

Let's discuss your project needs.

Get in touch