Next.js

Next.js Static Export on Cloudflare Pages: trailingSlash, _redirects, and What Breaks

This site is a Next.js app exported to static files and served from Cloudflare Pages. No server means no operational work, which is the whole reason. The catch is that output: 'export' removes a set of features, and code written without that in mind fails later rather than at the point you write it. Here is what I hit.

// next.config.ts const nextConfig: NextConfig = { output: 'export', trailingSlash: true, images: { unoptimized: true }, };

What stops working

Everything is rendered to HTML at build time, so anything that needs a runtime is gone:

  • Middleware (middleware.ts)
  • Route handlers (app/api/*/route.ts)
  • next/image optimization
  • ISR and on-demand revalidation
  • Runtime searchParams

I had written a middleware that redirected based on locale. That got abandoned wholesale. Dynamic routes also fail the build unless they have generateStaticParams.

The thing I wrongly assumed was gone is fs. Reading files inside a server component only ever runs during the build, so it survives static export perfectly well. That is how the article markdown gets loaded here.

const file = path.join(process.cwd(), 'data/articles', `${slug}-${lang}.md`); const content = await fs.readFile(file, 'utf-8');

If you set trailingSlash, make the sitemap agree

With trailingSlash: true, a request for /about gets a 308 to /about/. That part I expected.

The sitemap has to agree, and it is easy to write the paths without the slash:

// every generated URL points at a redirect sitemapEntries.push({ url: `${baseUrl}${route}` });

Every URL in the sitemap then costs a wasted crawl hop. In Search Console these land under "Page with redirect" rather than being indexed. Putting the slash into the path definitions themselves was the reliable fix:

export const ROUTES = { home: '/', about: '/about/', blog: '/blog/', } as const;

External URLs don't belong in there either. Listing apps that live on their own subdomains is tempting, but a sitemap only covers URLs on the same host. Best case the extra entries are ignored, worst case they invalidate the file.

I also stopped stamping lastmod with new Date(). That claims every page changed on every deploy. Do it often enough and the lastmod stops being believed at all, so omitting it on static routes is strictly better than lying.

sitemap.ts and robots.ts need force-static

Without this the build stops:

export const dynamic = 'force-static';

The error message is clear enough that it costs you a minute, but I forget it every single time I add one of these files.

Redirects can't live in next.config

redirects() is a server feature, so static export ignores it. Cloudflare Pages reads a _redirects file instead, and anything in public/ gets copied into out/ as-is:

/support.html /support/ 301

I needed this because the support URL I had registered with App Store Connect was /support.html, which does not exist on a site with trailingSlash: true. Changing the URL on Apple's side is slow, so the site has to absorb it.

opengraph-image.tsx is not usable here

I tried generating the OG card with next/og. The file convention builds fine and the image itself is correct:

$ file out/opengraph-image out/opengraph-image: PNG image data, 1200 x 630, 8-bit/color RGBA

It is a PNG, and the filename has no extension. Cloudflare Pages picks Content-Type from the extension, so it serves this as application/octet-stream and no scraper treats it as an image.

I generated it once, saved the result as public/og.png, referenced it explicitly from the metadata, and deleted the route file. It is not an image that needs rebuilding on every deploy, so nothing was lost.

Deploying

Hand it the out/ directory:

npx wrangler pages deploy out --project-name your-project

Git integration is an option, but I wanted to build locally and inspect the output before it goes up. What I inspect is mostly the canonical tags, for reasons that post explains.