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.
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:
- Page Discovery: Next.js discovers all pages in the
apporpagesdirectory - Data Fetching:
getStaticProps(Pages Router) or server components (App Router) fetch data at build time - HTML Generation: Each page is rendered to static HTML
- Asset Optimization: JavaScript bundles, CSS, and images are optimized
- Output Generation: All files are written to the
outdirectory
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}`;
}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 togetStaticPropswithgenerateStaticParams
// 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 outStep 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: ./outReal-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
-
Use
trailingSlash: true: This generates cleaner URLs and ensures compatibility with all static hosting providers. Most providers expectabout/index.htmlrather thanabout.html. -
Disable Image Optimization: Set
images: { unoptimized: true }or use a custom loader for external image services like Cloudinary or Imgix. -
Pre-render All Dynamic Paths: For static export,
fallback: falseis required ingetStaticPaths. All possible paths must be enumerated at build time. -
Implement Client-Side Redirects: Since there's no server to handle redirects, implement them in your React layout using
useRouterorredirect(). -
Use Environment Variables at Build Time: Static exports bake environment variables into the JavaScript bundle. Don't include secrets — only public configuration.
-
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.
-
Implement Offline Support: Add a service worker for offline caching. Static sites are ideal candidates for Progressive Web App (PWA) features.
-
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
| Pitfall | Impact | Solution |
|---|---|---|
Using getServerSideProps | Build fails | Convert to getStaticProps or move data fetching to the client |
| Not enumerating dynamic paths | 404 errors for unbuilt pages | Use generateStaticParams to list all paths |
| Using API routes | API routes not available in static export | Move to external serverless functions |
Using next/image without config | Build fails | Set images: { unoptimized: true } |
| Using middleware | Middleware not supported | Implement client-side alternatives |
| Large number of pages | Slow build times | Consider ISR or on-demand generation instead |
| Environment variables with secrets | Secrets exposed in client bundle | Only 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
| Feature | Static Export | Standard SSG | ISR | SSR |
|---|---|---|---|---|
| Server Required | No | Yes | Yes | Yes |
| Content Freshness | Build-time | Build-time | Configurable | Real-time |
| Dynamic Routes | Pre-enumerated | Pre-enumerated | On-demand | On-demand |
| API Routes | No | Yes | Yes | Yes |
| Middleware | No | Yes | Yes | Yes |
| Hosting Cost | Free/Low | Server cost | Server cost | Server cost |
| Best For | Docs, portfolios | Content sites | E-commerce | Personalized apps |
Advanced Patterns
Static Export with External Search
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:
- Start with pages that have no dynamic data
- Move pages with dynamic data to client-side fetching
- Extract API routes to external serverless functions
- Replace middleware with client-side alternatives
- 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:
- Configure with
output: 'export'innext.config.jsfor fully static builds - Use
generateStaticParamsto enumerate all dynamic routes at build time - Disable image optimization or use a custom loader for external image services
- Move server-side logic (API routes, middleware) to external services or client-side alternatives
- Deploy the
outdirectory 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.