CanonicalTag.com logoCanonicalTag.comThe canonical tag & URL canonicalization
FrameworkJavaScript Frameworks

Canonical Tags in React

A canonical tag added only in the browser is a canonical tag many crawlers will never see.

Adding canonical tags in a React app

A React application — whether built with Create React App, Vite, or hand-rolled — outputs no rel=canonical tag by default, for the same reason a bare Next.js project doesn't: nothing in React's rendering model concerns itself with document head metadata unless you tell it to. Inspect the HTML of an unmodified React single-page app and there's no canonical link element anywhere, because React only knows how to render into a div — the head isn't part of the component tree it manages.

Adding a canonical tag to a React app therefore always means reaching for something else: manipulating document.head directly, or — far more commonly and far more maintainably — a head-management library.

This is a meaningfully different starting point from a framework like Next.js, which at least ships an explicit, documented API for the job. In a plain React app, the canonical tag is entirely the responsibility of whatever architecture the team has chosen — and that choice matters more than it might seem, because it determines whether the tag ends up in the server response or only in the browser after the fact.

Using react-helmet-async

The standard tool for managing document head tags in a React app today is react-helmet-async. It's the maintained successor to the original react-helmet, which had gone stale and had known problems under server-side rendering — react-helmet-async was built specifically to be safe there, where multiple requests can be rendering concurrently on the server and a shared, non-thread-safe store, as the original react-helmet used, can leak head tags from one request's response into another's.

Usage wraps the app in a provider, then declares the tag inside any component:

import { Helmet, HelmetProvider } from 'react-helmet-async'

function App() {
  return (
    <HelmetProvider>
      <Helmet>
        <link rel="canonical" href="https://www.example.com/" />
      </Helmet>
      {/* rest of the app */}
    </HelmetProvider>
  )
}

Helmet collects the head tags declared by whichever component is currently rendered and merges them into the actual document head. Used this way — client-side only — it works, but it comes with the limitation covered next.

Why client-only canonicals are risky (SSR vs CSR)

A canonical tag should be present in the server-rendered initial HTML — the HTML a crawler receives before any JavaScript runs — not added after the fact once the browser executes the page's scripts. That distinction is the difference between server-side rendering (SSR), where the HTML response already contains the tag, and client-side rendering (CSR), where an empty or near-empty HTML shell is sent and JavaScript builds the page, including the head, after load.

In a pure client-side-rendered single-page app, if react-helmet-async only ever runs in the browser, the canonical tag simply isn't there in the initial response. Google's own JavaScript SEO documentation acknowledges this is possible — "While we don't recommend using JavaScript for this, it is possible to inject a rel=canonical link tag with JavaScript" — but treats it as the exception, not the recommended path, and requires that a JS-injected value match the HTML value exactly if both exist, with only one canonical ever active per page. Google can render JavaScript, eventually and on a delay; plenty of other crawlers and tools that consume your pages do not run JavaScript at all, and never see a canonical that only exists after client-side rendering.

That's the core reason a pure CSR SPA relying solely on browser-side Helmet is the risky configuration: the canonical exists for browsers and for crawlers patient and capable enough to execute your JavaScript, and effectively doesn't exist for everything else.

It's also worth being precise about what "match exactly" means in Google's guidance: if a page ships one canonical value in the server-rendered HTML and a script then overwrites it with a different value once the browser runs, that's not a refinement — it's a contradiction, and Google's stated position is that only one canonical should be treated as active per page. The safest pattern is to never need the JavaScript-injected fallback at all, which is why server-rendering the tag, covered next, is the approach worth building toward rather than treating client-side Helmet as good enough.

Server-rendering the canonical

The fix is to render the canonical on the server, as part of the initial HTML response, rather than leaving it to the browser. With react-helmet-async, this means using its server-side API: pass a context object into HelmetProvider, render the app to a string with renderToString, then pull the collected head tags — including the canonical — out of that context and inject them into the HTML template sent back, per request.

const helmetContext = {}

const html = renderToString(
  <HelmetProvider context={helmetContext}>
    <App />
  </HelmetProvider>
)

const { helmet } = helmetContext
// helmet.link.toString() now contains the rendered
// <link rel="canonical" ... /> tag to inject into the
// server-rendered page template, before the response is sent

Because context is created fresh for each request, this avoids the cross-request leakage problem the original react-helmet had, and — critically — it means the canonical tag is baked into the HTML the server sends, present before any client JavaScript executes.

The same principle applies regardless of which server-rendering setup a React project uses — a custom Express server, a meta-framework, or any other renderToString-based pipeline. The requirement is always the same: the head tags have to be collected and written into the HTML template before that HTML leaves the server, per request, rather than left for the client bundle to fill in after the page has already loaded.

Common React canonical mistakes

  • Client-only injection. Relying on Helmet, or any head-tag library, running purely in the browser with no server-rendering step leaves the canonical absent from the initial HTML.
  • Relative URLs. A canonical should be an absolute URL, including scheme and host — a relative path is ambiguous and inconsistent with how canonicals are meant to be interpreted.
  • Duplicate or conflicting canonicals. If a server-rendered canonical and a client-injected one disagree, or more than one canonical tag ends up in the head, that's a competing signal rather than a clear one; Google's guidance is explicit that a JS-injected canonical must match the HTML canonical if both exist, and that only one canonical should be active per page.
  • Using unmaintained react-helmet in an SSR context. The original react-helmet's shared, non-thread-safe store is a known problem for server-side rendering with concurrent requests; react-helmet-async exists specifically to fix that, and is the maintained choice.

Frequently Asked Questions

Does a React app output a canonical tag without any extra setup?

No. React has no built-in concept of document head metadata — a canonical tag only appears if you add one, typically with a library like react-helmet-async or by manipulating document.head directly.

Why use react-helmet-async instead of react-helmet?

react-helmet-async is the maintained successor built to be safe for server-side rendering. The original react-helmet uses a shared store that isn't thread-safe, which is a real problem when a server renders multiple concurrent requests — head tags from one request can leak into another's response.

Is it enough to add the canonical tag with Helmet on the client side?

Only if the risk is acceptable. A client-only canonical isn't present in the initial HTML a crawler receives before JavaScript runs. Google says it can process a JS-injected canonical but treats it as the non-recommended path, and plenty of other crawlers don't execute JavaScript at all. Server-rendering the canonical is the reliable approach.

How do I server-render a canonical with react-helmet-async?

Pass a context object into HelmetProvider, render the app with renderToString, then read the collected head tags, including the canonical link, out of that context and inject them into the HTML template before sending the response, per request.

What if my server-rendered canonical and a client-side one don't match?

That's a conflicting signal. If both an HTML canonical and a JavaScript-injected canonical exist on the same page, they need to match exactly, with only one canonical considered authoritative — mismatched or duplicate canonicals undermine the whole point of the tag.