✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem14 min read

How to Build a React Server Components Site with Strapi 5

April 30, 2026Updated on June 14, 2026
How to Build a React Server Components

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-rsc makes 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.

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 cms

Note: 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 develop

Open 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 rsc

This 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:

  • rsc runs your Server Components, discovers 'use client' boundaries, and produces the RSC payload stream.
  • ssr handles server-side rendering of the full component tree including the hydration shell. It runs last in the build pipeline.
  • client is 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_here

Secrets 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]=name

The 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=3600

This 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-renderer
import { 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.glob to 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-Control headers 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.

Paul BratslavskyDeveloper Advocate

Related Posts

Client-Side Rendering vs Server-Side Rendering
Web Development·12 min read

Client-Side Rendering vs Server-Side Rendering: Key Differences and Use Cases for Developers

Web rendering can make or break your website's success. As users expect more interactive and responsive experiences,...

·January 12, 2025
Server-Side Rendering in Next.js
Ecosystem·22 min read

Server-Side Rendering in Next.js: How It Works & When to Use It

ServerSide Rendering (SSR) in Next.js 15 is a powerful technique that builds webpages on the server before they reach...

·October 10, 2025
Vite vs Next.js
Ecosystem·11 min read

Vite vs Next.js: 2025 Developer Framework Comparison

You're staring at the blank terminal, fingers hovering over npm create. Pick Vite and you get lightningfast...

·September 3, 2025