SEO in a React App: Metadata, Crawlability, and the CSR Limitation

Understand what a React app needs to do for search engines and social previews — titles, meta description, canonical URLs, Open Graph, structured data — and understand precisely why a client-rendered React app is at a structural disadvantage compared to SSR/SSG, not just a tooling one.

SEO for a React single-page app is partly the same as SEO for any site (write a good title, a good description, use real headings) and partly a genuinely different problem, because the HTML a search engine or a social-media link preview first sees is not the HTML your components eventually render — it's whatever was in the response before React ever ran.

Why client-side rendering is a real SEO limitation, not just a myth

A plain Vite + React app built with the default client-side rendering (CSR) setup ships an HTML file that's essentially empty: a <div id="root"></div> and a <script> tag. Everything a user sees — the article text, the headings, the images — is generated by JavaScript after that script downloads, parses, and runs React. That's fine for a logged-in dashboard nobody needs to find via search, and it's a real problem for public content pages.

Google's crawler does execute JavaScript and can index CSR content, but it does so on a second, delayed rendering pass (sometimes minutes to days after the initial crawl), it has a JS execution budget it can cut off, and it discards anything that depends on browser APIs it doesn't fully emulate. Other crawlers matter too — Bing's JS rendering support is weaker, and most social-media link-preview bots (Facebook, Slack, Discord, X/Twitter, LinkedIn) do not execute JavaScript at all, meaning an Open Graph tag injected by a React effect after mount will simply not be present when those bots fetch the page.

This is the concrete, structural reason SSR (server-side rendering, e.g. Next.js) or SSG (static site generation, pre-rendering pages to HTML at build time) genuinely beat CSR for public, indexable content: the HTML the crawler or preview bot receives on the very first request already contains the real title, headings, text, and meta tags, with zero dependency on JavaScript executing correctly, quickly, or at all.

None of this means a CSR app is un-indexable or that SEO work there is wasted — it means the ceiling is lower and the margin for error is smaller. Doing every optimization covered below is still worth it in a CSR app; it's just not a substitute for SSR/SSG on pages where organic search traffic actually matters.

The per-page metadata baseline: title, description, canonical

Every distinct page needs its own document.title — not one static title from index.html reused everywhere — because the title is what appears as the clickable blue link in search results and as the browser tab label. In React, this means setting document.title inside an effect (or via a small hook, or a library like react-helmet-async) whenever the route or the content it depends on changes, keyed so it updates on navigation between client-side routes, which don't trigger a real page load.

The meta description (<meta name='description' content='...'>) is the snippet text search engines usually show under the title — it doesn't directly affect ranking, but it directly affects click-through rate, so it should be a specific, accurate one to two sentences per page rather than one generic description copy-pasted everywhere.

A canonical URL (<link rel='canonical' href='...'>) tells search engines which URL is the 'real' one when the same content is reachable through more than one URL — for example a lesson reachable both at /learn/react/hooks and via a query-string-tracked link. Without it, search engines may split ranking signal across duplicate-looking URLs or index the wrong variant.

Social previews: Open Graph and Twitter Card tags

Open Graph tags (og:title, og:description, og:image, og:url, og:type) control how a link looks when pasted into Slack, Discord, LinkedIn, or Facebook — the title, blurb, and preview image of the card that gets generated. Twitter/X reads its own twitter:card, twitter:title, twitter:description, and twitter:image tags, falling back to Open Graph tags if its own aren't present.

The critical constraint from the section above applies directly here: these tags must be present in the initial server response HTML, because the bots that generate these previews fetch the raw HTML once and do not run JavaScript. A CSR app that only sets og:image via a React effect after mount will show a generic or broken preview when shared — this is one of the most visible, easy-to-notice symptoms of the CSR SEO limitation, and it's exactly why pre-rendering (even a lightweight static HTML snapshot per route, short of a full SSR framework) is worth doing for pages people will actually share.

Structured data and semantic markup for crawlability

Structured data — usually a JSON-LD <script type='application/ld+json'> block describing the page using schema.org vocabulary (Article, Course, BreadcrumbList, FAQPage, and so on) — helps search engines understand what a page is about beyond just parsing its text, and can unlock rich results like star ratings, breadcrumbs, or FAQ accordions directly in search listings. Like Open Graph tags, this needs to be present in the served HTML to be reliably picked up.

