1How React Router 7 SSR SEO works

In React Router 7 SSR, every SEO tag is produced by a route's meta() function. React Router collects all descriptors from active routes and renders them server-side via <Meta /> in root.tsx. No client-side injection — the tags are in the HTML from the first byte.

WhatRoute exportHow
Title, description, canonical, OG, Twitter, robotsmeta() in each routetoRouterMeta(config) — converts any SEOConfig to a descriptor array in one call. Includes the canonical <link> tag too.
JSON-LD structured dataRoute component bodySchema generators + safeJsonLdSerialize inside a <script> tag
!

Do not put canonical in links() when using toRouterMeta(). That adapter already emits { tagName: 'link', rel: 'canonical', href } which <Meta /> renders as a proper <link rel="canonical"> tag. Adding the same URL via links() creates a duplicate.

2Project structure (React Router 7)

React Router 7 uses Vite under the hood. The app/ folder holds all route modules, the root layout, and shared utilities.

my-rr7-app/
├── app/
│ ├── root.tsx # Root layout — <html>, <head>, <Meta />, <Links />
│ ├── routes.ts # Route-to-file mapping (index, route, prefix)
│ ├── routes/
│ │ ├── home.tsx # / — mapped as index() in routes.ts
│ │ ├── about.tsx # /about
│ │ ├── blog.tsx # /blog (listing)
│ │ ├── blog.$slug.tsx # /blog/:slug (dynamic)
│ │ └── dashboard.tsx # /dashboard (noindex)
│ └── lib/
│ └── seo.ts # Shared SEO config + SITE_URL
├── public/ # Static assets
├── react-router.config.ts # SSR / prerender settings
├── vite.config.ts # Vite + React Router plugin
├── tsconfig.json
└── package.json

routes.ts maps each file to its URL path. index() marks the / route; route() maps everything else.

app/routes.tsTypeScript
import {
  type RouteConfig,
  index,
  route,
} from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),                         // → /
  route("about",     "routes/about.tsx"),           // → /about
  route("blog",      "routes/blog.tsx", [           // → /blog
    route(":slug",   "routes/blog.$slug.tsx"),      //   → /blog/:slug
  ]),
  route("dashboard", "routes/dashboard.tsx"),       // → /dashboard
] satisfies RouteConfig;
i

Prefer flat-file auto-discovery? Use import { flatRoutes } from "@react-router/fs-routes" and rename files to the dot-segment convention: _index.tsx/, blog.$slug.tsx/blog/:slug. The SEO code in this guide works identically with both approaches.

3Shared SEO config

Define site-wide defaults once. Every route merges its own overrides on top.

app/lib/seo.tsTypeScript
import { createSEOConfig } from "react-ssr-seo-toolkit";

export const siteConfig = createSEOConfig({
  titleTemplate: "%s | My Site",
  description: "A site about web development.",
  openGraph: {
    siteName: "My Site",
    type: "website",
    locale: "en_US",
  },
  twitter: {
    card: "summary_large_image",
    site: "@mysite",
  },
});

export const SITE_URL = "https://mysite.com";

4Root layout (root.tsx)

<Meta /> is the only place you need to care about. It collects every descriptor returned by the active route's meta() and renders them as actual <title>, <meta>, and <link> tags on the server — before any JavaScript runs.

app/root.tsxTSX
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "react-router";

export default function Root() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        {/* Each route's meta() descriptors are server-rendered here */}
        <Meta />
        {/* Stylesheet <link> tags from each route's links() */}
        <Links />
      </head>
      <body>
        <nav>{/* Shared navigation */}</nav>
        <Outlet />
        <footer>{/* Shared footer */}</footer>
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}
i

<Links /> is for stylesheet and preload <link> tags from a route's links() export — not for SEO. Canonical and hreflang tags are already rendered by <Meta /> via toRouterMeta().

5Static page with meta()

Call toRouterMeta() inside meta(). It returns a flat descriptor array — React Router passes it to <Meta /> which renders every tag server-side, including the canonical <link>.

app/routes/about.tsxTSX
import { toRouterMeta } from "react-ssr-seo-toolkit/adapters/react-router";
import { mergeSEOConfig, buildCanonicalUrl } from "react-ssr-seo-toolkit";
import { siteConfig, SITE_URL } from "~/lib/seo";
import type { MetaDescriptor } from "react-router";

export function meta(): MetaDescriptor[] {
  return toRouterMeta(
    mergeSEOConfig(siteConfig, {
      title: "About Us",
      description: "Learn about our team and mission.",
      canonical: buildCanonicalUrl(SITE_URL, "/about"),
      openGraph: { type: "website" },
    })
  );
}

export default function AboutPage() {
  return (
    <main>
      <h1>About Us</h1>
      <p>Our mission is to build great software.</p>
    </main>
  );
}

6What the server renders

When a user requests /about, React Router SSR renders the above meta() output through <Meta />. The final HTML sent to the browser already contains all SEO tags:

SSR output — /aboutHTML
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <!-- Rendered by <Meta /> from meta() descriptors -->
    <title>About Us | My Site</title>
    <meta name="description" content="Learn about our team and mission." />
    <link rel="canonical" href="https://mysite.com/about" />
    <meta property="og:title" content="About Us" />
    <meta property="og:description" content="Learn about our team and mission." />
    <meta property="og:url" content="https://mysite.com/about" />
    <meta property="og:site_name" content="My Site" />
    <meta property="og:type" content="website" />
    <meta property="og:locale" content="en_US" />
    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:site" content="@mysite" />
  </head>
  <body>
    ...
  </body>
</html>
TIP

These tags exist in the HTML before any JavaScript runs. Search engine crawlers and social media previews see them immediately — this is why SSR matters for SEO.

