Written by Stephan Miller✏️
The React team released React 18 in May 2023, which came with better support for React Server Components (RSCs). Less than a year later, RedwoodJS announced support for server-side rendering and RSCs. This is a shift from Redwood's traditional focus on GraphQL, but keeps the idea that it’s supposed to help you go from idea to startup as quick as you can.
In this article, we'll look at what this change means to developing with Redwood, create a simple app to explore RSCs in the framework, and discuss some of the workarounds you'll have to use while Bighorn is still in active development.
RedwoodJS Bighorn and React Server Components
RedwoodJS was already easy to use. I have experimented with it before and was actually considering using it for my side projects, but at the time, it was in alpha. Now that it’s production-ready (though RSCs are not), I’m looking at it again — and I think RSCs will make it even better.
Despite being easy to use, when I first used the framework, I didn’t know GraphQL. Not that it was that hard to pick up, but there was some overhead to juggle that I know I have completely forgotten now.
If I needed to pick up GraphQL again, I would have to climb down the same rabbit hole to use again. With this new version, I won't have to. While Redwood with RSCs is a canary version and not ready for production — and Redwood will continue to support GraphQL — it will be RSC-by-default in the future.
Although it takes some time to wrap your head around RSCs, you're at least part of the way there when you know React.
Getting started with RSCs in RedwoodJS
RedwoodJS makes development easy. I have played with it before. But this time, I had some issues when I went to build and serve the application, so setup wasn't as easy for me as it was the last time. Fortunately, I got it figured out, so you don't have to run into the same issues.
Setting up your development environment
With the current version of RedwoodJS I’m using, which is 8.0.0-canary.496
, you must use Node v20.10.0 or higher. I’m guessing any future version will require that, which I will explain after the install steps.
Once you have a compatible version of Node, run this command:
npx -y create-redwood-app@canary -y redwood_rsc_app
Do not forget this step (which I did initially), or the next few steps will greet you with error Command "rw" not found.
rw
is shorthand for redwood
. Here’s the next command:
yarn install
If you get an error when running the last command, run this:
corepack enable
To use React Server Components with RedwoodJS, you must install these experimental features:
yarn rw experimental setup-streaming-ssr -f
yarn rw experimental setup-rsc
Then run the following commands to build and start the app:
yarn rw build
yarn rw serve
This is the step where I ran into this error:
".../client-build-manifest.json" needs an import assertion of type "json"
It was because I was running Node v20.3.0. I actually tracked the issue down and edited files in my node_modules
folder to fix it, but after some research and experimentation, I found that in Node version 20.10.0, the following type of assert:
import data from './data.json' assert { type: 'json
Changed to this:
import data from './data.json' with { type: 'json
Here are more details on the issue I ran into. Upgrading to Node v20.10.0 and running yarn install
again fixed my issues without editing any files. Here is what you should ultimately get when visiting http://localhost:8910/:
Building a cat memory game with RedwoodJS and RSCs
This example application focuses on using React Server Components in Redwood. If you want to see an example that showcases more of the features of RedwoodJS, check out How to build a full-stack app in RedwoodJS.
To simplify the process and get to using RSCs quicker, I am going to use a project I just built as-is and put the memory game in the homepage. I'm going to use the free Cat as a service (Cataas) API to create the cards: You can check out the source code for the project in this GitHub repo.
Server Cells and Services
In traditional Redwood development, Cells had a QUERY function that contained the GraphQL query to fetch data from the database. To create a Server Cell, you export a data()
function instead. The data returned from this function will be passed to the success component.
Here is my Server Cell to get random cat images to use in the cards:
import Board from 'src/components/Board'
import { cats } from 'src/services/cats'
// The only difference between traditional and RSC RedwoodJS development
// Replaces the QUERY function that contained the GraphQL query
export const data = async () => {
return { cats: await cats() }
}
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Empty</div>
export const Failure = ({ error }) => <div>Error: {error.message}</div>
export const Success = ({ cats }) => {
return <Board cats={cats} />
}
To tell you the truth, there is not really much to look at here. In Redwood, a Cell manages data fetching and simplifies how you integrate data into the UI. Each state is handled by a different component.
Here is the cats
service that calls the Cataas API:
const swap = (array, i, j) => {
const temp = array[i]
array[i] = array[j]
array[j] = temp
}
const shuffle = (array) => {
const length = array.length
for (let i = length; i > 0; i--) {
const randomIndex = Math.floor(Math.random() * i)
const currIndex = i - 1
swap(array, currIndex, randomIndex)
}
return array
}
export const cats = async () => {
const res = await fetch('https://cataas.com/api/cats?type=square&limit=12')
const data = await res.json()
const doubled = data.flatMap((i) => [i, i])
const shuffled = shuffle(doubled)
return shuffled
}
All this does is call the API, double the results to have two of each cat, shuffle the array, and return the results to the cell. In traditional RedwoodJS with GraphQL, you put your services in the api
project folder. You don't even have that with RSC. Instead, it’s in the web/src/services/cats.ts
folder.
So, we’ve seen two minor differences so far in structuring your projects, but ultimately, it’s pretty easy to get started. Now, if you are new to React Server Components, like I am, repeat to yourself while you are writing interactive code:
- Server Components never re-render
- Don't use state in Server Components
Working with Server Components requires thinking about your project differently. If you’re used to writing frontend React, it may take a while to get it right. I refactored my <Board />
component two times before I got it right. Fortunately, there are many ways to do the same thing.
I knew I needed state to keep track of found cards, clicked cards, and tries. I kept this in mind, but wrote the game logic in the board component while it still was a Server Component just to work it out. I got this error:
React.useState is not a function or its return value is not iterable
But I expected something, so I refactored.
I made the <Board />
component a Client Component by adding use client
to the top, but left the onClick
prop on the <Card />
component. After this change, there were no errors, but also no click events registering. So I refactored again.
Here is the final refactor:
'use client'
import { useEffect } from 'react'
import Card from 'src/components/Card'
const Board = ({ cats }) => {
const [chosenCards, setChosenCards] = React.useState([])
const [foundCards, setFoundCards] = React.useState([])
const [tries, setTries] = React.useState(0)
const timeout = React.useRef(null)
// Check card choices
useEffect(() => {
if (chosenCards.length === 2) {
setTimeout(checkCards, 1000)
}
}, [chosenCards])
// Notify user of win
useEffect(() => {
if (foundCards.length === cats.length / 2) {
alert(`You won in ${tries} tries`)
}
}, [foundCards, cats, tries])
const checkCards = () => {
const [first, second] = chosenCards
if (cats[first]._id === cats[second]._id) {
setFoundCards((prev) => [...prev, cats[first]._id])
setChosenCards([])
return
}
timeout.current = setTimeout(() => {
setChosenCards([])
}, 1000)
}
const handleCardClick = (index) => {
if (foundCards.includes(cats[index]._id)) return
if (chosenCards.length === 1 && chosenCards[0] !== index) {
setChosenCards((prev) => [...prev, index])
setTries((tries) => tries + 1)
} else {
clearTimeout(timeout.current)
setChosenCards([index])
}
}
const isFlipped = (index) => {
return chosenCards.includes(index)
}
const isFound = (_id) => {
return Boolean(foundCards.includes(_id))
}
return (
<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
flexWrap: 'wrap',
gap: '20px',
margin: '20px',
}}
>
{cats.map((cat, id) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div key={cat._id} onClick={() => handleCardClick(id)}>
<Card
image={
isFlipped(id) || isFound(cat._id)
? `https://cataas.com/cat?_id=${cat._id}`
: 'https://d33wubrfki0l68.cloudfront.net/72b0d56596a981835c18946d6c4f8a968b08e694/82254/images/logo.svg'
}
/>
</div>
))}
</div>
)
}
export default Board
In the version that finally worked correctly, I wrapped the <Card />
in another <div />
that would handle the click events, and it only has an image
prop now:
const Card = ({ image }) => {
return (
<div
style={{
width: '240px',
height: '240px',
borderRadius: '10px',
boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<img
src={image}
alt="cat"
style={{ objectFit: 'cover', width: '200px', height: '200px' }}
/>
</div>
)
}
export default Card
Using RSCs makes you think more about how you structure your components. You need to decide which components will essentially be static and which components the user will interact with.
But you can nest Server Components inside of Client Components. In this project, the only Client Component is <Board />
because it uses state and its children (<Card />
components) are Server Components.
Check out the code for this project in this repo.
Current caveats to using RSCs in RedwoodJS
If you are used to developing with RedwoodJS, there are some workarounds you'll need to use with RSCs. Some or all of these may not be needed once Bighorn becomes official.
You can't use the dev server yet
For now, build and serve every time when you make changes. So:
- Make some changes.
- Hit
Ctrl c
to stop the server. - Run
yarn rw build && yarn rw serve
again to see your changes.
Use a hash #
when replacing the current url
Previously in RedwoodJS, you could modify the current URL without reloading the page by using { replace: true }
with navigate()
. This would change the address in history without server interaction.
But in the new canary version and Cambium RSC, this triggers a server call, redrawing the full page. This could cause transitions to trigger and page flicker with every change.
To avoid this, use a hash #
instead of a question mark ?
:
// classic RedwoodJS
http://localhost:8910/products/shoes?color=red&size=10
// RedwoodJS with RSC
http://localhost:8910/products/shoes#color=red&size=10
Manually edit the routing entries.ts
file
Currently, there will be a /web/src/entries.ts
created in your project:
import { defineEntries } from '@redwoodjs/vite/entries'
export default defineEntries(
// getEntry
async (id) => {
switch (id) {
case 'AboutPage':
return import('./pages/AboutPage/AboutPage')
case 'HomePage':
return import('./pages/HomePage/HomePage')
case 'ServerEntry':
return import('./entry.server')
default:
return null
}
}
)
Any route you define in the router (web/src/Routes.tsx
) will need its own entry there:
import { Router, Route, Set } from '@redwoodjs/router'
import NavigationLayout from 'src/layouts/NavigationLayout'
import NotFoundPage from 'src/pages/NotFoundPage'
const Routes = () => {
return (
<Router>
<Set wrap={NavigationLayout}>
<Route path="/" page={HomePage} name="home" />
<Route path="/about" page={AboutPage} name="about" />
</Set>
<Route notfound page={NotFoundPage} />
</Router>
)
}
export default Routes
In the future, /web/src/entries.ts
will be gone and the framework will handle this part for you.
Add <Metadata>
to client components only
Redwood's <Metadata>
component relies on React Context, which uses state — and state is restricted to client-side components. So when you use this component to add meta tags in the head section of your pages, make sure you use it inside a component marked with use client
.
No access to core Node vars
Even though Redwood renders components and services on the server, you won't be able to use variables like __filename
and __dirname
. Redwood still uses the CJS format for modules, but is being upgraded to ESM.
For now, use path.resolve()
to construct the full fill path relative to its execution location, not its original source.
No Server Actions yet
Redwood Bighorn's canary provides a glimpse of its data fetching capabilities, but is currently "read-only". Don't worry if data modification (mutations) aren't available yet — Server Actions, which unlock this functionality, are on the way.
No Server Cells in Layouts yet
RedwoodJS offers Server Cells for building dynamic UI elements. But nesting them directly in Layouts isn't fully supported yet, so you may run into bugginess if you try. The Redwood team is working on a solution for this.
Rendering server components in client components
In Redwood, you can mark components for client-side rendering with use client
, but you can also nest server components within client components…if you do it right. If you do it wrong, the server components will be rendered in the browser as well, which overrides the whole reason for using RSC.
To do it the right way, isolate the client-side logic — like theme management — in a separate client component. This component can then act as a parent to your server components, allowing them to be imported as children
and rendered on the server as expected.
RedwoodJS vs. other RSC-compatible frameworks
RedwoodJS isn't the only framework that uses RSCs. It's not even the first. Here are some other frameworks that support React Server Components:
- Next.js: This is probably the most well-known framework using RSCs because it's selling point is its server-side rendering capabilities. It is also the only one mentioned in React documentation. Next.js also provides a variety of server rendering strategies, including static (SSG), incremental (ISR), and dynamic (SSR) rendering
- Waku: Waku is a minimal React framework designed for small- to medium-sized projects. It was built from the ground up to use RSCs. However, Waku is still in active development, so features may be missing. It’s not recommended for production applications yet, but they are working toward a stable release
- Gatsby: Gatsby is traditionally known for its static site generation capabilities and React integration, but you can also use SSR. When you do, Gatsby uses React Server Components when you need to generate HTML dynamically
In summary, here’s how RedwoodJS compares to Next.js, Waku, and Gatsby:
RedwoodJS | Next.js | Waku | Gatsby | |
---|---|---|---|---|
Project recommendations | Any size of application from solo projects to enterprise. | Small and medium-sized | Small and medium-sized. Not for enterprise. | Ideal for static websites or project prioritizing SEO and performance. |
Ready for production? | Traditional development is but RSCs aren't just yet | Yes | Not yet | Yes |
Community and ecosystem | Growing community with active development. | Largest community in the list with many resources. | Relatively new framework with a small but growing community. | Large community and extensive ecosystem focused on static web development. |
Conclusion
The RedwoodJS team has some work to do before RSCs are ready for productions apps, but they are moving fast. They still need to:
- Get SSR working with RSC so the initial page load contains pre-rendered HTML
- Complete Server Actions so you can save data to your database
- Modify auth to work with RSCs
- Update generators to create RSC or GraphQL templates dynamically
But once that's done, RedwoodJS could be a game-changer for building React applications that are not only quick at loading, but quick and easy to develop.
Are you adding new JS libraries to build new features or improve performance? What if they’re doing the opposite?
There’s no doubt that frontends are getting more complex. As you add new JavaScript libraries and other dependencies to your app, you’ll need more visibility to ensure your users don’t run into unknown issues.
LogRocket is a frontend application monitoring solution that lets you replay JavaScript errors as if they happened in your own browser so you can react to bugs more effectively.
LogRocket works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Build confidently — start monitoring for free.
Top comments (0)