How to build and deploy a static site with Next.js and GitHub 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.
$ npx create-next-app@latest my-static-site
$ cd my-static-siteOne 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.
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.
{
"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:
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.
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.
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.
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.
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Base URL for your site
const baseUrl = `https://${process.env.CANONICAL_DOMAIN}$`;
//...
}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
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`,
}
}