7Dynamic page with loader + meta()

For dynamic routes, fetch data in a loader and access it in meta() via the data argument. Both run on the server — SEO tags are built from real data before the HTML is sent.

app/routes/blog.$slug.tsxTSX
import { toRouterMeta } from "react-ssr-seo-toolkit/adapters/react-router";
import {
  mergeSEOConfig,
  buildCanonicalUrl,
  createArticleSchema,
  createBreadcrumbSchema,
  safeJsonLdSerialize,
} from "react-ssr-seo-toolkit";
import { siteConfig, SITE_URL } from "~/lib/seo";
import type { Route } from "./+types/blog.$slug";

// Runs on the server — fetches post data
export async function loader({ params }: Route.LoaderArgs) {
  const post = await fetchPost(params.slug);
  return { post };
}

// Runs on the server — builds SEO tags from loader data
export function meta({ data }: Route.MetaArgs): ReturnType<typeof toRouterMeta> {
  if (!data?.post) return [{ title: "Post not found" }];
  const { post } = data;
  const url = buildCanonicalUrl(SITE_URL, `/blog/${post.slug}`);

  return toRouterMeta(
    mergeSEOConfig(siteConfig, {
      title: post.title,
      description: post.excerpt,
      canonical: url,
      openGraph: {
        type: "article",
        title: post.title,
        description: post.excerpt,
        url,
        images: [{ url: post.image, width: 1200, height: 630, alt: post.title }],
      },
      twitter: { card: "summary_large_image", title: post.title },
    })
  );
}

// Page component — JSON-LD goes in the body, not <head>
export default function BlogPostPage({ loaderData }: Route.ComponentProps) {
  const { post } = loaderData;
  const url = buildCanonicalUrl(SITE_URL, `/blog/${post.slug}`);

  const articleSchema = createArticleSchema({
    headline: post.title,
    url,
    datePublished: post.date,
    author: [{ name: post.author }],
    publisher: { name: "My Site", logo: `${SITE_URL}/logo.png` },
    images: [post.image],
  });

  const breadcrumb = createBreadcrumbSchema([
    { name: "Home", url: SITE_URL },
    { name: "Blog", url: buildCanonicalUrl(SITE_URL, "/blog") },
    { name: post.title, url },
  ]);

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: safeJsonLdSerialize(articleSchema) }}
      />
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: safeJsonLdSerialize(breadcrumb) }}
      />
      <h1>{post.title}</h1>
      <p>By {post.author} on {post.date}</p>
      <div>{post.content}</div>
    </article>
  );
}

async function fetchPost(slug: string) {
  return {
    title: "My Blog Post",
    excerpt: "A short description of the post.",
    content: "Full post content...",
    author: "Jane Doe",
    date: "2025-06-15",
    image: `${SITE_URL}/images/${slug}.jpg`,
    slug,
  };
}
TIP

JSON-LD <script> tags go in the component body — React Router has no built-in way to inject inline scripts via meta(). Google reads JSON-LD from anywhere in the document, so body placement is perfectly valid.

8Dashboard page (noindex)

Pass robots in the config — toRouterMeta() automatically emits the robots meta tag.

app/routes/dashboard.tsxTSX
import { toRouterMeta } from "react-ssr-seo-toolkit/adapters/react-router";
import { mergeSEOConfig } from "react-ssr-seo-toolkit";
import { siteConfig } from "~/lib/seo";
import type { MetaDescriptor } from "react-router";

export function meta(): MetaDescriptor[] {
  return toRouterMeta(
    mergeSEOConfig(siteConfig, {
      title: "Dashboard",
      robots: { noIndex: true, noFollow: false },
    })
  );
  // Renders: <meta name="robots" content="noindex,follow">
}

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <p>Your private dashboard content.</p>
    </main>
  );
}

9Reusable meta helper

Wrap merge + adapter in a thin helper so every route needs just one line.

app/lib/seo.ts — extendedTypeScript
import { createSEOConfig, mergeSEOConfig, buildCanonicalUrl } from "react-ssr-seo-toolkit";
import { toRouterMeta } from "react-ssr-seo-toolkit/adapters/react-router";
import type { SEOConfig } from "react-ssr-seo-toolkit";

export const siteConfig = createSEOConfig({
  titleTemplate: "%s | My Site",
  openGraph: { siteName: "My Site", type: "website", locale: "en_US" },
  twitter: { card: "summary_large_image", site: "@mysite" },
});

export const SITE_URL = "https://mysite.com";

export function buildRouteMeta(override: Partial<SEOConfig>, path?: string) {
  return toRouterMeta(
    mergeSEOConfig(siteConfig, {
      ...override,
      ...(path && { canonical: buildCanonicalUrl(SITE_URL, path) }),
    })
  );
}

Any route now generates all SEO tags in one line:

app/routes/about.tsx (simplified)TSX
import { buildRouteMeta } from "~/lib/seo";

export const meta = () =>
  buildRouteMeta({ title: "About Us", description: "Learn about our team." }, "/about");

export default function AboutPage() {
  return (
    <main>
      <h1>About Us</h1>
      <p>Our mission is to build great software.</p>
    </main>
  );
}

Summary

FeatureWhereTool
Title, description, canonical, OG, Twitter, robotsmeta()<Meta /> (SSR)toRouterMeta(config)
JSON-LD structured dataComponent body (<script>)Schema generators + safeJsonLdSerialize
Dynamic SEO (data from DB/CMS)loader()meta({ data })Same toRouterMeta(), called with fetched data
Noindexmeta()robots: { noIndex: true } in config
Stylesheets / preload hintslinks()<Links />Native React Router (not an SEO concern)