React Router
Build a localized React Router blog with Paragraph CMS from scratch, including prerendered routes, sitemap.xml, robots.txt, llms.txt, and RSS output.
This guide builds a React Router framework-mode blog from scratch with
Paragraph CMS, using build-time prerendering for known blog routes,
locale-aware /blog URLs, and generated sitemap.xml, robots.txt,
llms.txt, and RSS output. The code below follows the same integration
pattern as the full
paragraphcms/react-router-advanced
example while staying focused on the CMS, routing, and SEO setup.
Want a ready-made project instead? Use the
paragraphcms/react-router-advanced,
which already includes localized blog routes, locale switching UI,
prerendered pages, and generated sitemap.xml, robots.txt, llms.txt,
and RSS.
Install the packages
Add the Paragraph CMS client, React renderer, and SEO package to your React Router app:
npm install @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seopnpm add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seoyarn add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seobun add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seoCreate paragraph.config.ts
Create a shared client, a shared site object, and a shared seo
instance.
One detail here is easy to misread: routes is not your React Router file
tree. It is the route map used by @paragraphcms/seo when it generates
canonical URLs, sitemap entries, RSS links, and llms.txt links.
In this example:
home: localizedRoute("blog")tells the SEO library to treat/blogas the public entry point. It resolves to/blogfor the default locale and/${locale}/blogfor translated locales.blog: localizedContentRoute("blog")defines the actual blog content route. It tells the library that the blog index lives at/blogand/${locale}/blog, and that post URLs should be built by appending the page slug.
Both entries use "blog" on purpose. The first says "this is the public
home URL", and the second says "this is the blog content section".
If your real homepage lived at /, you would use home: localizedRoute()
instead.
Set site.url to your production site URL so generated sitemap entries and
feeds point to the correct domain.
site.defaultLocale is optional. If you omit that field, new SEO() uses
the default locale configured in Paragraph CMS.
import { Client } from "@paragraphcms/client";
import { localizedContentRoute, localizedRoute, SEO } from "@paragraphcms/seo";
const apiKey = process.env.PARAGRAPH_API_KEY;
if (!apiKey) {
throw new Error("PARAGRAPH_API_KEY environment variable is not set");
}
export const client = new Client({
apiKey,
});
export const site = {
url: "https://example.com",
name: "Paragraph CMS React Router Advanced Starter",
description: "Advanced Starter Paragraph CMS for React Router",
};
export const seo = new SEO({
client,
site,
routes: {
// Public home URL in generated SEO documents.
home: localizedRoute("blog"),
// Blog index and post URLs.
blog: localizedContentRoute("blog"),
},
});Configure prerendering in react-router.config.ts
React Router framework mode can prerender both HTML and loader data for known paths at build time. Use CMS data to generate that path list so blog posts, localized routes, and feeds stay in sync with Paragraph CMS.
One React Router-specific detail matters here: react-router.config.ts
runs in the build config layer, not inside your route loaders. Load the env
explicitly with Vite's loadEnv() and create the client inside
prerender().
import { Client } from "@paragraphcms/client";
import type { Config } from "@react-router/dev/config";
import { loadEnv } from "vite";
export default {
async prerender() {
const env = loadEnv("", process.cwd(), "");
const apiKey = env.PARAGRAPH_API_KEY || process.env.PARAGRAPH_API_KEY;
if (!apiKey) {
throw new Error("PARAGRAPH_API_KEY environment variable is not set");
}
const client = new Client({ apiKey });
const {
data: defaultLocale,
error: defaultLocaleError,
} = await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
const { data: locales, error: localesError } = await client.locales.list();
if (localesError) {
throw localesError;
}
const paths = new Set([
"/",
"/blog",
"/blog/rss.xml",
"/llms.txt",
"/robots.txt",
"/sitemap.xml",
]);
for (const locale of locales) {
const { data: pages, error } = await client.pages.list({
language: locale.code,
requiredSlug: true,
});
if (error) {
throw error;
}
if (locale.code !== defaultLocale) {
paths.add(`/${locale.code}`);
paths.add(`/${locale.code}/blog`);
paths.add(`/${locale.code}/blog/rss.xml`);
}
for (const page of pages) {
paths.add(
locale.code === defaultLocale
? `/blog/${page.slug}`
: `/${locale.code}/blog/${page.slug}`,
);
}
}
return Array.from(paths).sort();
},
ssr: true,
} satisfies Config;Declare the routes
Register the blog, locale, and generated-document routes in app/routes.ts:
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/index.tsx"),
route("blog", "routes/blog.tsx"),
route("blog/:slug", "routes/blog.$slug.tsx"),
route("blog/rss.xml", "routes/blog.rss.xml.ts"),
route(":locale", "routes/locale.tsx"),
route(":locale/blog", "routes/locale.blog.tsx"),
route(":locale/blog/:slug", "routes/locale.blog.$slug.tsx"),
route(":locale/blog/rss.xml", "routes/locale.blog.rss.xml.ts"),
route("llms.txt", "routes/llms.txt.ts"),
route("robots.txt", "routes/robots.txt.ts"),
route("sitemap.xml", "routes/sitemap.xml.ts"),
] satisfies RouteConfig;Redirect / and /:locale
Like the Next.js and Astro examples, this setup treats /blog as the
public entry point. Add a small index redirect:
import { redirect } from "react-router";
export function loader() {
return redirect("/blog");
}
export default function IndexRoute() {
return null;
}Then add a locale redirect so /{locale} resolves to /{locale}/blog
while still rejecting the default locale prefix:
import { redirect } from "react-router";
import type { Route } from "./+types/locale";
import { client } from "../../paragraph.config";
export async function loader({ params }: Route.LoaderArgs) {
const locale = params.locale;
if (!locale) {
throw new Response("Not Found", { status: 404 });
}
const defaultLocale = await client.locales.getDefaultLocale();
if (locale === defaultLocale) {
throw new Response("Not Found", { status: 404 });
}
return redirect(`/${locale}/blog`);
}
export default function LocalizedIndexRoute() {
return null;
}Build /blog
The unprefixed /blog route renders the default locale.
import { useLoaderData } from "react-router";
import { Feed } from "~/components/blog/feed";
import { client } from "../../paragraph.config";
export async function loader() {
const defaultLocale = await client.locales.getDefaultLocale();
const { data: posts } = await client.pages.list({
language: defaultLocale,
requiredSlug: true,
});
const { data: labels } = await client.labels.list();
return {
defaultLocale,
labels,
locale: defaultLocale,
posts,
};
}
export default function BlogRoute() {
const { defaultLocale, labels, locale, posts } = useLoaderData<typeof loader>();
return (
<Feed
defaultLocale={defaultLocale}
labels={labels}
locale={locale}
posts={posts}
/>
);
}Build /blog/:slug
For article pages, resolve the slug from the route param and fetch the full page directly by slug. Since slugs are globally unique, you do not need a separate locale-scoped lookup first.
import { useLoaderData } from "react-router";
import type { Route } from "./+types/blog.$slug";
import { BlogPostPage } from "~/components/blog/post-page";
import { client } from "../../paragraph.config";
export async function loader({ params }: Route.LoaderArgs) {
const slug = params.slug;
if (!slug) {
throw new Response("Not Found", { status: 404 });
}
const defaultLocale = await client.locales.getDefaultLocale();
const page = await client.page.getBySlug(slug).catch(() => {
throw new Response("Not Found", { status: 404 });
});
return {
defaultLocale,
locale: defaultLocale,
page,
};
}
export default function BlogPostRoute() {
const { defaultLocale, locale, page } = useLoaderData<typeof loader>();
return (
<BlogPostPage defaultLocale={defaultLocale} locale={locale} page={page} />
);
}Add localized /:locale/blog routes
Add a second route tree for non-default locales. The default locale stays
unprefixed, and only translated routes are generated under /:locale.
For translated article pages, resolve by slug first, then verify that the
fetched page belongs to the requested locale.
import { useLoaderData } from "react-router";
import type { Route } from "./+types/locale.blog";
import { Feed } from "~/components/blog/feed";
import { client } from "../../paragraph.config";
export async function loader({ params }: Route.LoaderArgs) {
const locale = params.locale;
if (!locale) {
throw new Response("Not Found", { status: 404 });
}
const defaultLocale = await client.locales.getDefaultLocale();
if (locale === defaultLocale) {
throw new Response("Not Found", { status: 404 });
}
const { data: posts } = await client.pages.list({
language: locale,
requiredSlug: true,
});
const { data: labels } = await client.labels.list();
return {
defaultLocale,
labels,
locale,
posts,
};
}
export default function LocalizedBlogRoute() {
const { defaultLocale, labels, locale, posts } = useLoaderData<typeof loader>();
return (
<Feed
defaultLocale={defaultLocale}
labels={labels}
locale={locale}
posts={posts}
/>
);
}import { useLoaderData } from "react-router";
import type { Route } from "./+types/locale.blog.$slug";
import { BlogPostPage } from "~/components/blog/post-page";
import { client } from "../../paragraph.config";
async function getLocalizedPage(locale: string, slug: string) {
const page = await client.page.getBySlug(slug).catch(() => {
throw new Response("Not Found", { status: 404 });
});
if (page.language !== locale) {
throw new Response("Not Found", { status: 404 });
}
return page;
}
export async function loader({ params }: Route.LoaderArgs) {
const locale = params.locale;
const slug = params.slug;
if (!locale || !slug) {
throw new Response("Not Found", { status: 404 });
}
const defaultLocale = await client.locales.getDefaultLocale();
if (locale === defaultLocale) {
throw new Response("Not Found", { status: 404 });
}
const page = await getLocalizedPage(locale, slug);
return {
defaultLocale,
locale,
page,
};
}
export default function LocalizedBlogPostRoute() {
const { defaultLocale, locale, page } = useLoaderData<typeof loader>();
return (
<BlogPostPage defaultLocale={defaultLocale} locale={locale} page={page} />
);
}This keeps /blog reserved for the default locale and prevents duplicate
content under /{default-locale}/blog.
Add robots.txt, sitemap.xml, and llms.txt
Once seo is configured, each generated document is just a small resource
route that returns the string from @paragraphcms/seo.
import { seo } from "../../paragraph.config";
export async function loader() {
const body = await seo.robotsTxt();
return new Response(body, {
headers: {
"content-type": "text/plain; charset=utf-8",
},
});
}import { seo } from "../../paragraph.config";
export async function loader() {
const body = await seo.sitemapXml();
return new Response(body, {
headers: {
"content-type": "application/xml; charset=utf-8",
},
});
}import { seo } from "../../paragraph.config";
export async function loader() {
const body = await seo.llmsTxt();
return new Response(body, {
headers: {
"content-type": "text/plain; charset=utf-8",
},
});
}Add RSS for the default locale and translated locales
Add one route for the default locale feed:
import { seo } from "../../paragraph.config";
export async function loader() {
const body = await seo.rssXml({
route: "blog",
});
return new Response(body, {
headers: {
"content-type": "application/rss+xml; charset=utf-8",
},
});
}Then add the localized RSS route:
import type { Route } from "./+types/locale.blog.rss.xml";
import { client, seo } from "../../paragraph.config";
export async function loader({ params }: Route.LoaderArgs) {
const locale = params.locale;
if (!locale) {
return new Response("Not Found", { status: 404 });
}
const defaultLocale = await client.locales.getDefaultLocale();
if (locale === defaultLocale) {
return new Response("Not Found", { status: 404 });
}
const body = await seo.rssXml({
locale,
route: "blog",
});
return new Response(body, {
headers: {
"content-type": "application/rss+xml; charset=utf-8",
},
});
}Verify the finished setup
After these changes, your app should expose:
/(redirects to/blog)/blog/blog/:slug/{locale}(redirects to/{locale}/blog)/{locale}/blog/{locale}/blog/:slug/robots.txt/sitemap.xml/llms.txt/blog/rss.xml/{locale}/blog/rss.xml
That gives you the same route and generated-document shape as the
react-router-advanced example while keeping the guide self-contained from
the initial install onward.
Astro
Build the same localized Astro blog as the working astro-advanced example, including simple React components, locale-aware routes, sitemap.xml, robots.txt, llms.txt, and RSS output.
Nuxt
Build a localized Nuxt blog with Paragraph CMS from scratch, including prerendered routes, sitemap.xml, robots.txt, llms.txt, and RSS output.