Next.js Static Export Pitfalls — 10 Landmines I Hit
Ten real pitfalls encountered deploying a Next.js 15 App Router app with output: export to S3. Symptoms and fix patterns in checklist form.
Why Static Export?
This blog runs on output: 'export' to minimize CDN load and operational cost. With only static files deployed to S3 + CloudFront, server maintenance is essentially zero and loads fast anywhere.
Good choice overall — but not a smooth path. Many App Router server features conflict with static export, and the workarounds are scattered across docs and forum posts. This post collects 10 landmines I actually stepped on.
1. rewrites() is dev-only
// next.config.ts
async rewrites() {
return [{ source: '/api/exoplanet', destination: 'https://...' }];
}
Symptom: works locally, 404 in production.
Cause: rewrites() runs only at Next.js dev server runtime. It's not included in output: 'export' build artifacts.
Fix: In prod, a CDN/Edge layer (CloudFront behavior, Vercel rewrites, nginx location, etc.) must handle the same path proxying. Infrastructure outside the repo owns that responsibility.
2. dynamic(..., { ssr: false }) is forbidden in Server Components
const Game = dynamic(() => import('./Game'), { ssr: false });
Symptom: Build error — ssr: false is not allowed with next/dynamic in Server Components.
Fix: Use { loading: () => null } as an alternative, or move the dynamic import into a Client Component. In this project, every game page.tsx uses next/dynamic lazy import with a loading fallback instead of ssr: false.
3. sitemap.ts requires export const dynamic = 'force-static'
Symptom: Build-time warning — "sitemap route is dynamic by default" — and the sitemap.xml doesn't get generated.
Fix:
// app/sitemap.ts
export const dynamic = 'force-static';
export default function sitemap() {
/* ... */
}
Without this line, the sitemap is treated as a dynamic route and won't emit properly in static export.
4. Don't use getLocale()
next-intl's getLocale() internally calls headers(). headers() is a runtime-only API — it breaks static export builds.
Fix: Accept params in page.tsx or layout.tsx:
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
// ...
}
5. Use { locale, namespace } form of getTranslations, not 'ns'
Symptom: Same as #4 — build failure.
Fix:
// ❌
const t = await getTranslations('game');
// ✅
const t = await getTranslations({ locale, namespace: 'game' });
Passing locale explicitly avoids the headers() call, keeping it static-export-safe.
6. metadata export only from Server Components
Symptom: Adding export const metadata to a 'use client' component triggers warnings and the metadata is ignored.
Fix: Keep page.tsx as a Server Component and export metadata there. Move interactive UI into a separate container component marked 'use client'. This separation is the canonical pattern.
// app/[locale]/galaga/page.tsx — Server Component
export const metadata = { title: 'Galaga' };
export default function GalagaPage() {
return <GalagaContainer />; // 'use client' container
}
7. Dynamic routes return empty pages without generateStaticParams
Symptom: Routes like /devlog/[slug] all return 404 after deploy.
Fix: Define generateStaticParams in the route's page.tsx to return every possible slug at build time.
export function generateStaticParams() {
return DEVLOG_SLUGS.map(slug => ({ slug }));
}
8. CDN Owns External API CORS
Calling an external API directly from the browser almost always fails with CORS. Static export has no Next.js proxy layer, so one of these is required:
- CloudFront behavior-based proxy (this blog's approach)
- Vercel Edge Functions
- Third-party CORS proxies (not recommended — trust issues)
9. Browser-only code must live inside useEffect
Symptom: Accessing localStorage, window.matchMedia, etc. at component top level triggers ReferenceError during SSR rendering at build time.
Fix:
// ❌
const theme = localStorage.getItem('theme');
// ✅
const [theme, setTheme] = useState<string | null>(null);
useEffect(() => {
setTheme(localStorage.getItem('theme'));
}, []);
One more trap: useMemo(() => localStorage.get(...), []) also evaluates to null during SSR and gets cached as null permanently — it never re-runs after hydration. Always use useEffect.
10. API routes (route.ts) don't work at all
Symptom: Creating app/api/xxx/route.ts does nothing after deployment.
Fix: Static export has no server runtime, so API routes simply don't fire. If you need one, adopt a BaaS like Supabase or Firebase, or deploy a separate Edge Function.
Summary Checklist
Skim this once before starting static export — most of these will bite you otherwise.
-
rewrites()is dev-only → production needs CDN proxy -
dynamic({ ssr: false })allowed only in Client Components -
sitemap.tsneedsforce-staticdeclared - No
getLocale()/getTranslations('ns')— go throughparams -
metadataexport only from Server Components - Dynamic routes require
generateStaticParams - External API CORS needs CDN-level config
- Browser-only APIs go inside
useEffect - Never use
useMemo(() => browserOnly(), []) - API routes don't work — use BaaS / Edge functions
Retrospective
When I first picked static export, I thought "as long as I avoid SSR features, I'm fine". In reality, many App Router APIs require runtime internally, and the landmines were spread across a wide range of features.
On the flip side, once you've stepped on each one and learned the workaround, it becomes a genuinely comfortable deployment environment. Deploys are seconds (S3 sync + CloudFront invalidation), server costs basically zero.
If you're considering Next.js static deployment, walk through the checklist above first. Having it consolidated in one place saves hours of repeated debugging later.
Guestbook
Leave a short note about this post
Loading...