💻AI Codingintermediate

Next.js 16 Static Export on Cloudflare Pages: Four Gotchas That Bit Me

Async params, generateStaticParams on every dynamic route, plain img for remote URLs, and the metadata merge that wiped my OG cards.

By··9 min read
A blank Open Graph card next to a fixed one, with Next.js 16 static export config

My local next dev server was happy. Every tutorial page rendered, every link resolved, the OG cards looked fine in the preview tab. Then I ran the real next build for the Cloudflare Pages deploy and three things went wrong at once: half my dynamic routes 404'd, TypeScript started complaining that slug was a Promise, and the article OG cards came up blank in a card validator. None of that surfaced in dev.

I rebuilt autokaam.com on Next.js 16 static export in April 2026, about 222 pages of news, tutorials, tool pages, and comparison pages, all output: "export" shipped to Cloudflare Pages under the project name autokaam. The move to the App Router under a pure static export changed four behaviors that do not announce themselves. They break at build time, or worse, they break silently in production where the only symptom is an empty social card three days later. These are the four that cost me real hours, with the exact code that fixed each one.

The stack, pinned

Before you blame your own code, match versions. This is what I host the site on:

[email protected]
[email protected]
[email protected]
node v22.22.1
[email protected]

The whole thing is a static export. My next.config.ts is three lines that matter:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
  images: { unoptimized: true },
};

export default nextConfig;

The deploy is one build plus one wrangler push. In CI the token comes from a secret, never from a file in the repo:

npm run build   # writes ./out  (154M here, 72M of it pre-rendered OG PNGs)
CLOUDFLARE_API_TOKEN="$CF_TOKEN" wrangler pages deploy out \
  --project-name autokaam --branch main

That build wrote 276 static HTML files and 269 OG images into out/, about 154M on disk with around 72M of that being the pre-rendered OG PNGs. The JavaScript payload under out/_next was only about 1.7M. In production, that out/ folder is the entire site. Cloudflare Pages serves files, not a Node server, so anything that did not get pre-rendered at build time does not exist on the edge. That single fact is the root cause of two of the four gotchas below.

Gotcha 1: params is now a Promise you must await

In the older App Router, params was a plain object. You destructured slug and moved on. In Next.js 16 under the App Router, params is a Promise. If you treat it like the old object, TypeScript flags it and your getBySlug lookup quietly receives a pending Promise instead of a string, so nothing matches and the page renders the not-found path.

The fix is small but it has to be everywhere params is read, including inside generateMetadata and generateStaticParams consumers. This is the shape that ships in my tutorials route:

import { notFound } from "next/navigation";

type Props = { params: Promise<{ slug: string }> };

export default async function TutorialPage({ params }: Props) {
  const { slug } = await params;        // await, or slug is a Promise
  const tutorial = getTutorialBySlug(slug);
  if (!tutorial) notFound();
  // ... render
}

Two things bit me here. First, the function has to be async now, even on a page that does no other async work, purely so you can await params. Second, generateMetadata reads params too, so it also has to await before it can build a title or canonical. I missed the metadata one on the first pass and got correct page content with a generic fallback title, which is the kind of bug that does not throw and does not show up until you check the rendered <head>.

Gotcha 2: every dynamic route needs generateStaticParams, and dev lies about it

Under output: "export", there is no server at request time. So every dynamic segment like [slug] has to be enumerated at build time. If you do not export generateStaticParams, the build either errors out telling you the page is missing static params, or it ships and the route is a 404 in production because that path was never written to disk.

export function generateStaticParams() {
  return getAllTutorials().map((t) => ({ slug: t.slug }));
}

The part that wasted my time was the debugging loop, not the fix. Here is the trap: next dev does not 404 these routes the way production does. With output: "export" and no dynamicParams override, next dev serves your custom not-found page for individual slugs even when those exact slugs are valid and 200 in production. I spent a good chunk of an afternoon assuming a slug page was a code bug because it 404'd in dev, when the real build had pre-rendered it fine the whole time.

So I stopped trusting next dev for dynamic routes. I verify against the real static build:

npx next build                                  # writes out/ with every param
python3 -m http.server 8788 --directory out     # serve the real output
# open http://127.0.0.1:8788/tutorials/<slug>/

One more sharp edge in that loop. Use 127.0.0.1, not localhost. On my box localhost resolves to the IPv6 ::1 and the connection gets refused, while the static server is listening on IPv4. curl localhost:8788 returned nothing while the same path on 127.0.0.1 returned 200. Static routes without params, like the index pages and hubs, render fine in next dev. It is specifically the param routes that need the build to tell the truth.

