MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Next.js Static Exports: Fully Static Sites

Export Next.js apps as static HTML: configuration, limitations, and deployment.

Next.jsStatic ExportSSGFrontend

By MinhVo

Introduction

Next.js Static Exports allow you to deploy your Next.js application as a collection of static HTML, CSS, and JavaScript files — no Node.js server required. This rendering strategy is ideal for sites where all content can be determined at build time: documentation sites, marketing pages, portfolios, blogs, and landing pages. By running next export, Next.js pre-renders every page to static HTML and generates a fully self-contained output directory that can be served from any static hosting provider.

The static export approach offers significant advantages in simplicity, cost, and performance. There's no server to manage, no scaling concerns, and pages load instantly from CDN edge locations. With the rise of platforms like GitHub Pages, Cloudflare Pages, Netlify, and Amazon S3, static exports have become one of the most cost-effective ways to deploy web applications. This guide covers configuration, limitations, dynamic route handling, and deployment strategies for Next.js static exports.

Static Site Architecture

Understanding Static Exports: Core Concepts

How Static Export Works

When you configure Next.js for static export, the build process generates a complete static site in the out directory. Every page is pre-rendered to HTML at build time, and client-side JavaScript hydrates the HTML to enable interactivity. The key distinction from standard SSG is that the entire application runs without a server — there are no API routes, no server-side rendering, and no middleware.

The build process follows these steps:

  1. Page Discovery: Next.js discovers all pages in the app or pages directory
  2. Data Fetching: getStaticProps (Pages Router) or server components (App Router) fetch data at build time
  3. HTML Generation: Each page is rendered to static HTML
  4. Asset Optimization: JavaScript bundles, CSS, and images are optimized
  5. Output Generation: All files are written to the out directory

The output: 'export' Configuration

In Next.js 13.3 and later, static export is configured through the next.config.js file using the output: 'export' option. This replaces the older next export command.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
  // Optional: customize the output directory
  distDir: 'out',
  // Optional: add trailing slashes to URLs
  trailingSlash: true,
  // Optional: disable image optimization (required for static export)
  images: {
    unoptimized: true,
  },
};
 
module.exports = nextConfig;

The output: 'export' configuration tells Next.js to produce a fully static build during next build. The trailingSlash option is recommended for static exports because it generates cleaner URLs (e.g., /about/index.html instead of /about.html) and works better with static hosting providers.

Image Optimization Limitations

Next.js's built-in image optimization (next/image) requires a server to process images on-demand. For static exports, you must either disable image optimization or use a third-party image optimization service.

// next.config.js
const nextConfig = {
  output: 'export',
  images: {
    unoptimized: true,
    // Or use a custom loader for external services
    loader: 'custom',
    loaderFile: './lib/image-loader.ts',
  },
};
 
module.exports = nextConfig;
// lib/image-loader.ts
export default function imageLoader({
  src,
  width,
  quality,
}: {
  src: string;
  width: number;
  quality?: number;
}) {
  // Use Cloudinary for image optimization
  return `https://res.cloudinary.com/your-cloud/image/fetch/w_${width},q_${quality || 75}/${src}`;
}

Static Export Pipeline

Architecture and Design Patterns

Dynamic Routes with generateStaticParams

Dynamic routes are fully supported in static exports, but you must tell Next.js at build time which paths to generate. This is done through generateStaticParams (App Router) or getStaticPaths (Pages Router).

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  
  return posts.map((post: { slug: string }) => ({
    slug: post.slug,
  }));
}
 
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json());
  
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

For the Pages Router equivalent:

// pages/blog/[slug].tsx
export async function getStaticPaths() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  
  return {
    paths: posts.map((post: { slug: string }) => ({
      params: { slug: post.slug },
    })),
    fallback: false, // false for static export — all paths must be pre-rendered
  };
}
 
export async function getStaticProps({ params }: { params: { slug: string } }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json());
  
  return {
    props: { post },
  };
}

Nested Dynamic Routes

For routes with multiple dynamic segments, generateStaticParams returns an array of objects with all combinations:

// app/docs/[version]/[slug]/page.tsx
export async function generateStaticParams() {
  const versions = ['v1', 'v2', 'v3'];
  const docs = await fetch('https://api.example.com/docs').then(r => r.json());
  
  // Generate all version + doc combinations
  return versions.flatMap(version =>
    docs.map((doc: { slug: string }) => ({
      version,
      slug: doc.slug,
    }))
  );
}

Catch-All Routes

Catch-all routes ([...slug]) require you to enumerate all possible path segments:

// app/docs/[...slug]/page.tsx
export async function generateStaticParams() {
  return [
    { slug: ['getting-started'] },
    { slug: ['getting-started', 'installation'] },
    { slug: ['api', 'reference'] },
    { slug: ['api', 'reference', 'auth'] },
    { slug: ['guides', 'deployment'] },
  ];
}
 
export default function DocPage({ params }: { params: { slug: string[] } }) {
  const path = params.slug.join('/');
  // Fetch and render documentation for this path
}

