DEV Community

Maksym Kuzmitskyi (MaximusFT)
Maksym Kuzmitskyi (MaximusFT)

Posted on • Originally published at ma-x.im on

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

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

Static export remains the simplest deployment model for content-focused sites. This article documents the setup behind this very site — ma-x.im — a Next.js 15 project deployed to GitHub Pages.

Honestly, this was my first static export project. Vercel exists and it's free too. But I wanted something dead simple — no platform dependencies, no vendor lock-in, just push and it's live. GitHub Pages felt like the most boring choice possible, and that was exactly the point. A few weeks in, I'm genuinely happy with it. I don't think about deployments, I don't debug infrastructure, it just works. Sometimes the boring choice is the right one.

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

Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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,
};

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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';

Enter fullscreen mode Exit fullscreen mode
  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
    },
  };
}

Enter fullscreen mode Exit fullscreen mode

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}\`,

Enter fullscreen mode Exit fullscreen mode
  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,
  ];
}

Enter fullscreen mode Exit fullscreen mode

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',
  };
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Option B: ALIAS/ANAME record (if supported)

ALIAS @ <user>.github.io

Enter fullscreen mode Exit fullscreen mode

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

CNAME www <user>.github.io

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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/

Enter fullscreen mode Exit fullscreen mode

"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. It's what powers this site, and after running it for a while I can say the setup is genuinely low-maintenance.

The key decisions — output: 'export', images: { unoptimized: true }, generateStaticParams with dynamicParams = false — address the common failure modes. Understanding the constraints (no server runtime, basePath for Project Pages, explicit static generation for dynamic routes) saves you from most surprises.

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.

Top comments (0)