Gotcha 3: use plain img for external URLs, not next/image

next/image and a pure static export do not get along once the source is a remote URL. The optimizer wants a server or a loader at request time, and there is no server. My config already sets images: { unoptimized: true }, which keeps the build from failing, but for genuinely external image URLs the cleanest path I found was to drop next/image entirely and use a plain <img>. The cost is one eslint comment to silence the rule that nudges you toward the component:

{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={tutorial.image} alt={tutorial.imageAlt} loading="lazy" />

I run this exact pattern in every spot that renders a remote image: the news hero, the front-page lead, the news and tutorial article bodies, the listing cards, and the author pages. With remote images and a static export, the <img> tag is the honest move. You give up the automatic optimization, you keep loading="lazy" for the deferral, and you stop fighting a component that was built for a runtime you do not have on Cloudflare Pages.

Gotcha 4: the metadata merge that silently wiped my OG cards

This is the one that did real damage, because it broke in production with zero build error. Next.js merges metadata from the root layout down to the page, but the merge is shallow. Top-level fields inherit if a page leaves them out. Nested objects like openGraph do the opposite: if a page defines its own openGraph, that object replaces the parent's openGraph completely. It is not deep-merged.

I had set my OG images once, in the root layout, and assumed every page would inherit them:

// src/app/layout.tsx
export const metadata: Metadata = {
  // ...
  openGraph: {
    siteName: "AutoKaam",
    type: "website",
    images: [
      { url: "https://autokaam.com/og/home.png", width: 1200, height: 630 },
    ],
  },
};

Then each article page exported its own generateMetadata with an openGraph block for the title, type, and URL, but no images. Because the page-level openGraph replaces the root one wholesale, those pages shipped with no OG image at all. The cards rendered blank in the validators and on social. The build was green every single time.

The fix is to re-specify images in every page that defines openGraph. I route mine through a small seo.ts helper so I never forget, but the principle is plain: if you touch openGraph on a page, you own all of it.

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const t = getTutorialBySlug(slug);
  if (!t) return {};
  const canonical = `https://autokaam.com/tutorials/${t.slug}/`;
  return {
    title: t.title,
    description: t.excerpt,
    alternates: { canonical },
    openGraph: {
      title: t.title,
      description: t.excerpt,
      type: "article",
      url: canonical,
      images: [
        { url: tutorialOgUrl(t.slug), alt: t.title, width: 1200, height: 630 },
      ],
    },
  };
}

The same merge rule has a second face, and it bit me on canonicals. My root layout set alternates: { canonical: "/" }. Every page that did not set its own alternates.canonical inherited that "/", so for a while a couple hundred subpages were all canonicalizing to the homepage, which tells Google they are duplicates. The override behavior that wiped my OG images is the exact same behavior that leaked a homepage canonical onto every page that stayed silent. Absent nested field, you inherit the parent. Present nested field, you replace the parent. Once that clicked, both bugs had the same one-line discipline: set canonical and OG images per page, in generateMetadata, every time.

The four, in one table

Gotcha Symptom The fix that shipped
params is a Promise TS error, lookups miss, generic fallback title const { slug } = await params; in page and generateMetadata
Missing generateStaticParams Route 404s in production, 404s in next dev even when valid Export generateStaticParams; verify on the real build at 127.0.0.1, not next dev
next/image on external URLs Build error or broken src under output: export Plain <img> with the eslint-disable, plus images: { unoptimized: true }
Page openGraph overrides root Blank OG cards in production, no build error Set images in every page's openGraph; same rule for per-page canonical

What I do differently now

None of these threw a red error in dev. Three of the four only show up against the real static build, and one only shows up in production where a crawler or a card validator catches it. So my loop changed. I build to out/, serve out/ on 127.0.0.1, and check the actual rendered <head> and the actual dynamic routes before I run wrangler pages deploy. The next dev server is for layout and styling work. For anything that depends on static pre-rendering or metadata merging, the build is the only source of truth on Cloudflare Pages, and it is worth the extra thirty seconds every time.

If you are moving an existing App Router site to a Next.js 16 static export, grep your codebase for three things first: every params destructure that is not awaited, every [slug] directory without a generateStaticParams, and every openGraph block on a page that does not include images. That single sweep would have saved me the whole weekend.

For the official references, the Next.js docs on static exports, generateStaticParams, and generateMetadata cover the runtime constraints and the metadata merge rules in detail.