Step-by-Step Implementation

Step 1: Configure for Static Export

Start by updating your next.config.js:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
  trailingSlash: true,
  images: {
    unoptimized: true,
  },
};
 
module.exports = nextConfig;

Step 2: Replace Server-Side Features

Static exports cannot use server-side features. Review your codebase and replace:

  • API Routes: Move to external serverless functions or a separate API
  • Server-Side Rendering: Convert to static generation with generateStaticParams
  • Middleware: Implement client-side alternatives using React context or route guards
  • getServerSideProps: Convert to getStaticProps with generateStaticParams
// Before: Server-side rendering
export async function getServerSideProps(context) {
  const session = await getSession(context.req);
  if (!session) return { redirect: { destination: '/login' } };
  
  const data = await fetchDashboardData(session.userId);
  return { props: { data } };
}
 
// After: Static export with client-side auth
export default function Dashboard() {
  const { user, loading } = useAuth();
  const [data, setData] = useState(null);
  
  useEffect(() => {
    if (!loading && !user) {
      router.push('/login');
    }
    if (user) {
      fetchDashboardData(user.id).then(setData);
    }
  }, [user, loading]);
  
  if (loading) return <Spinner />;
  if (!user) return null;
  
  return <DashboardContent data={data} />;
}

Step 3: Handle Client-Side Data Fetching

For pages that need dynamic data, fetch it on the client side:

'use client';
 
import { useState, useEffect } from 'react';
 
export default function ProductPage({ params }: { params: { id: string } }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch(`/api/products/${params.id}`)
      .then(res => res.json())
      .then(data => {
        setProduct(data);
        setLoading(false);
      });
  }, [params.id]);
  
  if (loading) return <ProductSkeleton />;
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <span>${product.price}</span>
    </div>
  );
}

Step 4: Build and Test the Static Export

# Build the static export
next build
 
# The output is in the 'out' directory
ls out/
 
# Serve locally for testing
npx serve out
 
# Or use a simple HTTP server
python3 -m http.server 3000 -d out

Step 5: Deploy to Static Hosting

Deploy the out directory to your static hosting provider:

# GitHub Pages deployment (.github/workflows/deploy.yml)
name: Deploy to GitHub Pages
on:
  push:
    branches: [main]
 
jobs:
  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

Deployment Workflow

Real-World Use Cases

Use Case 1: Documentation Sites

Technical documentation is the most common use case for static exports. Content is fetched from a CMS or markdown files at build time, and the resulting static site is deployed to a CDN. This approach powers documentation for many popular open-source projects.

The build process fetches all documentation pages, generates static HTML for each, and creates an index for search functionality. Client-side search (using tools like Fuse.js or Algolia DocSearch) enables fast, offline-capable search without a server.

Use Case 2: Marketing and Landing Pages

Marketing teams need fast-loading pages that convert well. Static exports deliver sub-second page loads, which directly improve conversion rates. The pages can still be interactive — forms, animations, and dynamic content work through client-side JavaScript — but the initial HTML renders instantly from the CDN.

Use Case 3: Portfolio and Personal Sites

Developers, designers, and photographers use static exports for personal sites because they're free to host on GitHub Pages or Cloudflare Pages, load instantly, and require zero maintenance. The build process pulls content from markdown files, and the site is deployed automatically on every git push.

Best Practices for Production

  1. Use trailingSlash: true: This generates cleaner URLs and ensures compatibility with all static hosting providers. Most providers expect about/index.html rather than about.html.

  2. Disable Image Optimization: Set images: { unoptimized: true } or use a custom loader for external image services like Cloudinary or Imgix.

  3. Pre-render All Dynamic Paths: For static export, fallback: false is required in getStaticPaths. All possible paths must be enumerated at build time.

  4. Implement Client-Side Redirects: Since there's no server to handle redirects, implement them in your React layout using useRouter or redirect().

  5. Use Environment Variables at Build Time: Static exports bake environment variables into the JavaScript bundle. Don't include secrets — only public configuration.

  6. Optimize Bundle Size: Without server-side rendering, the entire application runs on the client. Use code splitting, tree shaking, and dynamic imports to keep bundle sizes manageable.

  7. Implement Offline Support: Add a service worker for offline caching. Static sites are ideal candidates for Progressive Web App (PWA) features.

  8. Test with npx serve out: Before deploying, test the static export locally using a simple HTTP server to catch any path or routing issues.

Common Pitfalls and Solutions

PitfallImpactSolution
Using getServerSidePropsBuild failsConvert to getStaticProps or move data fetching to the client
Not enumerating dynamic paths404 errors for unbuilt pagesUse generateStaticParams to list all paths
Using API routesAPI routes not available in static exportMove to external serverless functions
Using next/image without configBuild failsSet images: { unoptimized: true }
Using middlewareMiddleware not supportedImplement client-side alternatives
Large number of pagesSlow build timesConsider ISR or on-demand generation instead
Environment variables with secretsSecrets exposed in client bundleOnly use public env vars (NEXT_PUBLIC_*)

