Static Export Optimization in Next.js 16
Getting static export right in Next.js 16 requires understanding Turbopack's chunk model and the levers available for bundle optimization.
Next.js static export with Turbopack introduces new chunk splitting behavior and build pipeline characteristics that differ materially from Webpack. This article documents production-proven optimization strategies for reducing bundle size, improving chunk cacheability, and maintaining sub-150kb hero payloads.
Contents
Static export with output: 'export' in Next.js 16 is the correct architecture for content-heavy sites and portfolios. No server to operate, edge-deployable, deterministic build artifacts. The tradeoff: every byte in your bundle is non-negotiable — there is no server-side rendering to bail you out of slow load times.
Turbopack's Chunk Model
Turbopack replaces Webpack's chunk splitting algorithm with a graph-based approach that reasons about module relationships differently. Key behavioral differences:
- Smaller base chunks: Turbopack produces more granular chunk boundaries by default, which improves cache hit rates for partial updates
- Aggressive tree-shaking: Turbopack eliminates unused exports more reliably than Webpack, particularly for ESM-only packages
- Different chunk naming: Turbopack uses content hashes without the predictable naming patterns some cache strategies depend on
Enable Turbopack in next.config.ts:
const nextConfig = {
output: 'export',
experimental: {
turbo: {
// Turbopack-specific rules
rules: {
'*.svg': { loaders: ['@svgr/webpack'], as: '*.js' }
}
}
}
};
The Bundle Analyzer Workflow
Always start with data:
ANALYZE=true npm run build
With @next/bundle-analyzer configured:
// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer';
const withAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
export default withAnalyzer(nextConfig);
The treemap will immediately reveal the largest contributors. In most Next.js apps with 3D and animation libraries, the breakdown looks roughly like:
- Three.js / React Three Fiber: 300-400kb gzipped
- Framer Motion: 50-80kb gzipped
- GSAP: 30-60kb gzipped
- React + React DOM: ~40kb gzipped
Lazy Loading Heavy Dependencies
The most impactful optimization for static export sites:
import { lazy, Suspense } from 'react';
// Never in the initial bundle
const ThreeDScene = lazy(() => import('./ThreeDScene'));
const AnimatedSection = lazy(() => import('./AnimatedSection'));
export function HomePage() {
return (
<>
<HeroSection /> {/* Synchronous — in initial bundle */}
<Suspense fallback={<div className="h-96 animate-pulse bg-muted" />}>
<ThreeDScene />
</Suspense>
</>
);
}
This moves Three.js out of the critical path entirely. The hero renders immediately; the 3D scene loads after.
Splitting GSAP Plugins
GSAP's plugin model is treeshakeable but only if you import selectively:
// ❌ Imports entire GSAP
import gsap from 'gsap';
// ✅ Import only what you use
import { gsap } from 'gsap/dist/gsap';
import { ScrollTrigger } from 'gsap/dist/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
Combined with dynamic import in the component that needs it, GSAP plugins stay out of the initial bundle.
Route-Level Code Splitting
App Router handles this automatically — each route segment is its own chunk. But admin panels and dashboard routes deserve explicit attention:
// app/admin/page.tsx — heavy admin bundle, isolated from public routes
// This chunk is never loaded for public visitors
Verify isolation with the analyzer: the admin chunk should contain all admin-specific code and none of it should appear in the landing page chunks.
Preload Critical Resources
For above-the-fold fonts and critical CSS:
// app/layout.tsx
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossOrigin=""
/>
</head>
With next/font/google, Next.js handles font optimization automatically — fonts are inlined or preloaded based on the route. Self-hosted fonts via next/font/local give you the most control.
Practical Targets
| Resource | Target |
|---|---|
| Hero + nav initial JS | < 150kb gzipped |
| LCP element | < 200ms with preload hint |
| Total page JS | < 500kb gzipped (warn above) |
| Any single chunk | < 100kb gzipped |
Run npm run build and check the output table — Next.js flags first-load JS above ~100kb in yellow and above ~300kb in red per route. Both colors represent work to do.