Log inPreview your site
Sampled: ChatGPT · Gemini · monthlyReceipts kept · unsampled ≠ absentYou approve every change — verified live

startups · field guide

Next.js SEO on Vercel and Cloudflare Pages

How to do SEO on a Next.js site deployed to Vercel or Cloudflare Pages with Git as the source of truth: the Metadata API, sitemap.ts and robots.ts, redirects, JSON-LD, rendering strategy, and preview-deployment review.

The repository is the editing surface, not the dashboard

Vercel and Cloudflare Pages deploy the site, but the durable source of truth is the Git repository. SEO changes on a Next.js site should be branches and pull requests with the same tests, review, authorship, and recovery expectations as any other product change — not a field edited directly in a hosting dashboard that leaves no diff, no reviewer, and no revert path.

That distinction matters most for SEO specifically because canonical URLs, redirects, and structured data are exactly the kind of change that's easy to get subtly wrong and expensive to leave broken. A reviewed diff catches a typo in a canonical before it ships; a dashboard edit does not.

The Metadata API and metadataBase

Next.js's Metadata API generates the title, description, Open Graph, and canonical tags from a typed object rather than hand-written head tags, and it is the framework-native primitive for all of it — reaching for a second metadata library on top of it usually means fighting the framework instead of using it. Set `metadataBase` once, at the root layout, so every relative URL used in `openGraph.images` or canonical resolution resolves against the production domain instead of whatever preview URL happened to render it.

Static routes can export a `metadata` object directly; dynamic routes need `generateMetadata`, which runs per-request and is where a canonical actually gets computed from the resolved slug rather than a hardcoded string. A common defect: a dynamic route that defines `metadata` statically instead of `generateMetadata`, which silently ships the same title and canonical for every page under that route.

If `metadataBase` isn't set at all, Next.js infers a default rather than failing the build, which is worse than an outright error: that default can resolve against the current deployment's own URL — a preview's random subdomain, or `localhost` in development — rather than the production domain, so canonical and Open Graph URLs quietly point at a URL that disappears the moment the deployment is deleted. Setting `metadataBase` explicitly removes the ambiguity, and it composes correctly with a relative canonical like `alternates.canonical: './'`, which Next.js resolves against the current route's own path rather than requiring a hardcoded absolute URL on every page.

  • Set metadataBase in the root layout so relative URLs resolve to the production domain, not the deploy URL
  • Use generateMetadata for any route where the canonical, title, or description depends on fetched data
  • Set alternates.canonical explicitly rather than relying on the framework's default, especially for paginated or filtered routes
  • Confirm the rendered head output on the actual deployment, since a build-time metadata bug will not show up in local dev if it depends on production data
  • A minimal shape at the root layout — metadataBase: new URL('https://example.com') paired with alternates: { canonical: './' } on each page — resolves to the right absolute URL per route without hardcoding it
  • Use title.template and title.default at the layout level so every page's title inherits a consistent suffix without a template string repeated in every route

sitemap.ts and robots.ts

Next.js supports generating both files as code — `app/sitemap.ts` and `app/robots.ts` — rather than static files checked into `public/`. That matters for a site with dynamic routes, because the sitemap can be built from the same data source that renders the pages, which keeps it from drifting out of sync the way a hand-maintained XML file does. Cloudflare Pages and Vercel both serve these correctly as long as the route segment config allows the export to run at build or request time as intended.

A minimal `robots.ts` returns a `rules` object with a `userAgent`, what to `allow`, and what to `disallow`, plus a `sitemap` field pointing at the sitemap's absolute URL — for example, `userAgent: '*'` allowing `/` and disallowing an authenticated segment like `/app/`, with `sitemap` set to the production sitemap URL. A minimal `sitemap.ts` returns an array of objects with a `url` and an optional `lastModified`, `changeFrequency`, and `priority` — Next.js serializes that array into valid sitemap XML at `/sitemap.xml` automatically, so nothing needs to hand-write the XML format itself.

Past 50,000 URLs in a single sitemap, split it — either by nesting a `sitemap.ts` under a route segment (`app/products/sitemap.xml`) or by exporting a `generateSitemaps` function that returns an array of sitemap IDs Next.js turns into indexed files at `/sitemap/[id].xml`. And if the site builds with `output: 'export'` for a fully static deployment, both `sitemap.ts` and `robots.ts` need `export const dynamic = 'force-static'`, or the static export can silently omit the generated file — the build succeeds either way, so this is worth confirming by requesting the deployed URL directly rather than trusting a green build.

  • Generate the sitemap from the same data source used to render the pages, not a separate hardcoded list
  • Exclude draft, internal, or noindex routes from the sitemap explicitly rather than assuming they will not be included
  • Keep robots.ts scoped to what actually should be disallowed — most Next.js apps only need to block an authenticated app segment, not entire content directories
  • Verify the deployed sitemap and robots files directly by URL after each deploy; a misconfigured route handler can 404 silently
  • For static export builds (output: 'export'), add export const dynamic = 'force-static' to both files or the generated output can be silently dropped
  • Past 50,000 URLs, split the sitemap using generateSitemaps rather than one oversized file