Performance Optimization

Static Export with Code Splitting

Next.js automatically code-splits your application, but you can further optimize with dynamic imports:

import dynamic from 'next/dynamic';
 
// Lazy-load heavy components
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // No need for SSR in static export
});
 
export default function AnalyticsPage() {
  return (
    <div>
      <h1>Analytics</h1>
      <HeavyChart />
    </div>
  );
}

Implementing a Service Worker

Add offline support and instant page loads with a service worker:

// public/sw.js
const CACHE_NAME = 'static-site-v1';
const PRECACHE_URLS = ['/', '/about', '/blog', '/contact'];
 
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
  );
});
 
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request).then((response) => {
        const clone = response.clone();
        caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
        return response;
      });
    })
  );
});

Comparison with Alternatives

FeatureStatic ExportStandard SSGISRSSR
Server RequiredNoYesYesYes
Content FreshnessBuild-timeBuild-timeConfigurableReal-time
Dynamic RoutesPre-enumeratedPre-enumeratedOn-demandOn-demand
API RoutesNoYesYesYes
MiddlewareNoYesYesYes
Hosting CostFree/LowServer costServer costServer cost
Best ForDocs, portfoliosContent sitesE-commercePersonalized apps

Advanced Patterns

Implement client-side search using a pre-built search index:

// lib/search.ts
import Fuse from 'fuse.js';
 
let fuse: Fuse<any> | null = null;
 
export async function initializeSearch() {
  const response = await fetch('/search-index.json');
  const posts = await response.json();
  
  fuse = new Fuse(posts, {
    keys: ['title', 'description', 'tags'],
    threshold: 0.3,
  });
}
 
export function search(query: string) {
  if (!fuse) return [];
  return fuse.search(query).map(result => result.item);
}

Incremental Migration from SSR to Static

For large applications, migrate to static export incrementally:

  1. Start with pages that have no dynamic data
  2. Move pages with dynamic data to client-side fetching
  3. Extract API routes to external serverless functions
  4. Replace middleware with client-side alternatives
  5. Finally, set output: 'export' when all pages are compatible

Testing Strategies

// static-export.test.ts
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
 
describe('Static Export', () => {
  beforeAll(() => {
    execSync('next build', { cwd: process.cwd() });
  });
 
  it('generates the out directory', () => {
    expect(fs.existsSync(path.join(process.cwd(), 'out'))).toBe(true);
  });
 
  it('generates HTML for all pages', () => {
    const pages = ['index.html', 'about/index.html', 'blog/index.html'];
    pages.forEach(page => {
      const filePath = path.join(process.cwd(), 'out', page);
      expect(fs.existsSync(filePath)).toBe(true);
    });
  });
 
  it('includes static assets', () => {
    const outDir = path.join(process.cwd(), 'out');
    const files = fs.readdirSync(outDir, { recursive: true });
    const hasCSS = files.some(f => String(f).endsWith('.css'));
    const hasJS = files.some(f => String(f).endsWith('.js'));
    
    expect(hasCSS).toBe(true);
    expect(hasJS).toBe(true);
  });
});

Performance Benchmarks

Static exports consistently outperform server-rendered pages in terms of Time to First Byte (TTFB). Benchmarks across major hosting providers show static exports achieving TTFB under 50ms from CDN edge locations, compared to 200-500ms for server-rendered pages. Largest Contentful Paint (LCP) improvements of 30-50% are typical when switching from server rendering to static exports, because the browser receives pre-rendered HTML without waiting for server processing. For sites with thousands of pages, incremental static regeneration through external build triggers can keep build times manageable while maintaining the performance benefits of static delivery.

Future Outlook

The static export feature continues to evolve alongside Next.js. The introduction of Partial Prerendering (PPR) blurs the line between static and dynamic rendering, allowing pages to have static shells with dynamic sections that stream in at request time. While PPR requires a server, it suggests a future where static exports might support hybrid patterns — static HTML with dynamic "islands" powered by edge functions.

The growth of edge computing platforms also makes static exports more powerful. By combining static HTML with edge functions for dynamic features, you can get the performance of static sites with the flexibility of server-side rendering — all without managing a traditional server.

Conclusion

Next.js Static Exports provide a powerful deployment option for sites where all content can be determined at build time. By generating a fully static output directory, you eliminate server costs, simplify deployment, and deliver instant page loads from CDN edge locations.

Key takeaways:

  1. Configure with output: 'export' in next.config.js for fully static builds
  2. Use generateStaticParams to enumerate all dynamic routes at build time
  3. Disable image optimization or use a custom loader for external image services
  4. Move server-side logic (API routes, middleware) to external services or client-side alternatives
  5. Deploy the out directory to any static hosting provider

Static exports are not a compromise — they're an architectural choice that prioritizes simplicity, performance, and cost efficiency. For documentation sites, marketing pages, portfolios, and blogs, they're often the best choice.

For more details, see the Next.js Static Exports documentation.