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.
bash1npx create-next-app@latest my-static-site 2cd 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.
typescript1import type { NextConfig } from "next"; 2 3const nextConfig: NextConfig = { 4 /* config options here */ 5 output: "export", 6 images: { 7 unoptimized: true 8 } 9}; 10 11export 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.
json1{ 2 "scripts": { 3 "dev": "next dev --turbopack", 4 "build": "next build", 5 "start": "npx serve@latest out", 6 }, 7}
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:
txt1src/ 2 app/ 3 layout.tsx # Root layout for all routes 4 page.tsx # Home page (/) 5 about/ 6 page.tsx # About page (/about) 7 blog/ 8 page.tsx # Blog index (/blog) 9 [slug]/ 10 page.tsx # Dynamic blog post page (/blog/my-post) 11 components/ 12 ...
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.
typescript1import type { Metadata, ResolvingMetadata } from 'next' 2 3export async function generateStaticParams() { 4 const posts = await fetch('https://.../posts').then((res) => res.json()) 5 6 return posts.map((post) => ({ 7 slug: post.slug, 8 })) 9} 10 11export async function generateMetadata( 12 { params }: Props, 13 parent: ResolvingMetadata 14): Promise<Metadata> { 15 // read route params 16 const { id } = await params 17 18 //... 19 20 return { 21 title: post.title, 22 description: post.description, 23 alternatives: { 24 canonical: post.canonical, 25 }, 26 robots: { 27 index: true, 28 follow: true, 29 } 30 } 31} 32 33export 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.
yaml1name: Deploy to GitHub Pages 2 3on: 4 push: 5 branches: ["main"] 6 # Allow manual triggers 7 workflow_dispatch: 8 9# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 10permissions: 11 contents: read 12 pages: write 13 id-token: write 14 15# Allow only one concurrent deployment 16concurrency: 17 group: "pages" 18 cancel-in-progress: true 19 20jobs: 21 build: 22 runs-on: ubuntu-latest 23 steps: 24 - name: Checkout 25 uses: actions/checkout@v4 26 27 - name: Setup Node 28 uses: actions/setup-node@v4 29 with: 30 node-version: 22 31 cache: "npm" 32 33 - name: Install dependencies 34 run: npm ci 35 36 - name: Build with Next.js 37 run: npm run build 38 39 - name: Setup Pages 40 uses: actions/configure-pages@v4 41 42 - name: Upload artifact 43 uses: actions/upload-pages-artifact@v3 44 with: 45 path: ./out 46 47 deploy: 48 environment: 49 name: github-pages 50 url: ${{ steps.deployment.outputs.page_url }} 51 runs-on: ubuntu-latest 52 needs: build 53 steps: 54 - name: Deploy to GitHub Pages 55 id: deployment 56 uses: actions/deploy-pages@v4 57
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.
Setup 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.
typescript1import { MetadataRoute } from 'next' 2import { getAllPosts, BlogPost } from '@/lib/blog' 3 4// Required for static export 5export const dynamic = 'force-static' 6 7export default async function sitemap(): Promise<MetadataRoute.Sitemap> { 8 // Base URL for your site 9 const baseUrl = `https://yoursite.com`; 10 11 // Get all blog posts 12 const posts = getAllPosts() 13 14 // Create sitemap entries for blog posts 15 const blogEntries = posts.map((post: BlogPost) => ({ 16 url: `${baseUrl}/blog/${post.slug}`, 17 lastModified: new Date(), 18 changeFrequency: 'weekly' as const, 19 priority: 0.8, 20 })) 21 22 // Define static pages 23 const staticPages = [ 24 { 25 url: baseUrl, 26 lastModified: new Date(), 27 changeFrequency: 'weekly' as const, 28 priority: 1.0, 29 }, 30 { 31 url: `${baseUrl}/about`, 32 lastModified: new Date(), 33 changeFrequency: 'monthly' as const, 34 priority: 0.8, 35 }, 36 { 37 url: `${baseUrl}/blog`, 38 lastModified: new Date(), 39 changeFrequency: 'weekly' as const, 40 priority: 0.9, 41 }, 42 { 43 url: `${baseUrl}/lego`, 44 lastModified: new Date(), 45 changeFrequency: 'monthly' as const, 46 priority: 0.7, 47 }, 48 ] 49 50 // Combine all entries 51 return [...staticPages, ...blogEntries] 52}
Create a 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.
typescript1export default async function sitemap(): Promise<MetadataRoute.Sitemap> { 2 // Base URL for your site 3 const baseUrl = `https://${process.env.CANONICAL_DOMAIN}$`; 4 5 //... 6}
typescript1export async function generateMetadata({ 2 params, 3}: { 4 params: Promise<{ slug: string }>; 5}): Promise<Metadata> { 6 const { slug } = await params; 7 const post = getPostBySlug(slug); 8 9 if (!post) { 10 return { 11 title: "Post Not Found", 12 }; 13 } 14 15 return { 16 title: `${post.title}`, 17 description: post.excerpt, 18 alternates: { 19 canonical: `https://${process.env.CANONICAL_DOMAIN}/blog/${slug}`, 20 }, 21 }; 22}
Generate a robots.txt file
Much like sitemap.ts, create a file at src/app/robots.ts
typescript1import { MetadataRoute } from 'next' 2 3// Required for static export 4export const dynamic = 'force-static' 5 6export default function robots(): MetadataRoute.Robots { 7 return { 8 rules: { 9 userAgent: '*', 10 allow: '/', 11 }, 12 sitemap: `https://${process.env.CANONICAL_DOMAIN}/sitemap.xml`, 13 } 14}