Redirects

`next.config.js` redirects are checked at build time on Vercel and are the right place for permanent, known redirects — slug changes, retired routes, a domain migration. They live in the repository, so they are reviewable in the same pull request as the page change that made them necessary, and they survive a redeploy without needing to be re-entered anywhere. Middleware-based redirects — renamed `proxy.ts` in Next.js 16, with the exported function renamed from `middleware` to `proxy` and the runtime moved from Edge to Node.js — are the right tool only when the target depends on runtime state a build-time config cannot know, such as a locale or an experiment bucket, and are unnecessary overhead for anything simpler.

A minimal `next.config.js` redirect is an object with `source`, `destination`, and `permanent`: setting `permanent: true` returns a 308 status and tells clients and search engines to cache the redirect forever; `permanent: false` returns a 307 and is treated as temporary. Next.js deliberately uses 307 and 308 rather than the more familiar 301 and 302, because both preserve the original request method — a `POST` stays a `POST` through the redirect — where 301 and 302 have historically been rewritten to `GET` by some clients regardless of the original method.

  • Prefer permanent: true (308) for real, permanent URL changes; reserve permanent: false (307) for redirects that are genuinely temporary, since a 308 tells search engines to cache the redirect indefinitely

JSON-LD

Structured data in Next.js is just a `<script type="application/ld+json">` tag rendered from a server component, typically built from the same typed data that already powers the page's visible content — which is the safest way to guarantee the schema matches what a reader can see, rather than drifting out of sync as a separately maintained object. Validate the rendered output against Google's structured-data guidelines after any component that emits schema is touched, since a refactor can silently drop a required field.

Next.js's current documented recommendation is specific: render the script tag directly in a server component's JSX, with the object serialized through `JSON.stringify` and injected via `dangerouslySetInnerHTML` — not through the Metadata API, which has no field for structured data, and not through `next/script`, which is built for executable JavaScript rather than a static data payload. The one documented safety step that's easy to skip: escape `<` characters in the serialized string (`.replace(/</g, '\u003c')`) before injecting it, since an unescaped `<` inside a data field can otherwise close the script tag early and create an injection path.

Placement follows the same site-wide-versus-page-specific split as everything else in Next.js: Organization or WebSite schema that's identical across the whole site belongs in the root layout, so it renders once and stays consistent everywhere; Article, Product, BreadcrumbList, or FAQPage schema that depends on a specific page's data belongs in that route's own page component, built from the same data that already renders the page's visible content.

  • Render JSON-LD as a script tag with type="application/ld+json" in a server component — the Metadata API has no field for it
  • Escape < characters in the serialized JSON before injecting it with dangerouslySetInnerHTML, per Next.js's own documented example
  • Put site-wide entities (Organization, WebSite) in the root layout; put page-specific entities (Article, Product, FAQPage) in that page's own component

Common defects that pass code review but fail on the rendered page

A handful of Next.js SEO defects share the same signature: the code looks correct, the pull request reviews clean, and the defect only shows up when someone actually inspects the rendered output on a real deployment rather than trusting the diff.

  • metadataBase is missing or wrong: relative URLs in openGraph.images or alternates.canonical resolve against a default Next.js infers, which can be the preview's own deployment URL rather than the production domain — the canonical looks right in code and wrong on the rendered page
  • A dynamic route exports a static metadata object instead of generateMetadata: every page under that route ships the same title and canonical, because nothing recomputes per request
  • JSON-LD is injected client-side only, through a useEffect call or a third-party tag manager: it's present in the browser and absent from the HTML a crawler evaluates before executing JavaScript
  • robots.ts or sitemap.ts silently produces no output under a static export build (output: 'export') unless the route explicitly sets export const dynamic = 'force-static' — the build succeeds, and the file that should exist at /robots.txt simply doesn't
  • A redirect added in next.config.js doesn't apply to client-side navigation (Link, router.push) the same way it applies to a fresh request — a redirect meant to catch every path to an old URL can quietly miss traffic that arrives via client-side routing rather than a full page load

Static generation versus dynamic rendering