Semantic HTML matters for crawlability independently of ARIA or accessibility concerns: a real <h1>–<h6> heading hierarchy, <article> and <nav> landmarks, and descriptive <a> link text (not 'click here') all give a crawler stronger signal about a page's structure and importance than an equivalent pile of generically-styled <div>s would. This is one of the rare cases where accessibility work and SEO work are the same work.

Crawlability also depends on plain infrastructure: a correct robots.txt that doesn't accidentally block your content routes, an XML sitemap listing your real indexable URLs, and internal links using real <a href> elements (React Router's <Link> renders one) rather than only onClick-driven navigation that a crawler can't follow.

Making metadata dynamic per route

In a multi-page React app, metadata has to change as the user (or a crawler) navigates — a /learn/react/hooks page and a /learn/python/decorators page need entirely different titles, descriptions, and canonical URLs, generated from that page's own content rather than hardcoded once in index.html.

The common pattern is a small reusable hook or component (or a library like react-helmet-async, which manages this centrally and de-duplicates conflicting tags from nested routes) that a page component calls with its own title/description/image, which then imperatively updates the relevant <head> tags. This solves the tab-title and in-app-navigation experience correctly; it does not solve the CSR/crawler limitation from the first section on its own, because it still only takes effect after JavaScript runs.

A minimal useDocumentHead hook for dynamic per-page metadata

Runs on every render where title/description/canonicalPath change, which covers client-side route navigation (a real page load isn't required). Mutating existing <meta>/<link> tags in place (rather than always appending new ones) avoids leaving stale duplicate tags in <head> after several navigations. This fixes the browser tab title and in-app UX correctly, but — per the CSR limitation above — it does not fix what a non-JS-executing crawler or social bot sees on first fetch.

type HeadConfig = {
  title: string;
  description: string;
  canonicalPath: string;
};

function useDocumentHead({ title, description, canonicalPath }: HeadConfig) {
  useEffect(() => {
    document.title = `${title} | EngiLipi`;

    const descTag = document.querySelector('meta[name="description"]');
    if (descTag) descTag.setAttribute("content", description);

    let canonicalTag = document.querySelector('link[rel="canonical"]');
    if (!canonicalTag) {
      canonicalTag = document.createElement("link");
      canonicalTag.setAttribute("rel", "canonical");
      document.head.appendChild(canonicalTag);
    }
    canonicalTag.setAttribute("href", `https://engilipi.dev${canonicalPath}`);
  }, [title, description, canonicalPath]);
}

Code Examples

Each lesson page sets its own title, description, and canonical URL via the hook, and embeds a JSON-LD Article block describing the lesson. In an SSR/SSG setup this JSON-LD would be present in the initial HTML response; in a plain CSR app it only appears after React mounts and runs the effect. Expected result: The rendered page includes a <script type="application/ld+json"> tag describing the lesson as a schema.org Article, once JavaScript has run.

dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}

Common mistake: dangerouslySetInnerHTML is the correct, safe way to inject a raw JSON-LD script tag here since the content is JSON.stringify'd data you control — it would be dangerous with unescaped user input, but not with a structured object like this.

function ReactHooksLessonPage({ lesson }: { lesson: Lesson }) {
  useDocumentHead({
    title: lesson.title,
    description: lesson.objective,
    canonicalPath: `/learn/react/${lesson.id}`,
  });

  const structuredData = {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: lesson.title,
    description: lesson.objective,
    author: { "@type": "Organization", name: "EngiLipi" },
  };

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
      />
      <h1>{lesson.title}</h1>
      <p>{lesson.intro}</p>
    </article>
  );
}

Important Rules

Every route needs its own document.title, meta description, and canonical URL generated from that page's real content — never one static set reused across every page.

Open Graph and Twitter Card tags must describe the specific page being shared (og:title, og:description, og:image, og:url) so link previews are accurate, not generic.

