React Router
Build a localized, server-rendered React Router blog with Paragraph CMS and manually generated public documents.
This guide extends the React Router quickstart
with localized blog routes and manual robots.txt, sitemap.xml, and
llms.txt routes. Server-side rendering (SSR) is the recommended delivery
model for Paragraph CMS.
Enable SSR and install the packages
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
} satisfies Config;npm install @paragraphcms/client @paragraphcms/parser-reactCreate server-only CMS and URL helpers
Create a blog collection in Paragraph CMS and add the API key to your
server environment. The helper below maps the default locale to /blog
and translated locales to /{locale}/blog.
import { Client } from "@paragraphcms/client";
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 = {
name: "Example blog",
url: "https://example.com",
};
export function blogPath(
locale: string,
defaultLocale: string,
slug?: string,
) {
const prefix = locale === defaultLocale ? "" : `/${locale}`;
const suffix = slug ? `/${slug}` : "";
return `${prefix}/blog${suffix}`;
}
export function publicUrl(pathname: string) {
return new URL(pathname, site.url).toString();
}Declare the SSR routes
import { index, route, type RouteConfig } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("blog", "routes/blog.tsx"),
route("blog/:slug", "routes/blog-post.tsx"),
route(":locale/blog", "routes/localized-blog.tsx"),
route(":locale/blog/:slug", "routes/localized-blog-post.tsx"),
route("robots.txt", "routes/robots.ts"),
route("sitemap.xml", "routes/sitemap.ts"),
route("llms.txt", "routes/llms.ts"),
] satisfies RouteConfig;Load Paragraph content in route loaders. Loaders run on the server during SSR, keeping the API key outside the browser bundle.
Server-render the default-locale blog
import { useLoaderData } from "react-router";
import { Blog } from "@/components/blog/blog";
import { client } from "@/lib/paragraph.server";
export async function loader() {
const { data: defaultLocale, error: defaultLocaleError } =
await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
const { data: posts, error } = await client.pages.list({
collection: "blog",
language: defaultLocale,
requiredSlug: true,
});
if (error) {
throw error;
}
return { defaultLocale, posts };
}
export default function BlogRoute() {
const { defaultLocale, posts } = useLoaderData<typeof loader>();
return <Blog defaultLocale={defaultLocale} locale={defaultLocale} posts={posts} />;
}import { useLoaderData } from "react-router";
import { Post } from "@/components/blog/post";
import { client } from "@/lib/paragraph.server";
export async function loader({ params }: { params: { slug?: string } }) {
if (!params.slug) {
throw new Response("Not Found", { status: 404 });
}
const { data: defaultLocale, error: defaultLocaleError } =
await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
const { data: page, error } = await client.page.getBySlug(params.slug);
if (error) {
throw error;
}
if (page.language !== defaultLocale) {
throw new Response("Not Found", { status: 404 });
}
return { defaultLocale, page };
}
export default function BlogPostRoute() {
const { defaultLocale, page } = useLoaderData<typeof loader>();
return <Post defaultLocale={defaultLocale} locale={defaultLocale} page={page} />;
}Server-render translated routes
The translated list route verifies that the URL does not repeat the
default locale. Use the same check plus page.language in the translated
post route.
import { useLoaderData } from "react-router";
import { Blog } from "@/components/blog/blog";
import { client } from "@/lib/paragraph.server";
export async function loader({ params }: { params: { locale?: string } }) {
const locale = params.locale;
if (!locale) {
throw new Response("Not Found", { status: 404 });
}
const { data: defaultLocale, error: defaultLocaleError } =
await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
if (locale === defaultLocale) {
throw new Response("Not Found", { status: 404 });
}
const { data: posts, error } = await client.pages.list({
collection: "blog",
language: locale,
requiredSlug: true,
});
if (error) {
throw error;
}
return { defaultLocale, locale, posts };
}
export default function LocalizedBlogRoute() {
const { defaultLocale, locale, posts } = useLoaderData<typeof loader>();
return <Blog defaultLocale={defaultLocale} locale={locale} posts={posts} />;
}The localized post loader follows the same sequence: validate locale,
load the page by slug, then return 404 when page.language !== locale.
Build public documents manually
import { blogPath, client, publicUrl, site } from "./paragraph.server";
function escapeXml(value: string) {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export async function getPublicUrls() {
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 urls = [publicUrl("/"), publicUrl("/blog")];
for (const locale of locales) {
const { data: posts, error } = await client.pages.list({
collection: "blog",
language: locale.code,
requiredSlug: true,
});
if (error) {
throw error;
}
if (locale.code !== defaultLocale) {
urls.push(publicUrl(blogPath(locale.code, defaultLocale)));
}
for (const post of posts) {
urls.push(publicUrl(blogPath(locale.code, defaultLocale, post.slug)));
}
}
return urls;
}
export function robotsTxt() {
return `User-agent: *\nAllow: /\nSitemap: ${publicUrl("/sitemap.xml")}\n`;
}
export function sitemapXml(urls: string[]) {
const entries = urls
.map((url) => ` <url><loc>${escapeXml(url)}</loc></url>`)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries}\n</urlset>`;
}
export function llmsTxt(urls: string[]) {
return `# ${site.name}\n\n## Pages\n${urls.map((url) => `- ${url}`).join("\n")}\n`;
}Expose the document routes
import { robotsTxt } from "@/lib/public-documents.server";
export function loader() {
return new Response(robotsTxt(), {
headers: { "content-type": "text/plain; charset=utf-8" },
});
}import { getPublicUrls, sitemapXml } from "@/lib/public-documents.server";
export async function loader() {
const urls = await getPublicUrls();
return new Response(sitemapXml(urls), {
headers: { "content-type": "application/xml; charset=utf-8" },
});
}import { getPublicUrls, llmsTxt } from "@/lib/public-documents.server";
export async function loader() {
const urls = await getPublicUrls();
return new Response(llmsTxt(urls), {
headers: { "content-type": "text/plain; charset=utf-8" },
});
}Configure caching at deployment
Cache public published pages and public documents at the deployment layer. Preview, draft, authenticated, and webhook routes must bypass that shared cache.