React Router 7 Integration Guide
How to use react-ssr-seo-toolkit with React Router 7's SSR — the meta() export, loaders, JSON-LD, and the toRouterMeta() adapter. All SEO tags are rendered server-side through <Meta /> in your root layout.
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.
| What | Route export | How |
|---|---|---|
| Title, description, canonical, OG, Twitter, robots | meta() in each route | toRouterMeta(config) — converts any SEOConfig to a descriptor array in one call. Includes the canonical <link> tag too. |
| JSON-LD structured data | Route component body | Schema 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.
├── 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.
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;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.
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.
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>
);
}<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>.
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:
<!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>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.
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,
};
}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.
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.
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:
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
| Feature | Where | Tool |
|---|---|---|
| Title, description, canonical, OG, Twitter, robots | meta() → <Meta /> (SSR) | toRouterMeta(config) |
| JSON-LD structured data | Component body (<script>) | Schema generators + safeJsonLdSerialize |
| Dynamic SEO (data from DB/CMS) | loader() → meta({ data }) | Same toRouterMeta(), called with fetched data |
| Noindex | meta() | robots: { noIndex: true } in config |
| Stylesheets / preload hints | links() → <Links /> | Native React Router (not an SEO concern) |