Remember that crawlers running a delayed JS pass and social-preview bots not running JS at all are the concrete reason a CSR-only app under-performs SSR/SSG for public, indexable content — this is a structural limitation, not a missing meta tag you can patch your way around.

Use real <a>/<Link> elements for internal navigation so crawlers can follow links, and keep robots.txt and your sitemap in sync with your actual indexable routes.

Prefer semantic HTML (real heading hierarchy, <article>, <nav>, descriptive link text) — it's simultaneously an accessibility and an SEO improvement.

Where this matters for EngiLipi

EngiLipi's lesson and course pages (the C, Python, Java, C++ courses and every future React lesson page) are exactly the kind of public, shareable, search-worthy content this lesson is about. As a Vite CSR app today, EngiLipi should still implement per-page titles, descriptions, canonical URLs, Open Graph tags, and structured data for every one of these pages — but it's worth flagging explicitly, as a known limitation to revisit, that moving high-traffic public pages to SSR/SSG later would materially improve both search ranking reliability and how links to them preview when shared.

// index.html, unchanged on every route: <title>EngiLipi</title> <meta name="description" content="Learn engineering." />

A per-route call to a hook like useDocumentHead that sets a specific title/description generated from that page's own lesson/course content.

A single static title and description reused on every route means every page in search results and every browser tab looks identical and uninformative — search engines and users alike lose the ability to tell pages apart.

Assuming a CSR Vite React app has 'the same SEO' as an SSR app because 'Google executes JavaScript now'.

Treating SSR/SSG as materially better for public content pages, and reserving CSR-only rendering for logged-in or non-indexable views.

Google's JS execution is a delayed second pass with a limited budget, Bing's is weaker, and social-preview bots don't execute JavaScript at all — so a CSR app's Open Graph tags, structured data, and even core content can be missed or delayed in ways an SSR/SSG app's static HTML never is.

[multiple-choice] Why do Open Graph tags set via a useEffect after React mounts often fail to show up correctly when a page is shared on Slack or Discord? A. Open Graph tags don't work in React at all B. Those preview bots typically fetch the raw HTML and do not execute JavaScript, so tags added only after JS runs are never seen C. Slack and Discord only support Open Graph tags on .html file extensions D. og: tags must be written in all uppercase to be recognized

B. Those preview bots typically fetch the raw HTML and do not execute JavaScript, so tags added only after JS runs are never seen — Most social link-preview bots fetch a page once and parse the raw HTML without running JavaScript, so metadata that only exists after a client-side effect runs is invisible to them.

[multiple-choice] What does a canonical URL (<link rel='canonical'>) tell a search engine? A. The page's meta description B. Which URL is the authoritative version when the same content is reachable through multiple URLs C. The page's load time D. The site's primary color scheme

B. Which URL is the authoritative version when the same content is reachable through multiple URLs — A canonical tag prevents duplicate-URL variants of the same content from splitting or confusing search ranking signal by declaring one URL as the real one.

[multiple-choice] Why is a plain CSR Vite React app at a structural SEO disadvantage compared to an SSR or SSG setup for public content pages? A. CSR apps can't use meta tags at all B. The initial HTML response is essentially empty, so crawlers and non-JS social bots may miss or delay indexing content that only exists after JavaScript runs C. Vite doesn't support the <head> element D. React components can't render <title> tags

B. The initial HTML response is essentially empty, so crawlers and non-JS social bots may miss or delay indexing content that only exists after JavaScript runs — In CSR, real content and metadata only exist after JavaScript executes client-side; SSR/SSG serve that same content pre-rendered in the initial HTML, which is far more reliable for crawlers and preview bots that don't fully or promptly execute JS.

Extend useDocumentHead to also manage an og:title and og:description meta tag (in addition to the existing plain title/description), reusing the same create-if-missing pattern used for the canonical link.

let ogTitleTag = document.querySelector('meta[property="og:title"]'); if (!ogTitleTag) { ogTitleTag = document.createElement("meta"); ogTitleTag.setAttribute("property", "og:title"); document.head.appendChild(ogTitleTag); } ogTitleTag.setAttribute("content", title);

Meta tags are selected by their property attribute for Open Graph, not name — e.g. document.querySelector('meta[property="og:title"]').