• Features
  • Pricing
Get Started

Next.jsAstroReact RouterNuxtSvelteKitManual

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

react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
} satisfies Config;
npm install @paragraphcms/client @paragraphcms/parser-react

Create 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.

app/lib/paragraph.server.ts
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

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

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

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

app/lib/public-documents.server.ts
import { blogPath, client, publicUrl, site } from "./paragraph.server";

function escapeXml(value: string) {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&apos;");
}

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

app/routes/robots.ts
import { robotsTxt } from "@/lib/public-documents.server";

export function loader() {
  return new Response(robotsTxt(), {
    headers: { "content-type": "text/plain; charset=utf-8" },
  });
}
app/routes/sitemap.ts
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" },
  });
}
app/routes/llms.ts
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.

Astro

Build a localized, server-rendered Astro blog with Paragraph CMS and manually generated public documents.

Nuxt

Build a localized, server-rendered Nuxt blog with Paragraph CMS and manually generated public documents.

On this page

Enable SSR and install the packagesCreate server-only CMS and URL helpersDeclare the SSR routesServer-render the default-locale blogServer-render translated routesBuild public documents manuallyExpose the document routesConfigure caching at deployment