billyhale.me

· web-development, github, next.js, react

How to build and deploy a static site with Next.js and GitHub Pages

git pushactionsnext buildgh-pagesmainon: push→ out/static HTML01020304
fig. 1 — push to main, static HTML lands on Pages

Project setup

Use create-next-app to make your life easy. I suggest using typescript and using the app router with a src directory for better organization.

bash
$ npx create-next-app@latest my-static-site
$ cd my-static-site

One modification is required to next.config.ts for Static Site Generation. Setting nextConfig.output to export will enable static export. Additionally, disabling image optimization will bypass quirks in next/image. Typically I suggest using a 3rd party CDN such as imagekit.io (which offers a fairly generous free tier) that will produce better results anyway. I may write an article about this in the future, until then documentation can be found here.

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

const nextConfig: NextConfig = {
  /* config options here */
  output: "export",
  images: {
    unoptimized: true
  }
};

export default nextConfig;

With Next.js I find it important to preview your work in production mode, dev mode is tuned for developer experience and has caveats that can make you feel like a fool come deployment time. Granted, using static exports this effect is minimal compared to Server Side Rendering, however it is a good habit none the less.

Use the serve package to easily serve the out directory.

package.jsonjson
{
    "scripts": {
        "dev": "next dev --turbopack",
        "build": "next build",
        "start": "npx serve@latest out",
    },
}

Development

If you used my recommendations for create-next-app, you should have a src/app directory. Modify your root layout.tsx and page.tsx to your liking. You are able to use any of the routing features of Next.js with a static generated site including dynamic routes (or dynamic segments to use Next.js nomenclature). Your src directory will likely end up looking something like this:

txt
src/
  app/
    layout.tsx       # Root layout for all routes
    page.tsx         # Home page (/)
    about/
      page.tsx       # About page (/about)
    blog/
      page.tsx       # Blog index (/blog)
      [slug]/
        page.tsx     # Dynamic blog post page (/blog/my-post)
  components/
  ...

Dynamic segments

When using dynamic segments with a SSG site. You will need to implement generateStaticParams and generateMetadata within page.tsx. When next build is executed, it will use these generated parameters to determine what pages to create. Metadata generation is technically optional, but if you care about search engines you should setup at least the basics.

src/app/blog/[slug]/page.tsxtypescript
import type { Metadata, ResolvingMetadata } from 'next'

export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then((res) => res.json())
 
  return posts.map((post) => ({
    slug: post.slug,
  }))
}

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  // read route params
  const { id } = await params
 
  //...
 
  return {
    title: post.title,
    description: post.description,
    alternatives: {
      canonical: post.canonical,
    },
    robots: {
      index: true,
      follow: true,
    }
  }
}
 
export default function Page() {}

Deployment

Github actions is the way to go. Unless your site is large it will only take a minute or two to build and publish your site, so your likely to hit the rate limits for pages deployments before you run out of actions minutes for the month, even on the free tier. Create a file in the .github/workflows directory.

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

on:
  push:
    branches: ["main"]
  # Allow manual triggers
  workflow_dispatch:

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
  contents: read
  pages: write
  id-token: write

# Allow only one concurrent deployment
concurrency:
  group: "pages"
  cancel-in-progress: true

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

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

      - name: Install dependencies
        run: npm ci

      - name: Build with Next.js
        run: npm run build

      - name: Setup Pages
        uses: actions/configure-pages@v4

      - 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

Search engine optimization

If you want your site to be indexed effectively by search engines, here are some quick tips to help you achieve your goal.

Use a custom domain

While not technically necessary, Google seems to avoid pages on the github.io domain unless that are of unusually high quality. Setting up a custom domain will allow you to start from a normal baseline, rather than already in the hole. Plus branding, even personal branding will eventually pay off.

Set up a sitemap

Next.js can be configured to generate a sitemap.xml file for you at build time.

Create a sitemap file src/app/sitemap.ts. Unfortunately Next.js is not very smart about sitemap generation, so you will have to supply the sitemap data either inline or by reusing similar logic as in generateStaticParams.

src/app/sitemap.tstypescript
import { MetadataRoute } from 'next'
import { getAllPosts, BlogPost } from '@/lib/blog'

// Required for static export
export const dynamic = 'force-static'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // Base URL for your site
  const baseUrl = `https://yoursite.com`;
  
  // Get all blog posts
  const posts = getAllPosts()
  
  // Create sitemap entries for blog posts
  const blogEntries = posts.map((post: BlogPost) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: new Date(),
    changeFrequency: 'weekly' as const,
    priority: 0.8,
  }))
  
  // Define static pages
  const staticPages = [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'weekly' as const,
      priority: 1.0,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: 'monthly' as const,
      priority: 0.8,
    },
    {
      url: `${baseUrl}/blog`,
      lastModified: new Date(),
      changeFrequency: 'weekly' as const,
      priority: 0.9,
    },
    {
      url: `${baseUrl}/lego`,
      lastModified: new Date(),
      changeFrequency: 'monthly' as const,
      priority: 0.7,
    },
  ]
  
  // Combine all entries
  return [...staticPages, ...blogEntries]
}

Create an environment variable for your canonical domain

By default Next.js will use relative links everywhere. This will work, however its best practice to include the absolute url on at least your canonical metadata and within your sitemap.

src/app/sitemap.tstypescript
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // Base URL for your site
  const baseUrl = `https://${process.env.CANONICAL_DOMAIN}$`;
  
  //...
}
src/app/blog/[slug]/page.tsxtypescript
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = getPostBySlug(slug);

  if (!post) {
    return {
      title: "Post Not Found",
    };
  }

  return {
    title: `${post.title}`,
    description: post.excerpt,
    alternates: {
      canonical: `https://${process.env.CANONICAL_DOMAIN}/blog/${slug}`,
    },
  };
}

Generate a robots.txt file

Much like sitemap.ts, create a file at src/app/robots.ts

src/app/robots.tstypescript
import { MetadataRoute } from 'next'

// Required for static export
export const dynamic = 'force-static'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
    },
    sitemap: `https://${process.env.CANONICAL_DOMAIN}/sitemap.xml`,
  }
}