5 Pitfalls of Next.js output: export and How to Avoid Them
When you switch a Next.js app to output: export (static export) to host it on Cloudflare Pages, GitHub Pages, or a plain CDN, you often pass next build fine and then hit a wall a few steps later.
Most of the time the cause is not a bug in your code. It is a static-export-specific assumption you did not account for: there is no Node.js server at serving time, so anything that generates output per request stops working.
This article walks through five pitfalls you are likely to hit with output: export and how to avoid each. CSS errors that only appear in the dev server (next dev) have a different root cause, so I link that as a related article at the end.
Background: what output: export does
output: export builds your Next.js app at build time into a fully static set of HTML, CSS, and JS. You enable it in next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;
The exported artifact (the out/ directory by default) contains no server code that runs at request time. Any feature that assumes a server runtime needs an alternative. That single fact is the common root of every pitfall below.
Pitfall 1: metadata routes require force-static
The first thing people trip on is the robots.ts, sitemap.ts, and RSS feed route handlers. Under static export, these require the following declaration:
// src/app/robots.ts
export const dynamic = "force-static";
export default function robots() {
return {
rules: [{ userAgent: "*", allow: "/" }],
sitemap: "https://example.com/sitemap.xml",
};
}
Without it, the build stops during the "Collecting page data" stage with an error like:
Could not collect static generation routes.
Error: Cannot find module '<project>/.next/routes-manifest.json'
output: export requires every route to be statically generated at build time. Without the force-static marker, the framework treats the route as a dynamic endpoint and fails.
The routes that commonly need it:
src/app/robots.ts— robots.txt generatorsrc/app/sitemap.ts— XML sitemap generatorsrc/app/feed.xml/route.ts(orrss.xml) — RSS feed generator
This requirement is not prominent in the Next.js 15 migration guide. The safe habit is simple: add force-static every time you create a new metadata route.
Pitfall 2: treat RSS and sitemap as build-time static files
Even after force-static gets you past pitfall 1, it helps to change how you think about these routes. Treating RSS or a sitemap as a "dynamic route that generates XML per request" is an anti-pattern.
Static export tools and CDNs cache aggressively. A route written as if it runs per request will end up either:
- never called (the CDN caches the first response forever), or
- stale until you rebuild every time content changes
The correct mental model is that RSS, robots, and sitemap are generated at build time and emitted as static files inside out/. A force-static route handler does exactly that, which gives you "frozen to the latest content on every deploy" behavior.
Pitfall 3: next/og does not work in static export
next/og (formerly @vercel/og), which generates Open Graph images dynamically, does not work with static export. next/og renders images with Satori + Resvg on a Node.js server, and static export has no server runtime.
The fix is to pre-generate images and serve them as static files:
- Prepare OG images as JPEG or PNG and place them under
public/og/ - Reference them by static URL in your metadata
<meta property="og:image" content="https://example.com/og/article.png" />
Generating each dynamic OG image on a server would reintroduce the server you were trying to remove, defeating the point of static export. Even when you want a different image per article, pre-generate them at build time.
Pitfall 4: trailingSlash behavior and its effect on links
On static hosts and CDNs, setting trailingSlash: true makes path resolution more reliable:
// next.config.ts
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
// URLs become /articles/slug/ with a trailing slash
};
With this, each page is written as a directory plus index.html, like out/articles/slug/index.html, which most static hosts resolve as expected.
The side effect is that every link should align to the trailing-slash form. If you use next/link's <Link>, it is handled automatically. If you hand-write raw <a href> tags, append the trailing / explicitly to avoid redirect and cache mismatches.
Pitfall 5: next/image with a dynamic src is unsupported
Using <Image> from next/image with a dynamic src (an image URL decided at runtime) also breaks under static export. next/image performs request-time optimization such as resizing and WebP conversion, but static export requires every image to be resolved at build time.
Replace dynamic or user-supplied images with a standard <img> tag:
// ✓ Correct: use a plain img for dynamic src
export default function Article({ article }: { article: { imageSrc: string } }) {
return <img src={article.imageSrc} alt="" />;
}
// ❌ next/image with a dynamic src is unsupported in static export
import Image from "next/image";
export default function Article({ article }: { article: { imageSrc: string } }) {
return <Image src={article.imageSrc} alt="" />;
}
You can still use next/image when src is resolved at build time via a static import:
import Image from "next/image";
import heroImage from "./hero.png"; // static import
export default function HomePage() {
return <Image src={heroImage} alt="" />;
}
If you must use next/image dynamically, you can set images.unoptimized: true in next.config.ts, but you lose the optimization benefit. A clean rule for static export is "plain <img> for dynamic images, next/image for static ones".
Pre-deploy checklist
Before deploying with output: export, run through this list:
- Did you set
output: "export"innext.config.ts? - Did you add
export const dynamic = "force-static"torobots.ts,sitemap.ts, and RSS routes? - Are you treating RSS and sitemap as request-time generated routes (avoid this)?
- Are you using dynamic OG images via
next/og(replace with pre-generated images)? - Did you set
trailingSlash: trueand align links to the trailing-slash form? - Did you replace
next/imagewith a dynamicsrcby a plain<img>? - Did you actually inspect the contents of
out/after the build?
Summary
Most output: export problems are not CSS or code bugs. They come from missing the assumption that there is no server at serving time.
The missing force-static on metadata routes is the most common first hurdle, because it is not prominent in the Next.js 15 migration guide. The safe approach is mechanical: add force-static every time you create a new metadata route.
There is also a different class of problem where next build passes but next dev fails on CSS. That is caused by a dev-server environment variable, not static export, so see the related article below.
Related article: Why only next dev breaks CSS: it was NODE_ENV=production