• Features
  • Pricing
Get Started

Next.jsAstroReact RouterNuxtSvelteKitManual

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

Enable SSR

Keep server rendering enabled in react-router.config.ts so your Paragraph CMS API key stays on the server:

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

Set Environment Variables

Create a .env file in the project root:

.env
PARAGRAPH_API_KEY=your_api_key

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.

paragraph.config.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 });

Declare the Blog Routes

Register your routes in app/routes.ts:

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:

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

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

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

Astro

Server-side rendering in Astro for a simple Paragraph CMS blog.

Nuxt

Server-side rendering with Nuxt for a simple Paragraph CMS blog.

On this page

Install the PackageEnable SSRSet Environment VariablesCreate a Paragraph CMS ClientDeclare the Blog RoutesCreate Example ComponentsBuild /blogBuild /blog/:slug