• Features
  • Pricing
Get Started

Next.jsAstroReact RouterNuxtSvelteKitManual

Advanced Usage

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/seo
pnpm add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo
yarn add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo
bun add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo

Set environment variables

Create a .env file in the project root:

.env
PARAGRAPH_API_KEY=your_api_key

Create 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 /blog as the public entry point. It resolves to /blog for the default locale and /${locale}/blog for translated locales.
  • blog: localizedContentRoute("blog") defines the actual blog content route. It tells the library that the blog index lives at /blog and /${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.

paragraph.config.ts
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().

react-router.config.ts
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:

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:

app/routes/index.tsx
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:

app/routes/locale.tsx
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.

app/routes/blog.tsx
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.

app/routes/blog.$slug.tsx
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.

app/routes/locale.blog.tsx
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}
    />
  );
}
app/routes/locale.blog.$slug.tsx
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.

app/routes/robots.txt.ts
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",
    },
  });
}
app/routes/sitemap.xml.ts
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",
    },
  });
}
app/routes/llms.txt.ts
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:

app/routes/blog.rss.xml.ts
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:

app/routes/locale.blog.rss.xml.ts
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.

On this page

Install the packagesSet environment variablesCreate paragraph.config.tsConfigure prerendering in react-router.config.tsDeclare the routesRedirect / and /:localeBuild /blogBuild /blog/:slugAdd localized /:locale/blog routesAdd robots.txt, sitemap.xml, and llms.txtAdd RSS for the default locale and translated localesVerify the finished setup