Back to Blog
Blog

Moving a 1,700-page site to the Next.js 16 App Router without a sitemap hole

Tony Spiro's avatar

Tony Spiro

September 21, 2026

Hero image

Most "we upgraded to Next.js 16" posts are version bumps with a changelog attached. This is not that. This is what it actually took to move cosmicjs.com, a 1,696-URL marketing and docs site backed by Cosmic, from the Pages Router to the App Router, and the five problems that cost real time.

None of them were in the migration guide. All five are things you will hit if your site is large, CMS-driven, and deployed on Vercel.

The shape of the site

Worth setting the scale, because every problem below is a function of it.

  • 1,696 URLs in the sitemap

  • 686 blog posts and 243 daily Rundown issues, all Objects in Cosmic

  • Comparison pages, solutions pages, templates, marketplace listings, integrations, and partners, also all CMS-driven

  • MDX docs, and a next-seo setup with <NextSeo> calls scattered across a few hundred view components

The migration ran as a coexistence first. The App Router and Pages Router happily share a repo, so we moved routes in batches and kept pages/ around for the stragglers. That part was uneventful. Then we cut over the rest, and the build stopped finishing.

Problem 1: the build hit Vercel's 45-minute cap

The first full App Router production deploy ran for 46 minutes and then died. Not a crash, not a type error. It hit the ceiling.

The cause was generateStaticParams. Under the Pages Router, getStaticPaths with fallback: 'blocking' had been quietly doing the right thing for years. When we ported the route files, every dynamic segment got a generateStaticParams that returned the full slug list, so the build tried to prerender roughly 1,700 pages, each one making its own Cosmic request, inside a 45-minute budget.

The fix is an environment variable and a guard in every path loader:

export const loadPaths: GetStaticPaths = async () => { if (process.env.SKIP_BUILD_STATIC_GENERATION === 'true') { return { paths: [], fallback: 'blocking' }; } // ...fetch every slug from Cosmic };

With SKIP_BUILD_STATIC_GENERATION=true set on Vercel, the build prerenders none of the CMS slug pages. Each one is generated on its first request and then cached, with export const revalidate = 60 on the route keeping it fresh. Build time drops from "does not finish" to a handful of minutes. The first visitor to a cold slug triggers the render and every visitor after that gets a cached page. We measured what that trade actually costs, further down.

This is the right trade for a content site. It is the wrong trade if you have a hard requirement that every page is warm the instant a deploy finishes.

Problem 2: skipping the prerender quietly broke the sitemap

Here is the part that would have been an SEO incident if we had shipped it without checking.

We generate sitemap.xml with next-sitemap in postbuild. next-sitemap works from the Next.js route manifest. It lists what the build produced. So the moment we stopped prerendering the CMS pages, next-sitemap stopped listing them. The sitemap would have gone from 1,696 URLs to 433, on a site where the blog is the top of the funnel.

Nothing would have errored. The deploy would have looked perfect.

The fix is additionalPaths. Rather than relying on the route manifest, we query Cosmic directly at postbuild time and inject every published slug:

const [posts, categories, tags, authors, comparisons, solutions] = await Promise.all([ fetchAllObjects(bucket, 'blog-posts', 'slug,modified_at,metadata.last_updated'), fetchAllObjects(bucket, 'categories', 'slug,id'), // ... ]); for (const post of posts) { const path = isRundownSlug(post.slug) ? `/rundown/${post.slug}` : `/blog/${post.slug}`; extraPaths.add(path); dates[path] = post.metadata?.last_updated || post.modified_at; }

next-sitemap merges additionalPaths by loc, so this is safe whether or not the prerender ran. Turn SKIP_BUILD_STATIC_GENERATION off for a full build and you get no duplicates.

Two details that are easy to miss:

Paginated listing routes disappear too. /blog/page/2 through /blog/page/76 are prerendered pages, not CMS Objects, so they vanish along with everything else. We recompute them from the post count and the page size rather than hardcoding a number.

Turn off autoLastmod. Stamping today's date on all 1,696 URLs on every deploy is a signal crawlers learn to discount. We set autoLastmod: false and pull a real per-post lastmod from the Cosmic Object's modified_at, which means the dates in the sitemap now mean something.

We also added a loud failure. If SKIP_BUILD_STATIC_GENERATION is on and the Cosmic fetch returns nothing, the build logs an error instead of silently producing a short sitemap:

if (process.env.SKIP_BUILD_STATIC_GENERATION === 'true' && extraPaths.size === 0) { console.error( '[sitemap] SKIP_BUILD_STATIC_GENERATION is on but no Cosmic CMS paths were fetched.' ); }

A dry run injected 1,261 paths. The live sitemap now carries 1,263 CMS-driven URLs out of 1,696 total, which is the number that would have quietly disappeared.

What the sitemap would have lost next-sitemap, from the Next route manifest alone 433 URLs 1,263 missing with Cosmic additionalPaths 1,696 URLs 433 1,263 CMS-driven URLs The 1,263, by route family Blog posts 686 Categories 78 Rundown pagination 26 Rundown issues 243 Tags 73 Compare 19 Blog pagination 75 Authors 54 Solutions 9 Counted from the live sitemap on September 21, 2026. next-sitemap builds from the Next route manifest, so the moment SKIP_BUILD_STATIC_GENERATION stops the prerender, only the 433 survive. Nothing errors. The deploy looks clean.

Problem 3: Turbopack compiled forever in production

With the prerender skipped, the build still hung. Different place this time: "Creating an optimized production build" with no output and no error, until the cap.

Turbopack is the default bundler for next build in Next.js 16 and it is genuinely faster in development. On this repo, in Vercel's production build environment, the compile step never finished. Locally it was fine, which is the worst kind of bug.

We did not root-cause it. We shipped:

"build": "yarn generate-search-index && yarn check-types && next build --webpack"

Webpack finished in minutes. next dev still uses Turbopack, so we keep the fast feedback loop and give up nothing except a slightly slower CI build. If your production build hangs at compile with no diagnostic output, try --webpack before you spend a day bisecting your own code.

Problem 4: draftMode() turned every blog post dynamic, then 500'd

Cosmic's live preview needs the page to fetch draft content instead of published content. Under the Pages Router this came through context.preview. The obvious App Router port is draftMode():

// This is the version that broke things export async function pageLoaderArgs(params) { const draft = await draftMode(); const preview = draft.isEnabled || Boolean((await cookies()).get(COSMIC_PREVIEW_COOKIE)); return { params, preview, draftMode: preview /* ... */ }; }

Calling draftMode() or cookies() inside a route opts that route out of static rendering entirely. Every blog post became dynamic, ISR stopped applying, and several slugs started returning 500s in production because the loader was now running in a request context it did not expect.

The fix is to stop asking the canonical route whether it is in preview. Preview gets its own route:

// app/preview/[...path]/page.tsx export const dynamic = 'force-dynamic';

That catch-all resolves blog, rundown, partners, customers, templates, and marketplace slugs to the same loaders and views the canonical routes use, but runs them with preview: true and returns robots: { index: false, follow: false }. The canonical /blog/[slug] route went back to a plain synchronous argument builder with no cookies() call anywhere in its tree, which made it statically renderable again.

Where preview lives, before and after Before: preview inside the canonical route GET /blog/[slug] the canonical, indexable route draftMode() + cookies() a dynamic API, called in the route tree Dynamic on every request ISR off, several slugs returning 500 After: split the route GET /blog/[slug] no dynamic API anywhere in the tree Static, revalidate 60 x-vercel-cache: HIT, 82 to 134 ms preview moves to a second, uncached route GET /preview/[...path] force-dynamic, preview: true, noindex Calling a dynamic API opts the whole route out of static rendering. Give per-request features their own route instead.

The general rule: keep dynamic APIs out of the routes you want cached. If a feature needs per-request state, give it a separate route rather than making your entire content tree dynamic to serve a handful of editor sessions.

Problem 5: next-seo went silent and took 400 page titles with it

next-seo is built on next/head, which does nothing in the App Router. Not an error, not a warning. The component renders null and your <title> quietly becomes the layout default.

We had <NextSeo> calls in a few hundred view components. Deleting them all as step one of the migration would have meant a very large diff with no way to verify anything in between. Instead we aliased the package to a no-op shim:

// src/lib/next-seo-app.tsx function noop(_props?: any) { return null; } export const NextSeo = noop; export const ArticleJsonLd = noop; // ...every other export

Aliased in both next.config.mjs (turbopack.resolveAlias and the webpack resolve.alias) and tsconfig.json paths. Everything compiled, nothing crashed, and every page shipped the default title.

Then we rebuilt metadata properly with generateMetadata. The pattern that scaled across 100-plus route files is a single helper that runs the page's existing data loader, picks the SEO fields out of the result, and falls back safely:

export async function metadataFromLoader(loadPage, ctx, pick = defaultSeoPick) { const params = flattenParams(ctx.params ? await ctx.params : {}); const path = fillPath(ctx.path, params); try { const result = await loadPage(loaderArgs(params, ctx.preview)); if (result?.notFound || result?.redirect) return {}; const picked = pick(result?.props ?? {}, params); return pageMetadata({ title: picked.title || titleFromPath(path), description: picked.description || LAYOUT_DEFAULT_DESCRIPTION, image: picked.image, path: picked.path || path, }); } catch (error) { console.error(`generateMetadata failed for ${path}:`, error); return pageMetadata({ title: titleFromPath(path), description: LAYOUT_DEFAULT_DESCRIPTION, path }); } }

Route files become three lines, with a per-route pick function for the shapes that differ:

export async function generateMetadata({ params }) { return metadataFromLoader(loadPage, { params, path: '/blog/[slug]' }, articleSeoPick); }

The try/catch matters more than it looks. A metadata function that throws takes the whole page down. Falling back to a title derived from the path means a bad CMS field costs you a weak title, not a 500.

JSON-LD needed the same treatment. next-seo's ArticleJsonLd was now a no-op, and a 'use client' component that injects a script tag is not reliably visible to crawlers. We render it from the server component instead:

<JsonLd id="ld-article" data={articleJsonLd(props, `/blog/${slug}`)} />

The thing that actually de-risked this

We wrote a script that crawls every URL in the sitemap and records status code, <title>, meta description, og:title, og:description, og:image, canonical, and JSON-LD block count. Then we ran it three times: against production before the merge, against the Vercel preview, and against production after the deploy.

Diffing those three files is what caught the compare pages falling back to a generic Compare | Cosmic title instead of Contentful vs Cosmic. It is what confirmed the four slugs that had been 500ing were fixed. It is what proved the sitemap still had all 1,696 URLs.

It is about 40 lines of curl and a parser. No Lighthouse run and no amount of clicking around the homepage would have found any of it, because every one of those problems lived on CMS slugs the homepage never links to.

What it actually bought us

Numbers, because "it feels faster" is not a result. These come from the Vercel API across every production deployment of this project.

Production build duration, before and after the cutover Vercel 45-minute cap Pages Router Jul 20 to Sep 18, n=85 median 6.4 min, range 2.2 to 11.7 App Router, before fixes 4 builds, none finished 27.1 to 46.2 min, every one cancelled or errored App Router, after fixes Sep 19 onward, n=6 median 1.9 min, range 1.5 to 3.3 0 10 20 30 40 50 build duration (minutes) Every production deployment, from the Vercel API. Medians rather than means: the Pages Router distribution is bimodal at 2 to 3 and 6 to 8 minutes, which is build cache hits against misses. The after sample is 6 builds over 2 days.

Window

Successful builds

Median

Mean

Range

Pages Router, Jul 20 to Sep 18

85

6.4 min

6.0 min

2.2 to 11.7 min

App Router, Sep 19 onward

6

1.9 min

2.0 min

1.5 to 3.3 min

Roughly a 70% cut in median build time. The window between those two rows is its own data point: four consecutive production builds at 27.1, 41.3, 44.8, and 46.2 minutes, every one of them cancelled or errored.

Read that table carefully before quoting it. The Pages Router distribution is bimodal, clustering at 2 to 3 minutes and again at 6 to 8 minutes, which is almost certainly build cache hits against misses, so the median is the honest figure and the mean is not. The App Router sample is six builds over two days. And most of the gain comes from the ISR trade in Problem 1 rather than from the App Router itself. "Next.js 16 made our builds faster" would be the wrong conclusion to draw.

The sitemap held. 1,696 URLs live, 1,263 of them CMS-driven. That second number is what additionalPaths carries and what would otherwise have disappeared.

Runtime did not change, and we are not going to pretend it did. Vercel keeps old deployments reachable at their unique URLs, so we measured the last Pages Router production deploy against current production on the same four routes. TTFB came back at 78 to 116 ms on the new build and 72 to 260 ms on the old. That is noise. Both serve CDN-cached HTML, so there was never a mechanism for the App Router to make it faster. Anyone promising you a speed win from this migration is selling something.

The ISR trade turned out to be free. The stated risk in Problem 1 was that the first visitor to a cold slug pays for the render. We pulled two rarely-visited Rundown posts and got x-vercel-cache: HIT and STALE at 82 to 134 ms. Stale-while-revalidate serves the cached copy and regenerates behind it, so in practice nobody waits.

What we cannot show yet is the measurement that matters most: Search Console impressions, clicks, and coverage before against after. That needs a few weeks of post-deploy data. If the sitemap work did its job, the correct result there is that nothing happens at all.

What to take from this

If you are moving a large CMS-backed site to the App Router:

  1. Budget your build. Count how many pages generateStaticParams will prerender and multiply by your CMS round-trip. If that number approaches your platform's build cap, skip the prerender and lean on ISR.

  2. Your sitemap comes from the route manifest, not from reality. The moment you stop prerendering, check what next-sitemap actually emitted. Query your CMS for additionalPaths and fail the build loudly if it comes back empty.

  3. If the production build hangs at compile, try --webpack. Keep Turbopack for next dev.

  4. Keep cookies() and draftMode() out of cacheable routes. Preview belongs on its own force-dynamic route.

  5. Assume next-seo is doing nothing. Alias it to a no-op so you can migrate incrementally, then verify with a crawl rather than a spot check.

  6. Diff the crawl, not the vibes. Before, preview, after.

The site is live on Next.js 16 with the App Router, builds in minutes, and did not lose a URL. The content still lives in Cosmic, which is the part that made the sitemap fix possible: when your content model is queryable over an API, "list every URL this site should have" is a question you can answer at build time, independent of whatever your framework decided to prerender.

If you want to see the setup, the Cosmic Next.js docs cover the SDK patterns used here, and live preview is the feature behind the preview route.

Hero image