Static generation (`generateStaticParams`, static rendering) produces pages at build time that serve instantly from the edge and are trivially crawlable — no rendering delay for a crawler to wait through. Dynamic rendering trades that for per-request freshness, which is correct for something like a personalized dashboard but is usually the wrong default for a public marketing or content page, where a crawler benefits from a fast, deterministic response more than the page benefits from real-time data. Prefer static generation with revalidation (`revalidate`) for content that changes occasionally, and reserve fully dynamic rendering for pages that genuinely need it.

`generateStaticParams` tells Next.js which dynamic-route params to build ahead of time, and `revalidate` on top of that gives static output a refresh interval instead of a one-time build — a product or article page can be static at the edge and still pick up a content change within a defined window, without paying the per-request cost of full dynamic rendering. That combination covers the great majority of marketing and content pages: they don't change fast enough to need a fresh render on every request, and they benefit far more from serving instantly and predictably to a crawler than from real-time freshness.

  • Combine generateStaticParams with a revalidate interval for content that updates occasionally — static speed with a bounded staleness window, instead of full dynamic rendering

Preview-deployment review before merge

Every pull request on Vercel and most Cloudflare Pages setups produces a preview deployment at its own URL, and that preview is the approval surface — not a description of the change in the pull request body. Crawl the preview directly: inspect the rendered metadata, structured data, canonical, links, and status codes, and compare that against the intended diff rather than trusting that the code change did what it was meant to. A canonical that correctly points at the production domain in code can still render wrong on a preview URL if `metadataBase` was not set, which is exactly the kind of defect that only shows up by checking the rendered output.

