React Server Components and Strapi 5 solve two different problems that often get tangled together on content-heavy React sites. This tutorial shows how to pair a Strapi 5 backend with a minimal Vite RSC frontend so your team can publish content without tying every edit to a rebuild.
Content teams often get stuck behind engineering workflows when a simple copy change still requires a full rebuild and redeploy. React Server Components reduce frontend JavaScript, and a headless content management system (CMS) removes the content bottleneck. Together, they replace the static-site-generator pattern many React content sites are still using.
In this tutorial, you'll build a Strapi 5 backend paired with a minimal Vite RSC frontend that fetches and renders content, including Dynamic Zones, with a single Client Component for interactivity.
In brief:
- Server Components let you fetch data in async components on the server, so only interactive bits ship JavaScript to the browser.
- Vite's
@vitejs/plugin-rscmakes RSCs usable without committing to a full framework. - Strapi 5's flattened REST API and Document Service map cleanly onto async Server Components.
- The pairing gives you near-static performance without the rebuild-on-every-typo penalty of static site generators.
Why React Server Components Need a Headless CMS
Before touching code, it helps to understand why these two technologies solve different halves of the same problem.
The Static-Site Rebuild Problem
Static site generators (SSGs) trade runtime flexibility for raw speed. Every page is pre-rendered to HTML at build time, so the CDN serves files fast. The cost is that content and source code are coupled. Every change, whether it's a new feature or a one-line copy fix, triggers the same pipeline: rebuild, redeploy, wait.
That pattern creates a long feedback loop for content teams. Editors block on engineers, and engineers spend cycles on deploys that have nothing to do with code.
What Server Components Actually Solve
React Server Components let you write async function components that fetch data on the server and stream rendered output to the browser. The non-interactive parts of your page ship zero JavaScript. No useEffect for data fetching, no loading spinners, and no client-side fetch ceremony.
What RSCs don't solve: content authoring, editorial workflows, role-based publishing, or media management. They're a rendering architecture, not a content platform.
Where Strapi Fits In
Headless CMS exposes content over REST and GraphQL. Editors update content through the Admin Panel, using Strapi features, and your Server Components fetch it at request time. Your CDN caches the rendered HTML. No redeploys, and no coupling.
This separation means your content team can publish a blog post in the afternoon without filing a pull request. Your engineering team can refactor the frontend without touching a single piece of content.
Sign up for the Logbook, Strapi's Monthly newsletter
Set Up Your Strapi 5 Backend
The backend establishes the data your RSC frontend will consume. This section walks through installation, content modeling, and API access.
Install Strapi and Create Your Project
Prerequisites: Node.js 20, 22, or 24 LTS (odd-numbered "current" releases like v23 or v25 are not supported).
Run the create command:
npx create-strapi@latest cmsNote: the legacy --quickstart flag and quickstart behavior still exist in Strapi 5, though the official docs no longer highlight it and its behavior has changed. The CLI walks you through install prompts, including database selection and related database configuration details.
Once installed, start the development server:
cd cms && npm run developOpen http://localhost:1337/admin and register your first admin user. For a full walkthrough of these steps, see the Quick Start guide.
Define a Post Collection Type
Open the Content-Type Builder from the admin panel's main navigation. Click "Create new collection type", enter Post as the display name, and add the following fields:
- Title: Text (Short text)
- Slug: UID (attached to Title)
- Excerpt: Text (Long text)
- Body: Rich text (Blocks)
- Cover: Media (Single media)
- Author: Relation (many Posts to one Author, if you've created an Author type)
Click Save. The server restarts automatically.
If you plan to build flexible landing pages, also consider adding a Dynamic Zone field to a separate Page collection type. We'll use Dynamic Zones later in this tutorial to render component-based layouts.
Head to the Content Manager, create two or three sample posts, and publish them. You'll need real data to test the frontend.
One note: the Content-Type Builder is only accessible in development mode. In production, it switches to read-only. This is by design. Your content model is part of your codebase, not a runtime concern.
Generate an API Token and Set Permissions
Navigate to Settings → API Tokens → Create new API token. Set the type to Read-only and the duration to Unlimited (fine for development). Click Save and copy the token immediately. It's shown only once unless you've configured an encryption key.
If you'd rather use public access instead of token-based auth, go to Settings → Users & Permissions → Roles → Public, find the Post content type, and enable find and findOne. Click Save.
For this tutorial, we'll use the API token approach. Store the token somewhere safe. You'll pass it as a bearer header from the RSC backend.
Test your endpoint:
curl http://localhost:1337/api/posts \
-H "Authorization: Bearer YOUR_TOKEN_HERE"You should see Strapi 5's flattened response format, with fields directly on data rather than nested under data.attributes. More on that shortly.
Bootstrap the React Server Components Frontend with Vite
With the backend running and serving data, it's time to build the frontend.
Why Vite for This Build
Some React frameworks bundle routing, caching, and server runtime opinions into a cohesive package. That's useful when you want convention over configuration. But the Vite RSC plugin, @vitejs/plugin-rsc, gives you React 19 Server Components with almost none of those opinions. It's a low-level primitive, not a framework.
This matters when you want full control over how you fetch from and cache against a CMS API. You choose the routing library, the caching strategy, and the deployment target.
The plugin is already used as a foundation for multiple RSC-based setups, so you're not betting on a niche tool.
Create the Project
Scaffold the app:
npm create vite@latest -- --template rscThis generates a minimal RSC application. Open vite.config.ts. The important part is the three-entry configuration:
import rsc from '@vitejs/plugin-rsc'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
rsc({
entries: {
rsc: 'src/entry.rsc.tsx',
ssr: 'src/entry.ssr.tsx',
client: 'src/entry.browser.tsx',
},
}),
],
})Each entry targets a separate environment with its own module graph:
rscruns your Server Components, discovers'use client'boundaries, and produces the RSC payload stream.ssrhandles server-side rendering of the full component tree including the hydration shell. It runs last in the build pipeline.clientis the browser bundle. It handles client components, hydration, and interactivity.
During production builds, the pipeline runs in a specific order: RSC scan → SSR scan → RSC build → Client build → SSR build. This lets each stage discover the boundaries the next stage needs.
Configure Environment Variables for Your Strapi URL
Create a .env file in the project root:
STRAPI_URL=http://localhost:1337
STRAPI_TOKEN=your_api_token_hereSecrets accessed in Server Components stay on the server unless you explicitly pass their values to Client Components or otherwise serialize them into the response. The RSC environment runs server-side only. Your API token stays on the server, and the client bundle has no reference to it.
This improves on client-side React patterns, but it requires deliberate architecture. The RSC vulnerabilities (CVE-2025-55183 specifically) demonstrated that hardcoded secrets in Server Functions can be exposed through crafted HTTP requests. Always avoid inline literals for secrets and use a proper secrets management approach. And keep your React version patched: 19.0.4+, 19.1.5+, or 19.2.4+ contain the current fixes.
Fetch Strapi 5 Content from Server Components
This is the core of the integration. Server Components can await data directly, so fetching from Strapi's REST API feels natural.
Write a Typed Fetch Helper for the Strapi REST API
Build a small helper that handles the base URL and authorization:
// lib/strapi.ts
const STRAPI_URL = process.env.STRAPI_URL!
const STRAPI_TOKEN = process.env.STRAPI_TOKEN!
interface StrapiResponse<T> {
data: T
meta: Record<string, unknown>
}
export async function getStrapi<T>(
path: string,
params?: string
): Promise<StrapiResponse<T>> {
const url = `${STRAPI_URL}${path}${params ? `?${params}` : ''}`
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
},
})
if (!res.ok) {
throw new Error(`Strapi request failed: ${res.status} ${res.statusText}`)
}
return res.json()
}Two things to note about Strapi 5's response format. First, the response is flattened: attributes live directly on data, not nested under data.attributes like in v4. Second, documentId (a 24-character alphanumeric string) replaces the v4 numeric id as the stable identifier for content queries. Both id and documentId appear in responses, but you should use documentId for fetching individual documents. See the REST API reference for the full specification.
Render a List of Posts with an Async Server Component
Here's where RSCs are useful. The component fetches data, renders it, and ships zero JavaScript to the browser:
// components/PostList.tsx
import { getStrapi } from '../lib/strapi'
interface Post {
documentId: string
title: string
slug: string
excerpt: string
}
export default async function PostList() {
const { data: posts } = await getStrapi<Post[]>('/api/posts')
return (
<section>
<h2>Latest Posts</h2>
<ul>
{posts.map((post) => (
<li key={post.documentId}>
<a href={`/posts/${post.slug}`}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</a>
</li>
))}
</ul>
</section>
)
}No useEffect. No useState for loading. No client-side data fetching ceremony. The component is async, it awaits the API call, and React suspends until the data resolves.
Use the Populate Parameter for Relations and Media
By default, Strapi 5 responses don't include relations, media, components, or Dynamic Zones. You need to ask for them explicitly using the populate parameter:
/api/posts?populate[cover]=true&populate[author][fields][0]=nameThe fields parameter trims the response to only the properties you need. This matters for RSC payload size, because every byte the server renders is a byte the client receives in the initial HTML or RSC stream.
For a single post with its cover image and author name:
const { data: post } = await getStrapi<Post>(
`/api/posts/${documentId}`,
'populate[cover]=true&populate[author][fields][0]=name'
)Cache Responses with Cache-Control Headers
To get CDN-level caching without a full static build, set Cache-Control headers on your server responses:
Cache-Control: public, s-maxage=300, stale-while-revalidate=3600This tells shared caches (CDNs) to serve the cached version for five minutes, then revalidate in the background for up to an hour.
Content updates may take up to the configured cache freshness and staleness periods (potentially up to s-maxage + stale-while-revalidate) to propagate, and may not appear to all users within a few minutes without additional cache invalidation. For managed hosting with built-in CDN support, Strapi Cloud is one option worth evaluating.
For routes where you want fully static output, React 19's prerender API from react-dom/static waits for all data to load before returning complete HTML. A practical pattern is to add a Cache-Control: public, max-age=123 header to your responses and use import { prerender } from 'react-dom/static'.
Render Dynamic Zones and Rich Text Blocks
Dynamic Zones are the hard part of any headless CMS frontend, and a place where RSCs make life noticeably easier.
Populate Dynamic Zones Explicitly in Strapi 5
Strapi 5 dropped the shared population strategy from v4. Dynamic Zones must be populated with on fragments that specify each component. This is a breaking change. You have to update queries manually.
For complex queries, the qs library keeps things readable:
import qs from 'qs'
const query = qs.stringify({
populate: {
blocks: {
on: {
'blocks.hero': { populate: ['backgroundImage'] },
'blocks.rich-text': { populate: '*' },
'blocks.feature-list': {
populate: ['features', 'features.icon'],
},
},
},
},
}, { encodeValuesOnly: true })
const { data: page } = await getStrapi<Page>(`/api/pages?${query}`)Each component in the response includes a __component field in the format category.component-name, for example blocks.hero or blocks.rich-text. For a deeper walkthrough, see our Dynamic Zones article.
Map Components to React Components on the Server
Build a <DynamicZoneRenderer> Server Component that switches on __component:
// components/DynamicZoneRenderer.tsx
import HeroBlock from './blocks/HeroBlock'
import FeatureBlock from './blocks/FeatureBlock'
const componentRegistry: Record<string, React.ComponentType<any>> = {
'blocks.hero': HeroBlock,
'blocks.feature': FeatureBlock,
}
interface Block {
id: number
__component: string
[key: string]: unknown
}
export default function DynamicZoneRenderer({ blocks }: { blocks: Block[] }) {
return (
<>
{blocks.map((block) => {
const Component = componentRegistry[block.__component]
if (!Component) {
console.warn(`No component registered for: ${block.__component}`)
return null
}
return <Component key={block.id} {...block} />
})}
</>
)
}Each branch can itself be an async Server Component. A <HeroBlock> could fetch related content or check a feature flag inline, with no prop drilling and no client-side round trip.
Render the Blocks Rich Text Format
Strapi 5's Blocks field returns structured JSON, not HTML. The format includes node types like paragraph, heading, list, quote, code, image, and link.
You can use the official @strapi/blocks-react-renderer package:
npm install @strapi/blocks-react-rendererimport { BlocksRenderer } from '@strapi/blocks-react-renderer'
export default function RichTextBlock({ content }: { content: any[] }) {
return (
<BlocksRenderer
content={content}
blocks={{
paragraph: ({ children }) => <p className="mb-4">{children}</p>,
heading: ({ children, level }) => {
const Tag = `h${level}` as keyof JSX.IntrinsicElements
return <Tag>{children}</Tag>
},
list: ({ children, format }) =>
format === 'ordered'
? <ol className="list-decimal pl-6">{children}</ol>
: <ul className="list-disc pl-6">{children}</ul>,
code: ({ plainText }) => (
<pre><code>{plainText}</code></pre>
),
image: ({ image }) => (
<img src={image.url} alt={image.alternativeText || ''} />
),
}}
/>
)
}Custom block components receive the props of the block or modifier; make sure to render children so nested content is rendered as well.
A Server Component is the right home for this rendering work. The recursive tree walk happens on the server, produces HTML, and the client receives static markup with no hydration cost for what is, fundamentally, static prose.
Add Client Interactivity Without Bloating the Bundle
Only the interactive pieces ship JavaScript. Everything else stays on the server.
Mark Interactive Pieces with 'use client'
Create a <SearchFilter> Client Component with the directive at the top of the file:
// components/SearchFilter.tsx
'use client'
import { useState } from 'react'
interface Post {
documentId: string
title: string
slug: string
excerpt: string
}
export default function SearchFilter({ posts }: { posts: Post[] }) {
const [query, setQuery] = useState('')
const filtered = posts.filter((post) =>
post.title.toLowerCase().includes(query.toLowerCase())
)
return (
<div>
<input
type="text"
placeholder="Filter posts..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ul>
{filtered.map((post) => (
<li key={post.documentId}>
<a href={`/posts/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</div>
)
}Everything imported into a Client Component becomes part of the client bundle. Keep the boundary tight. The 'use client' directive marks the entire module and all its transitive dependencies as client-rendered.
Pass Server-Fetched Data into Client Islands
The Server Component handles the data fetching. The Client Component handles only the interactivity:
// pages/Home.tsx (Server Component, no directive)
import { getStrapi } from '../lib/strapi'
import SearchFilter from '../components/SearchFilter'
export default async function Home() {
const { data: posts } = await getStrapi<Post[]>('/api/posts')
return (
<main>
<h1>Blog</h1>
<SearchFilter posts={posts} />
</main>
)
}The server fetches the post list from Strapi, serializes it as props, and passes it to <SearchFilter>. The client receives only the filtering logic and the data it needs. No duplicate fetch, and no loading state.
Verify the Bundle Stays Small
Build the project and check the output. Most routes should ship well under the typical JavaScript payload you'd see in a client-rendered React app. The bundler can automatically split code into separate client and server bundles by detecting 'use client' and 'use server' directives. Client Components can still help structure what runs on the client, but code-splitting behavior depends on the framework and loading strategy.
That outcome is one of the main benefits of this architecture. The default path is to keep non-interactive UI on the server instead of shipping it to the browser.
For more patterns on combining React 19 with Strapi, see our React 19 tutorial.
Where to Take This Next
Here's what you've built: a Strapi 5 backend serving content over REST, a Vite RSC frontend fetching that content in Server Components, Dynamic Zones rendered server-side, and a single Client Component handling interactivity. The content team can publish without triggering a deploy, and the site ships minimal JavaScript to the browser.
From here, a few directions worth exploring:
- File-based routing using
import.meta.globto auto-discover page components and scale your site without manual route wiring. - Server Functions for form submissions. Add
'use server'to async functions and call them directly from Client Components. - Deploy to CDN with
Cache-Controlheaders for near-static performance at the edge.
Ready to spin up your own backend? Start with getting started guide, and check the Strapi changelog to stay current with the latest features.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.