Back to blog
Tutorial

Building a Static Site with Next.js: A Complete Guide

nextjsreactstatic-site

When people think of Next.js, they usually think of server-side rendering, API routes, and full-stack applications. But Next.js is also one of the best tools available for building static sites. With the App Router and static export, you get the developer experience of React combined with the performance and simplicity of pre-rendered HTML files.

I have used Next.js to build several static sites, including the marketing pages for my own projects. In this guide, I will walk you through the entire process — from project setup to deployment.

Why Next.js for Static Sites?

You might wonder: why not just use a simpler static site generator like Astro or Hugo? Here are the reasons I keep coming back to Next.js:

React ecosystem. If you already know React, you do not have to learn a new templating language. You get access to thousands of React components and libraries.

The App Router. Introduced in Next.js 13 and now stable, the App Router provides file-based routing, nested layouts, and powerful data fetching patterns — all of which work perfectly with static export.

Built-in optimizations. Image optimization, font loading, metadata management, and code splitting come out of the box.

Incremental adoption. You can start with a static site and later add server-rendered pages or API routes without switching frameworks.

TypeScript support. First-class TypeScript integration with zero configuration.

Setting Up the Project

Start by creating a new Next.js project:

npx create-next-app@latest my-static-site --typescript --tailwind --app
cd my-static-site

To enable static export, update your next.config.ts:

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  output: 'export',
  images: {
    unoptimized: true,
  },
};

export default nextConfig;

The output: 'export' setting tells Next.js to generate static HTML files when you run next build. The images.unoptimized: true setting is necessary because the default next/image optimization requires a server — but we will discuss alternatives later.

App Router Basics

The App Router uses a file-system based routing convention inside the app/ directory. Every folder represents a route segment, and special files define the UI for that segment.

app/
├── layout.tsx          # Root layout (wraps all pages)
├── page.tsx            # Home page (/)
├── about/
│   └── page.tsx        # About page (/about)
├── blog/
│   ├── page.tsx        # Blog index (/blog)
│   └── [slug]/
│       └── page.tsx    # Blog post (/blog/my-post)
└── globals.css

The root layout.tsx wraps every page and is where you define your HTML structure, global styles, and shared UI elements like headers and footers:

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: {
    default: 'My Static Site',
    template: '%s | My Static Site',
  },
  description: 'A fast static site built with Next.js',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <header>
          <nav>{/* navigation */}</nav>
        </header>
        <main>{children}</main>
        <footer>{/* footer */}</footer>
      </body>
    </html>
  );
}

Each page.tsx file defines the content for its route:

export default function HomePage() {
  return (
    <div>
      <h1>Welcome to My Site</h1>
      <p>This is a statically generated page.</p>
    </div>
  );
}

Dynamic Routes with generateStaticParams

For dynamic routes like blog posts, you need to tell Next.js which pages to generate at build time. This is where generateStaticParams comes in.

Let's say you have blog posts stored as MDX files. You want to generate a page for each post:

// app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPosts } from '@/lib/posts';

export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = getPostBySlug(slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.date}</time>
      <div>{post.content}</div>
    </article>
  );
}

The generateStaticParams function returns an array of parameter objects. Next.js calls your page component once for each set of parameters and generates a static HTML file.

This works for any kind of content: blog posts, product pages, documentation, portfolio items. As long as you can enumerate all the possible parameter values at build time, Next.js will generate the pages.

The Metadata API

Next.js provides a powerful Metadata API for managing SEO-related tags. You can define metadata at the layout or page level, and it merges automatically.

Static metadata is defined by exporting a metadata object:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = getPostBySlug(slug);

  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      type: 'article',
      publishedTime: post.date,
      images: [{ url: post.ogImage }],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.description,
    },
  };
}

This generates the appropriate <title>, <meta>, and Open Graph tags for each page. You no longer need a separate head management library — it is all built into the framework.

Working with MDX Content

For a blog or documentation site, MDX is an excellent content format. It lets you write Markdown with embedded React components.

Install the MDX dependencies:

npm install @next/mdx @mdx-js/loader @mdx-js/react

For a simpler approach, I often read MDX files directly from the filesystem and parse them with gray-matter for frontmatter and a Markdown renderer for content:

// lib/posts.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

const postsDirectory = path.join(process.cwd(), 'content/blog');

export function getAllPosts() {
  const filenames = fs.readdirSync(postsDirectory);

  return filenames
    .filter((name) => name.endsWith('.mdx'))
    .map((filename) => {
      const slug = filename.replace(/\.mdx$/, '');
      const fullPath = path.join(postsDirectory, filename);
      const fileContents = fs.readFileSync(fullPath, 'utf8');
      const { data, content } = matter(fileContents);

      return {
        slug,
        title: data.title,
        date: data.date,
        description: data.description,
        content,
      };
    })
    .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}

This approach gives you full control over how content is loaded and processed. Each blog post is a simple MDX file with YAML frontmatter, and the build process reads them all to generate static pages.

Image Optimization for Static Export

The default next/image component requires a server for on-the-fly optimization. With static export, you have a few options.

