← Back to Blog
Architecture

Next.js Static Export — The Complete Production Guide

Everything you need to know about shipping a fully static Next.js site: output modes, constraints, dynamic routes, image optimization, and deployment.

By Youssef Mahmoud
2025-08-01
5 min read
Next.js Static Export — The Complete Production Guide

Why Static Export?

Modern hosting is changing. Platforms like GitHub Pages, Cloudflare Pages, and traditional shared hosting offer fast, affordable static file serving — but they don't run Node.js. If your Next.js site doesn't need a live server for data fetching, going fully static with output: "export" gives you:

  • Zero cold starts — HTML is pre-rendered, served instantly.
  • Maximum CDN cacheability — every route is a static file.
  • Simplified deployment — upload an out/ folder anywhere.
  • Cost efficiency — no server to pay for or maintain.

This is the architecture I chose for my personal portfolio y0ussef.com, and this guide captures everything I learned.


Setting Up Static Export

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",       // ← This is the key setting
  trailingSlash: true,    // Generates /about/index.html instead of /about.html
  images: {
    unoptimized: true,    // Required — Image Optimization API needs a server
  },
};

export default nextConfig;

⚠️ unoptimized: true disables Next.js's built-in image resizing and WebP conversion. You'll need to handle this yourself — I use a sharp-based prebuild script.


The Core Constraints

Understanding what you cannot do in static export is critical:

FeatureStatic ExportWhy
getStaticProps✅ Fully supportedRuns at build time
getStaticPaths✅ Fully supportedPre-generates dynamic routes
getServerSideProps❌ Not supportedRequires live server
API Routes❌ Not supportedRequires live server
Middleware❌ Not supportedEdge runtime needed
Streaming / RSC❌ Not supportedServer-only
OG Image Generation❌ Not supportedAPI route needed

Locale Routing Without the i18n Plugin

Next.js's built-in i18n routing (i18n config key) doesn't work with static export. The workaround I use is path-based locale routing:

pages/
  en/
    index.tsx    →  /en/
    about.tsx    →  /en/about/
    blog/
      index.tsx  →  /en/blog/
      [slug].tsx →  /en/blog/[slug]/
  ar/
    index.tsx    →  /ar/
    about.tsx    →  /ar/about/

Each locale folder is a separate route namespace. Direction and language are set via useEffect in _app.tsx:

// src/pages/_app.tsx
useEffect(() => {
  const isArabic = router.pathname.startsWith("/ar");
  document.documentElement.dir = isArabic ? "rtl" : "ltr";
  document.documentElement.lang = isArabic ? "ar" : "en";
}, [router.pathname]);

Simple, zero-dependency, works perfectly with SSG.


Dynamic Routes with getStaticPaths

For blog posts, portfolio projects, or any dynamic content:

// src/pages/en/blog/[slug].tsx
import { GetStaticProps, GetStaticPaths } from "next";

export const getStaticPaths: GetStaticPaths = () => {
  const slugs = getAllBlogSlugs(); // reads from src/content/blog/
  return {
    paths: slugs.map((slug) => ({ params: { slug } })),
    fallback: false, // 404 for unknown slugs — required for static export
  };
};

export const getStaticProps: GetStaticProps = ({ params }) => {
  const post = getBlogPostBySlug(params!.slug as string, "en");
  if (!post) return { notFound: true };
  return { props: { post } };
};

fallback: false is mandatory for static export. fallback: 'blocking' and fallback: true both require a server.


Image Optimization Without the Server

Since images.unoptimized: true disables Next.js optimization, I wrote a prebuild script using sharp:

// scripts/convert-images.js
const sharp = require("sharp");
const fs = require("fs");
const path = require("path");

const IMAGES_DIR = path.join(__dirname, "..", "public", "images");

async function convertImages() {
  const files = fs.readdirSync(IMAGES_DIR);
  for (const file of files) {
    const ext = path.extname(file).toLowerCase();
    if (![".png", ".jpg", ".jpeg"].includes(ext)) continue;

    const source = path.join(IMAGES_DIR, file);
    const target = path.join(IMAGES_DIR, `${path.basename(file, ext)}.webp`);

    if (fs.existsSync(target)) continue; // Skip if already converted

    await sharp(source).webp({ quality: 85 }).toFile(target);
    console.log(`Converted: ${file}${path.basename(target)}`);
  }
}

convertImages();

Hook it into the build:

{
  "scripts": {
    "convert-images": "node scripts/convert-images.js",
    "build": "npm run convert-images && next build"
  }
}

Generating sitemap.xml at Build Time

Without API routes, the sitemap must be a static file generated during prebuild:

// scripts/generate-sitemap.js
const fs = require("fs");
const path = require("path");

const SITE_URL = "https://y0ussef.com";

const staticRoutes = [
  "/en/", "/ar/",
  "/en/about/", "/ar/about/",
  "/en/services/", "/ar/services/",
  "/en/projects/", "/ar/projects/",
  "/en/case-studies/", "/ar/case-studies/",
  "/en/contact/", "/ar/contact/",
];

function getBlogSlugs() {
  const blogDir = path.join(__dirname, "..", "src", "content", "blog");
  if (!fs.existsSync(blogDir)) return [];
  return fs.readdirSync(blogDir);
}

function generateSitemap() {
  const blogSlugs = getBlogSlugs();
  const blogRoutes = blogSlugs.flatMap((slug) => [
    `/en/blog/${slug}/`,
    `/ar/blog/${slug}/`,
  ]);

  const allRoutes = [...staticRoutes, ...blogRoutes];
  const today = new Date().toISOString().split("T")[0];

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${allRoutes
  .map(
    (route) => `  <url>
    <loc>${SITE_URL}${route}</loc>
    <lastmod>${today}</lastmod>
    <changefreq>weekly</changefreq>
    <priority>${route === "/en/" || route === "/ar/" ? "1.0" : "0.8"}</priority>
  </url>`
  )
  .join("\n")}
</urlset>`;

  fs.writeFileSync(path.join(__dirname, "..", "public", "sitemap.xml"), xml);
  console.log(`Sitemap generated: ${allRoutes.length} URLs`);
}

generateSitemap();

Build Output Example

A typical successful build looks like:

✓ Generating static pages (21/21)
✓ Exporting (21/21)

Route (pages)
├ ○ /en/
├ ○ /en/about/
├ ○ /en/blog/
├ ○ /en/blog/building-alablam-platform/
├ ○ /en/blog/nextjs-static-export-guide/
├ ○ /ar/
└ ... (and all AR mirrors)

Summary

Static export with Next.js Pages Router is a powerful, underrated approach for portfolio sites, marketing pages, and content-heavy sites that don't need live server features. The constraints are real but manageable — once you understand the boundaries, you can build anything.

The key principles I follow:

  1. All data at build timegetStaticProps for everything.
  2. fallback: false on all dynamic routes.
  3. Handle image optimization yourself via a sharp prebuild script.
  4. Generate sitemaps at build time via a Node.js script.
  5. Path-based locale routing instead of the i18n config.

Happy shipping. 🚀

Y

Youssef Mahmoud

Full-Stack Engineer & Project Engineer

Have a project in mind?

Let's discuss your requirements and build something great together.

Book a Strategy Call