Both platforms protect previews from search engines by default, which matters for how confidently a preview can be crawled for review without worrying about it leaking into search results. Vercel adds an `X-Robots-Tag: noindex` header to every preview deployment automatically, and Cloudflare Pages does the same for every preview URL it generates — visible directly with `curl -I` against the preview URL, which should show `x-robots-tag: noindex` in the response headers. That default disappears the moment a custom domain is attached to a non-production branch on either platform, since a custom domain reads as an intentional environment rather than a disposable one; a `staging.example.com` branch needs the header added back deliberately (a `headers()` function in `next.config.js`, gated on `process.env.VERCEL_ENV === 'preview'`, is Vercel's documented approach) if it shouldn't be indexed.

  • Confirm a preview URL actually carries X-Robots-Tag: noindex with curl -I, rather than assuming the platform default is in effect
  • If a custom domain is attached to a preview or staging branch, add the noindex header back explicitly — the platform's automatic protection doesn't cover custom domains

Edge rendering and preview defaults: Vercel vs. Cloudflare Pages

Vercel and Cloudflare Pages both serve a Next.js site's static output from a global edge network, and both generate an automatic preview deployment for every pull request — but the SEO-relevant defaults between them aren't identical, and treating the two as interchangeable is where preview-related indexing mistakes usually start.

Static generation behaves the same on both platforms in the ways that matter for SEO: pages built with `generateStaticParams` or plain static rendering serve instantly with no render-blocking wait for a crawler. The practical differences show up around visibility and configuration — Vercel's GitHub integration posts a deployment comment with the preview URL directly on the pull request, and Cloudflare Pages surfaces the same information through a GitHub check. Either is sufficient as the trigger to go inspect the preview; the point is that neither platform requires manually hunting for the URL once the integration is connected.

Vercel vs. Cloudflare Pages: what changes for SEO

Both defaults protect preview URLs from search engines; the exceptions are where mistakes happen.

 VercelCloudflare Pages
Default preview indexingX-Robots-Tag: noindex on every preview, and on the previous production deployment for a window after a promoteX-Robots-Tag: noindex on every preview deployment by default
Custom domain on a preview branchnoindex header is omitted automatically — add it yourself if that branch shouldn't be indexedThe pages.dev preview alias stays protected; a custom domain on the branch can still be gated with Cloudflare Access
PR visibilityVercel's GitHub bot comments the preview URL directly on the pull requestA GitHub check surfaces the unique preview URL on the pull request
Static output servingServed from Vercel's global edge networkServed from Cloudflare's global network

Trailing slash and www consistency

Next.js's `trailingSlash` config and the presence or absence of a `www` redirect both need to be decided once and enforced everywhere, because an inconsistent site — some internal links with a trailing slash, some without, or both `www.example.com` and `example.com` serving 200 without a redirect between them — creates duplicate-content canonicalization work that a search engine has to resolve for you instead of you resolving it once in config.

The redirect that enforces the non-canonical host or trailing-slash choice should be a single permanent hop at the edge — a `next.config.js` redirect or a platform-level domain redirect — rather than a client-side script, since a client-side redirect requires a crawler to render the page before it discovers where the content actually lives.

  • Pick one trailing-slash convention in next.config.js and confirm internal links match it
  • Redirect the non-canonical host (www or bare domain, whichever you are not using) with a single 301, not a client-side redirect
  • Confirm the canonical tag always uses the chosen convention, even on pages reached through the non-canonical form

IndexNow, as an optional accelerant

IndexNow lets a site ping supporting search engines the moment a page publishes or changes, instead of waiting for the next scheduled crawl. It is optional and additive — it does not replace a correct sitemap or robots configuration, and it will not rescue a page with a canonical or rendering defect. Wiring it into the deploy step is worth doing once the rest of this list is solid, because a newly published page or a corrected redirect gets discovered faster; it is not worth troubleshooting before the fundamentals are confirmed. Trigger the ping on publish or update specifically, not on every build — a ping storm from an unrelated deploy adds noise without adding value.

Pair deployment rollback with Git recovery

Rolling traffic back to a previous deployment at the provider level does not change the repository. If the next deploy is triggered from the same broken branch, the defect returns. Open a revert pull request alongside any provider-level rollback, so the fix is recorded in Git history the same way the original change was, and the next deploy does not silently reintroduce the problem.

Note that rolling back is a different operation from re-promoting an older deployment to production. On Vercel specifically, an outdated production deployment picks up its own noindex header once a newer one is promoted ahead of it, so re-promoting it without a matching Git revert can momentarily leave production serving a build the platform itself had flagged as stale.

Common questions

Does a Next.js site need a sitemap.xml file, or can it use sitemap.ts?

sitemap.ts is the framework-native way to do it and is preferable for any site with dynamic routes, because it can be generated from the same data source that renders the pages instead of a separately maintained file that drifts out of sync. A static public/sitemap.xml still works for a small, fully static site, but as soon as routes are generated from data, code-based generation is less error-prone.

Where should canonical URLs be set in Next.js — the page or the layout?

Set metadataBase once at the root layout so relative URLs resolve consistently, then set the actual canonical per-route through generateMetadata (for dynamic routes) or the static metadata export (for static ones), since the canonical value itself is route-specific and cannot correctly live at the layout level for anything other than the site's root.

Should redirects live in next.config.js or in middleware?

Use next.config.js redirects for anything known at build time — retired URLs, slug changes, a domain migration — because they are reviewable in a pull request and checked before deploy. Reserve middleware (renamed proxy.ts in Next.js 16) for redirects that depend on runtime state a build cannot know, such as locale detection, since it runs on every matching request and adds overhead a build-time redirect avoids.

How do I verify SEO changes before they go live on Vercel or Cloudflare Pages?

Use the automatic preview deployment every pull request creates. Crawl that preview URL directly, inspect the rendered metadata, canonical, structured data, and status codes, and compare the result against the diff you are about to merge — a code change that looks correct can still render incorrectly on a preview if metadataBase or an environment variable is misconfigured for that environment.

Is IndexNow worth setting up for a new Next.js site?

It is worth adding once the fundamentals — sitemap, robots, canonicals, rendering — are confirmed correct, because it shortens the delay between publishing and discovery. It is not a substitute for any of those fundamentals and will not help a page that has a canonical or rendering defect; treat it as an optional accelerant on top of a working setup, not a fix for a broken one.

Is middleware still the right place for redirects in Next.js 16?

The file convention was renamed to proxy.ts in Next.js 16 — the exported function is now proxy, and it runs on the Node.js runtime rather than Edge — but the guidance is unchanged: reserve it for redirects that depend on runtime state a build can't know, like locale detection, and keep anything knowable ahead of time in next.config.js redirects instead.

Do Vercel and Cloudflare Pages index preview deployments by default?

No — both platforms add an X-Robots-Tag: noindex header to preview deployments automatically, which is checkable directly with curl -I against the preview URL. The one exception on both platforms is a custom domain attached to a non-production branch, which needs the header added back manually if that branch shouldn't be indexed.

Sources

  1. Next.js Docs — generateMetadata and metadataBase
  2. Next.js Docs — sitemap.xml file convention
  3. Next.js Docs — robots.txt file convention
  4. Next.js Docs — redirects in next.config.js
  5. Next.js Docs — How to implement JSON-LD
  6. Vercel Docs — Environments (Preview and Production)
  7. Vercel — Are Preview Deployments indexed by search engines?
  8. Cloudflare Pages Docs — Preview deployments
  9. Google Search Central — Understand the JavaScript SEO basics
  10. Google Search Central — How to specify a canonical URL with rel="canonical"

Keep reading

Back to all guides