React Router v7 (Framework Usage) Practical Guide: Learning Server-Side and Client-Side Rendering by Building a Blog Site

  • vite
    vite
  • remix
    remix
Published on 2025/01/23

Introduction

In the previous article, we introduced the routing features of React Router v7 as a React framework while building a blog site.

https://shinagawa-web.com/en/blogs/react-router-v7-framework-guide-blog-site-routing-example

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.

https://reactrouter.com/home

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.

Image from Gyazo

Setup

For the basic setup of libraries such as React, Vite, and React Router v7, please refer to the previous article.

https://shinagawa-web.com/en/blogs/react-router-v7-framework-guide-blog-site-routing-example

Server-side rendering

Here we’ll configure server-side rendering.
That said, the configuration is actually already in place.

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

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.

https://jsonplaceholder.typicode.com/

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.

app/lib/data.ts
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…

Image from Gyazo

  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.

routes/home.tsx
- 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[]>.

Image from Gyazo

After that, loaderData is Post[], so we can render it as is.

Image from Gyazo

Let’s check that it actually displays on the screen.

Image from Gyazo

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.

Image from Gyazo

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.

app/lib/data.ts
+ 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.

Image from Gyazo

(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.

app/components/spinner.tsx
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.

app/lib/data.ts
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.

routes/home.tsx
+ 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.

Image from Gyazo

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

Image from Gyazo

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.

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

routes/home.tsx
- 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.

Image from Gyazo

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).

routes/home.tsx
+ 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.

Image from Gyazo

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.

routes/home.tsx
+ 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.

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form