all posts
2026-06-18 · 6 min read

Next.js SEO: the deploy-level changes that move rankings

A client-only React SPA fails SEO loudly — the crawler gets an empty shell. Next.js fails it quietly. It renders on the server by default, so the page looks fine to you and to Google right up until a one-line diff changes how it's rendered, and nobody notices for a quarter.


If you've already got a Next.js app, you've cleared the bar most SPAs trip over: your pages ship real HTML. So the SEO failures that actually cost you rankings aren't "the content isn't there." They're regressions — a single line in a pull request that silently changes a page's rendering mode or where its meta tags live, with no error, no failing test, and no visible difference in the browser. These are the ones worth knowing by sight, because they're invisible in code review unless you're looking for them.

Static by default — and one line flips a whole subtree dynamic

Next.js prerenders pages to static HTML at build time unless something tells it the page depends on the request. The list of things that flip a route from static to dynamic (rendered fresh on every request) is short and easy to trip:

TriggerWhere it sneaks in
cookies() / headers()An auth check or A/B flag read at the top of a layout
searchParams propA page that reads ?ref= or ?page= from the URL
connection() / draftMode()A preview toggle, a "is this a bot" check
unstable_noStore()Copy-pasted in to "fix" a stale-data bug
fetch(url, { cache: 'no-store' })One data call that didn't want caching
export const dynamic = 'force-dynamic'Pasted in to make a build error go away

Each one is legitimate somewhere. The problem is scope. Put any of these in a layout, and it doesn't just affect that layout — it opts every page nested under it out of static rendering. Add cookies() to a root layout to read a session, and your marketing pages, your blog, and your docs all quietly become per-request renders.

app/ layout.tsx — cookies() ← one line page.tsx (home) — static → now dynamic blog/[slug]/page.tsx — static → now dynamic pricing/page.tsx — static → now dynamic fix: read cookies() in a child Server Component, not the shared layout or isolate the dynamic part behind a Suspense boundary (Partial Prerendering)
A request-time API in a shared layout cascades dynamic rendering to every route beneath it. The pages themselves never changed — their rendering mode did.

Why does this matter for rankings? A dynamic page is rendered on demand instead of served as a pre-built file. That's slower at the edge, it burns server cost on traffic that could've been cached, and — as we'll see in a moment — it changes when your meta tags reach the crawler. The build still succeeds. The page still works. You find out months later that your fast static site became a sluggish per-request one because of a session check three layers up.

The guardrail is cheap: keep request-time APIs out of shared layouts. Read cookies() in the specific child Server Component that needs it, or wrap the dynamic part in a <Suspense> boundary so Partial Prerendering keeps the rest of the page static. And in CI, watch the build output — Next.js prints a per-route legend marking each route as static () or dynamic (ƒ). A route that flips from to ƒ in a PR is a rendering regression in plain sight.

'use client' on a page silently deletes its metadata

Next.js's Metadata API — the static metadata export and the generateMetadata function — is the right way to ship per-page titles, descriptions, canonicals, and Open Graph tags into the server-rendered HTML. There's one rule that bites hard: it only works in Server Components.

So when someone needs a useState or an onClick on a page and reaches for the fastest fix — drop 'use client' at the top of page.tsx — the metadata export on that file stops doing anything. No build error. The page renders. But its <title>, description, and canonical are simply gone from the HTML, and every page sharing that pattern inherits the site-wide defaults instead of its own. It's the Next.js version of the oldest SPA bug: meta tags that exist only after JavaScript runs.

The fix is the idiomatic one: keep page.tsx a Server Component that exports metadata, and push the interactive bits into a small 'use client' child component that the page renders. The page stays crawlable; the button still works.

Streaming metadata: a dynamic page misses the first wave

These two failures compound, and this is the part most Next.js SEO guides miss. As of Next.js 15, when a page is dynamically rendered, the framework streams its metadata separately — it sends the page body and injects the resolved generateMetadata tags afterward, rather than blocking on them. For a page that's correctly static, the metadata is baked into the initial HTML response, exactly where you want it.

That distinction lines up precisely with how Google indexes JavaScript: in two waves, where wave one parses the raw HTML on first crawl and wave two — rendering — is queued for hours to weeks later. Meta tags that arrive in the initial static HTML are read in wave one. Meta tags streamed in after the body are not guaranteed to be in that first parse. So a page that silently went dynamic (per the cascade above) doesn't just get slower — it can lose its title and description from the wave-one snapshot that decides how it shows up in results. The two regressions you can't see in the browser stack on top of each other.

The short list to check on every Next.js SEO deploy

When a PR touches routing, layouts, data fetching, or page components, these are the deploy-level regressions worth a 60-second look — none of them fail the build:

  • A route flipped ○ → ƒ in the build output (static → dynamic) without a real per-request reason.
  • A 'use client' added to a page.tsx that exports metadata or generateMetadata.
  • A request-time API (cookies(), headers(), searchParams) added to a shared layout instead of a leaf component.
  • A force-dynamic or unstable_noStore() pasted in to silence a caching error rather than fix it.
  • JSON-LD moved into a client useEffect instead of rendered server-side into the page.
  • A canonical or redirect change in next.config.js or middleware that quietly drops a previously-indexed URL.

Then confirm it in the rankings, not the build log

Here's the trap that makes all of this expensive: the deploy that introduces one of these regressions is green, and the one that fixes it is also green. The build log can't tell you whether a page got faster, kept its title in wave one, or started climbing — only Search Console can, and it reports that on a lag of days to weeks. That gap is exactly why a one-line rendering change ships, gets forgotten, and is never connected to the traffic it cost or earned.

Tying a specific pull request — "added cookies() to the root layout," "moved interactivity into a client child and restored metadata" — to the ranking movement that followed weeks later is what Code Results is built to do: changepoint detection on your Search Console history, matched back to the deploy that moved the number. Ship the fix, then watch the page you fixed actually recover.


Sources: Next.js — generateMetadata, Next.js — Static and Dynamic Rendering, Next.js — app/ Static to Dynamic Error, Next.js — SEO Rendering Strategies, Strapi — The Complete Next.js SEO Guide.

[ From the team building this ]

See which of your PRs actually moved rankings.

Code Results connects your GitHub deploys to Google Search Console with causal attribution — so you stop guessing which code change moved organic search, and start measuring it.

Start for free