React Router v7 (Framework Usage) Practical Guide: Learning Server-Side and Client-Side Rendering by Building a Blog Site
Introduction
In the previous article, we introduced the routing features of React Router v7 as a React framework while building a blog site.
This article is a continuation of that one.
We’ll introduce features that make full use of the framework, such as server-side and client-side rendering.
What is React Router?
React Router is a routing library for React applications. Routing is the mechanism that controls which component is displayed when a user accesses a specific URL.
Starting from v7, React Router can be used either to the fullest as a React framework, or minimally as a library with its own architecture.
Goal of this article
We’ll use a sample server called JSON Placeholder, which returns json, to show how to fetch post data on both the server side and the client side.
We’ll also implement and explain how to display a spinner while processing when the response is slow.
Setup
For the basic setup of libraries such as React, Vite, and React Router v7, please refer to the previous article.
Server-side rendering
Here we’ll configure server-side rendering.
That said, the configuration is actually already in place.
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
At the project creation stage, ssr: true is already set, so server-side rendering is available.
JSON Placeholder
In the previous article, we stored blog data in a file and accessed it from there.
This time, to make it feel a bit more “real”, we’ll fetch sample blog data via an API from a site called JSON Placeholder and run our tests.
As shown on the top page, you can fetch data with the following command (no user registration or authentication required).
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(response => response.json())
.then(json => console.log(json))
Response result:
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
In this example, we’re only fetching the data with id: 1 from todos.
Server-side rendering
Now we’ll fetch data from JSON Placeholder on the server side and complete the rendering on the server before displaying it on the screen.
This time, we’ll display a list of posts on the top page.
type Post = {
userId: number;
id: number;
title: string;
body: string;
}
export const fetchPosts = async (): Promise<Post[]> => {
console.log('fetch')
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
return await response.json()
}
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
This is the process that fetches the list of posts from JSON Placeholder.
type Post = {
userId: number;
id: number;
title: string;
body: string;
}
We define a type so that it’s easier to work with the data.
As for how the type was defined: I actually fetched the data and checked it…
console.log('fetch')
To make it easy to see where this function is running, we output the string fetch every time it executes.
We’ll now modify the top page so it displays the list of posts.
- import { posts } from '~/const/posts'
import type { Route } from './+types/home'
import { Link } from 'react-router'
import { HomeNavigation } from '~/components/home-navigation'
+ import { fetchPosts } from '~/lib/data'
export function meta({}: Route.MetaArgs) {
return [
{ title: 'New React Router App' },
{ name: 'description', content: 'Welcome to React Router!' },
]
}
+ export async function loader() {
+ const posts = await fetchPosts()
+ return posts
+ }
export default function Home({
+ loaderData,
+ }: Route.ComponentProps) {
+ const posts = loaderData;
return (
<>
<HomeNavigation />
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link to={`/post/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
</>
)
}
A route loader provides data to a route component before that route component is rendered. During server rendering, it is called on the server.
export async function loader() {
const posts = await fetchPosts()
return posts
}
When receiving the fetched data in the component, we use loaderData.
export default function Home({
loaderData,
}: Route.ComponentProps) {
const posts = loaderData;
return (
<>
<HomeNavigation />
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link to={`/post/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
</>
)
}
Since we defined the type, the return value of loader() is Promise<Post[]>.
After that, loaderData is Post[], so we can render it as is.
Let’s check that it actually displays on the screen.
If you check the console where you ran npm run dev, you should see fetch printed. This confirms that the fetch process ran on the server side.
We’ve confirmed that data fetching is done on the server side, but now let’s check whether rendering is also being done on the server side.
We’ll add a timer to the data-fetching process.
When fetchPosts is called, it will wait 3 seconds before executing.
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
export const fetchPosts = async (): Promise<Post[]> => {
+ await sleep(3000)
console.log('fetch')
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
return await response.json()
}
If you run it, you’ll notice that nothing happens for a while after clicking “Top”.
The behavior is: the server waits 3 seconds, then performs data loading and rendering, and once that’s complete, it returns the response and the browser displays it.
(It’s a bit hard to see exactly when the click happens in this video…)
Here we intentionally waited 3 seconds, but in real development projects you’ll likely encounter processes with slow responses.
Showing nothing during that time is not good from a UI perspective, so we’ll display a spinner while the server is doing the rendering.
First, create a spinner component.
export const Spinner = () => {
return (
<div
className="flex h-screen items-center justify-center"
aria-label="Loading"
>
<div className="h-10 w-10 animate-spin rounded-full border-4 border-blue-500 border-t-transparent"></div>
</div>
)
}
Next, we’ll modify the data-fetching process.
export const fetchPosts = (): Promise<Post[]> => {
console.log('fetch')
return new Promise((resolve) =>
setTimeout(() => {
fetch('https://jsonplaceholder.typicode.com/posts')
.then((response) => response.json())
.then((data) => resolve(data))
}, 3000),
)
}
fetchPosts itself is now just a function that returns a Promise.
On top of that, we’ll modify the top page component so that it performs the actual processing there.
+ import { Suspense } from 'react'
import type { Route } from './+types/home'
- import { Link } from 'react-router'
+ import { Await, Link } from 'react-router'
import { HomeNavigation } from '~/components/home-navigation'
import { fetchPosts } from '~/lib/data'
+ import { Spinner } from '~/components/spinner'
export function meta({}: Route.MetaArgs) {
return [
{ title: 'New React Router App' },
{ name: 'description', content: 'Welcome to React Router!' },
]
}
export async function loader() {
- const posts = await fetchPosts()
+ const data = fetchPosts()
return { data }
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { data } = loaderData
return (
<>
<HomeNavigation />
+ <Suspense fallback={<Spinner />}>
+ <Await resolve={data}>
+ {(value) => {
+ return (
<ul>
+ {value.map((post) => (
<li key={post.id}>
<Link to={`/post/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
)
+ }}
+ </Await>
+ </Suspense>
</>
)
}
We remove await from the route loader. It still returns the Promise as is.
export async function loader() {
const data = fetchPosts()
return { data }
}
You can see that the type of loaderData is now Promise.
<Suspense fallback={<Spinner />}>
<Await resolve={data}>
{(value) => {
return (
<ul>
{value.map((post) => (
<li key={post.id}>
<Link to={`/post/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
)
}}
</Await>
</Suspense>
Using React’s Suspense, we render the Spinner component while the data is being fetched.
We also execute data here, and once the result is returned, we render it.
The data-fetching process may feel a bit complex, but by configuring it this way, we can display a spinner while waiting for the rendering result from the server.
Client-side rendering (CSR)
Performing data loading and rendering on the server side for the initial display can provide a more stable response, but depending on various conditions, there will also be cases where you render on the client side.
You can disable server-side rendering with the following setting.
If you’re migrating from an existing SPA and must use CSR, it’s a good idea to configure this.
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
} satisfies Config;
Client data loading
We’ll implement the process that fetches data from JSON Placeholder on the client.
- export async function loader() {
+ export async function clientLoader() {
const data = fetchPosts()
return { data }
}
Basically, using clientLoader means data fetching will be done on the client side.
If you check the browser console log, you’ll see the string fetch, confirming that the fetch is happening in the browser.
HydrateFallback
As mentioned in the console log earlier, when using clientLoader, it’s recommended to use HydrateFallback.
This component is a mechanism for displaying an alternative component during the client-side hydration process.
Concretely, there is a process where server-side pre-rendered content (HTML) is “handed over” to React on the client side (hydration). Until this hydration is complete, some parts of the UI may not be interactive. HydrateFallback is used to provide some visual feedback to the user during this time.
We define HydrateFallback alongside the Home component (at the same level).
+ export function HydrateFallback() {
+ return <div>Loading...</div>;
+ }
export default function Home({ loaderData }: Route.ComponentProps) {
const { data } = loaderData
return (
<>
<HomeNavigation />
<Suspense fallback={<Spinner />}>
<Await resolve={data}>
{(value) => {
return (
<ul>
If you reload the page, you can see the text Loading... appear for just a brief moment.
You can also set the hydrate property on clientLoader.
If you set this property to true, you can force the client loader to run during hydration and before the page is rendered.
+ clientLoader.hydrate = true as const
However, in this test environment, hydration completes very quickly, so setting this doesn’t make a noticeable difference in performance.
Conclusion
We’ve explained how to implement server-side rendering and client-side rendering with React Router v7.
In particular, we saw that by focusing on the following points, we can provide a better user experience:
- Stable responses via server-side rendering
- Showing a loading state using Suspense and a spinner
- Smooth screen transitions with HydrateFallback
- Choosing client-side rendering when appropriate
React Router v7 offers flexible implementation options, allowing you to choose the optimal rendering method based on your project’s requirements.
Use the implementation patterns introduced in this article as a reference when considering the best rendering approach for your own projects.
In the next article, we plan to dive deeper into other features of React Router v7. Stay tuned.
Questions about this article 📝
If you have any questions or feedback about the content, please feel free to contact us.Go to inquiry form
Related Articles
React Router v7 (Framework Usage) Practical Guide: Learn the Latest Routing by Building a Blog Site
2025/01/23Guide to Building a Blog Site Using React Router v7 (Library Usage)
2025/01/20Vite Introduction to Accelerate React Development: A Fast and Flexible Project Setup Guide
2025/01/17Practical Schema-Driven Development: Efficient API Design with React × Express × GraphQL
2024/10/12Frontend Test Automation Strategy: Optimizing Unit, E2E, and API Tests with Jest, Playwright, and MSW
2024/01/21









