React Router
SSR with React Router framework-mode loaders for a simple Paragraph CMS blog.
This quickstart uses React Router framework mode and walks through the core setup for fetching and rendering Paragraph CMS content in loader-based route modules, with SSR as the baseline because loaders run on the server and keep the API key out of the browser bundle.
Want a ready-made project instead? Use the
paragraphcms/react-router-starter,
which ships with the same integration pattern already wired into a blog.
Install the Package
Add the Paragraph CMS client and React renderer to your React Router app:
npm install @paragraphcms/client @paragraphcms/parser-reactpnpm add @paragraphcms/client @paragraphcms/parser-reactyarn add @paragraphcms/client @paragraphcms/parser-reactbun add @paragraphcms/client @paragraphcms/parser-reactEnable SSR
Keep server rendering enabled in react-router.config.ts so your
Paragraph CMS API key stays on the server:
import type { Config } from "@react-router/dev/config";
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: true,
} satisfies Config;Prerender Known Blog Routes
If you want the blog index and known posts prerendered at build time,
add prerender() in react-router.config.ts and fetch the slugs from
Paragraph CMS.
react-router.config.ts runs in the build config layer, not inside your
route loaders, so 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, error } = await client.pages.list({ requiredSlug: true });
if (error) {
throw error;
}
return ["/blog", ...data.map((page) => `/blog/${page.slug}`)];
},
ssr: true,
} satisfies Config;This keeps the prerendered /blog/:slug paths aligned with the pages
currently stored in Paragraph CMS.
Create a Paragraph CMS Client
Create paragraph.config.ts and initialize a shared client. In React
Router framework mode, route loaders run on the server, so reading from
process.env keeps the API key out of the browser bundle.
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 });Declare the Blog Routes
Register your routes in app/routes.ts:
import { type RouteConfig, route } from "@react-router/dev/routes";
export default [
route("blog", "routes/blog.tsx"),
route("blog/:slug", "routes/blog.$slug.tsx"),
] satisfies RouteConfig;Create Example Components
Create two very simple components that we will render in the next steps:
import type { PageSummaryWithSlug } from "@paragraphcms/client";
import { Link } from "react-router";
export function Blog({ posts }: { posts: PageSummaryWithSlug[] }) {
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link to={`/blog/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
</main>
);
}import type { PageWithSlug } from "@paragraphcms/client";
import { ParagraphContent } from "@paragraphcms/parser-react";
export function Post({ page }: { page: PageWithSlug }) {
return (
<main>
<h1>{page.title}</h1>
<ParagraphContent content={page.content} />
</main>
);
}Build /blog
Build the /blog route with client.pages.list(). Setting
requiredSlug: true narrows the result to PageSummaryWithSlug[], so
post.slug is safe to use directly.
import { useLoaderData } from "react-router";
import { Blog } from "../components/blog/blog";
import { client } from "../../paragraph.config";
export async function loader() {
const { data, error } = await client.pages.list({
requiredSlug: true,
});
if (error) {
throw error;
}
return { posts: data };
}
export default function BlogRoute() {
const { posts } = useLoaderData<typeof loader>();
return <Blog posts={posts} />;
}By default, client.pages.list() returns published pages only. If you want
to include unpublished pages too or limit the query to a single
collection, pass additional parameters under the same call:
client.pages.list({
requiredSlug: true,
published: false,
collection: "blog",
});Build /blog/:slug
Build the /blog/:slug route with a loader that resolves the route
param through client.page.getBySlug().
import { useLoaderData } from "react-router";
import type { Route } from "./+types/blog.$slug";
import { Post } from "../components/blog/post";
import { client } from "../../paragraph.config";
export async function loader({ params }: Route.LoaderArgs) {
const { data, error } = await client.page.getBySlug(params.slug!);
if (error) {
throw error;
}
return { data };
}
export default function BlogPostRoute() {
const { data } = useLoaderData<typeof loader>();
return <Post page={data} />;
}This keeps the route aligned with the slugs stored in Paragraph CMS while letting React Router load each post on the server.
Once this is wired, the rest of the page can be standard React Router UI around the rendered Paragraph content.