← ./articles

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 generator
  • src/app/sitemap.ts — XML sitemap generator
  • src/app/feed.xml/route.ts (or rss.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:

  1. Prepare OG images as JPEG or PNG and place them under public/og/
  2. 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.

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" in next.config.ts?
  • Did you add export const dynamic = "force-static" to robots.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: true and align links to the trailing-slash form?
  • Did you replace next/image with a dynamic src by 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