Option 1: Pre-optimize images. Use a build script to convert images to WebP/AVIF and generate multiple sizes before the build. Tools like sharp make this straightforward:

// scripts/optimize-images.js
const sharp = require('sharp');
const glob = require('glob');

const images = glob.sync('public/images/**/*.{jpg,png}');

for (const image of images) {
  sharp(image)
    .resize(800)
    .webp({ quality: 80 })
    .toFile(image.replace(/\.(jpg|png)$/, '.webp'));
}

Option 2: Use a third-party image CDN. Services like Cloudinary or imgix can optimize images on the fly via URL parameters. You can configure a custom loader for next/image:

// next.config.ts
const nextConfig: NextConfig = {
  output: 'export',
  images: {
    loader: 'custom',
    loaderFile: './lib/image-loader.ts',
  },
};
// lib/image-loader.ts
export default function cloudinaryLoader({
  src,
  width,
  quality,
}: {
  src: string;
  width: number;
  quality?: number;
}) {
  const params = [`w_${width}`, `q_${quality || 'auto'}`];
  return `https://res.cloudinary.com/your-cloud/image/upload/${params.join(',')}${src}`;
}

Option 3: Use unoptimized images. For small sites with few images, setting images.unoptimized: true and manually optimizing your images is the simplest path. Just make sure to use WebP format and appropriate sizes.

SEO Best Practices with Next.js

Static sites are inherently SEO-friendly because search engines receive pre-rendered HTML. But there are additional steps to maximize your SEO:

Generate a sitemap. Create a sitemap.ts file in the app/ directory:

// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';

export default function sitemap(): MetadataRoute.Sitemap {
  const posts = getAllPosts();
  const blogUrls = posts.map((post) => ({
    url: `https://yoursite.com/blog/${post.slug}`,
    lastModified: new Date(post.date),
    changeFrequency: 'monthly' as const,
    priority: 0.7,
  }));

  return [
    {
      url: 'https://yoursite.com',
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 1,
    },
    ...blogUrls,
  ];
}

Add a robots.txt. Similarly, create app/robots.ts:

// app/robots.ts
import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
    },
    sitemap: 'https://yoursite.com/sitemap.xml',
  };
}

Use semantic HTML. The HTML elements you choose matter for SEO. Use <article> for blog posts, <nav> for navigation, <main> for primary content, and proper heading hierarchy (one <h1> per page, followed by <h2>, <h3>, and so on).

Setting Up Internationalization

If you want your static site to support multiple languages, the App Router makes this clean with route groups:

app/
├── [locale]/
│   ├── layout.tsx
│   ├── page.tsx
│   └── blog/
│       └── page.tsx

Define your supported locales and generate static params for each:

// app/[locale]/layout.tsx
export async function generateStaticParams() {
  return [{ locale: 'en' }, { locale: 'ko' }];
}

export default function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: { locale: string };
}) {
  return (
    <html lang={params.locale}>
      <body>{children}</body>
    </html>
  );
}

For translations, I use a simple dictionary approach — a JSON or TypeScript file for each language:

// lib/dictionaries.ts
const dictionaries = {
  en: () => import('./dictionaries/en.json').then((m) => m.default),
  ko: () => import('./dictionaries/ko.json').then((m) => m.default),
};

export const getDictionary = async (locale: string) =>
  dictionaries[locale as keyof typeof dictionaries]();

This keeps translations in sync and makes it easy to add new languages. Each locale gets its own set of static HTML files.

Deployment to Vercel

Deploying a Next.js static site to Vercel is the most straightforward option. Push your code to GitHub, connect the repository to Vercel, and it handles the rest.

For static export specifically, the build output goes to the out/ directory. Vercel detects this automatically when output: 'export' is set.

But since this is a static site, you are not locked into Vercel. The out/ directory contains plain HTML, CSS, and JavaScript files that can be hosted anywhere:

# Build the static site
npm run build

# The output is in the 'out' directory
# Upload it to any static hosting service

You can deploy to GitHub Pages, Netlify, Cloudflare Pages, or even an S3 bucket with CloudFront. The lack of server dependency is one of the biggest advantages of static export.

For GitHub Pages deployment, add a workflow file:

# .github/workflows/deploy.yml
name: Deploy to GitHub Pages
on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build
      - uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./out

Performance Results

A well-built Next.js static site consistently scores 95 or above on Lighthouse across all categories. The combination of pre-rendered HTML, automatic code splitting, and built-in optimizations produces sites that load fast on any device and network.

For my own projects, static export reduced the Time to First Byte (TTFB) to under 50ms on a CDN, and the total page weight for a typical blog post stayed under 100KB. That is the kind of performance that keeps users happy and search engines satisfied.

When Not to Use Static Export

Static export is not right for every Next.js project. If you need server-side rendering on every request, API routes, middleware, or dynamic features that depend on request-time data (like authentication), you should use the default Next.js deployment mode instead.

But for blogs, documentation sites, marketing pages, portfolios, and any content-driven site where pages do not change between deployments — static export gives you the best of both worlds: React's developer experience with the speed and simplicity of static HTML.

Start with static export. You can always add server features later if you need them. Next.js makes that transition seamless.

Building a Static Site with Next.js: A Complete Guide