DEV Community

Cover image for Server-side rendering with React Router v7
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

Server-side rendering with React Router v7

Written by Amazing Enyichi Agu✏️

React Router has long been a popular routing solution for SPAs, developed by the team behind Remix. Incremental improvements to the routing library brought React Router and Remix closer in functionality, leading to their eventual merger into React Router v7. With this recent release, React Router can be used as either a routing library or a full-stack framework, incorporating the entire functionality of Remix. It also includes React v19 as a dependency.

This article demonstrates how to build an SSR application with React Router v7 by creating a book tracking app using tools like Radix Primitives, React Icons, and Tailwind CSS. Prior knowledge of React.js, TypeScript, and basic data fetching concepts like actions and loaders is helpful but not required. The final project source code can be found here.

How to set up the React Router framework

Node.js v20 is the minimum requirement for running React Router, so make sure your device runs that version or something higher:

node --version
Enter fullscreen mode Exit fullscreen mode

Next, install the React Router framework by running npm create vite. “React Router v7” is available as one of the options under React Vite templates. Selecting this option will redirect you to the React Router framework CLI to complete the installation.

For that reason, this tutorial will go straight to using the React Router CLI. Here, the title of the example project is react-router-ssr. Open your terminal and run the following command:

npx create-react-router@latest react-router-ssr
Enter fullscreen mode Exit fullscreen mode

The CLI will ask if you want to initialize a git repo for the project. Check “yes” if you want that. It will also ask if you want to install the dependencies using npm. Here are both options checked: CLI Dependencies This will create a folder with whatever you named your project. Change into that directory, then start the development server of the application:

cd react-router-ssr
npm run dev
Enter fullscreen mode Exit fullscreen mode

After that, open your browser and visit the URL http://localhost:5173, where you should be greeted with a homepage that looks like this: React Router Homepage With that, you have successfully installed the React Router Framework.

Since this tutorial doesn’t involve deploying the app with Docker, you can safely remove all Docker-related files from the source code for a cleaner codebase. These files — .dockerignore, Dockerfile, Dockerfile.bun, and Dockerfile.pnpm — are included in the template for cases where Docker deployment is needed.

How to build SSR pages

In order to use React Router v7 for SSR, make sure ssr is set to true in the React Router configuration file. It is set to true by default. Open the react-router.config.ts file in your code editor to confirm:

//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;
Enter fullscreen mode Exit fullscreen mode

This tutorial uses a “light mode” theme for the app so you need to disable the dark mode in Tailwind CSS. Open the app/app.css file and comment out all the “dark mode” styles:

// routes/app.css

@tailwind base;
@tailwind components;
@tailwind utilities;

html,
body {
  /* @apply bg-white dark:bg-gray-950; */
  @media (prefers-color-scheme: dark) {
    /* color-scheme: dark; */
  }
}
Enter fullscreen mode Exit fullscreen mode

After that, you’ll create your first SSR page. You will define all your routes (route modules) inside the app/routes/ folder, but home.tsx will serve as the first page. There are also going to be other routes that use it as a frame. Create the file app/routes/home.tsx.

Inside app/routes/home.tsx, export the <Home /> component that contains the following:

// app/routes/home.tsx

import { Outlet } from 'react-router';
import { Fragment } from 'react/jsx-runtime';
import Header from '~/components/Header';
import Footer from '~/components/Footer';

export default function Home() {
  return (
    <Fragment>
      <Header />
      <main className='max-w-screen-lg mx-auto my-4'>
        <Outlet />
      </main>
      <Footer />
    </Fragment>
  );
}
Enter fullscreen mode Exit fullscreen mode

The file imports two React components you will create later (<Header /> and <Footer />) and the <Outlet /> component from React Router. <Outlet /> renders the components of any nested route that uses home.tsx as its layout.

To display something on the page, you'll need to create the imported custom components. Start by modifying the app/welcome folder that comes with the template:

  • Either delete the app/welcome folder and create a new folder named app/components,
  • Or rename the welcome folder to components and delete all the files inside it

Next, in the app/components folder, create two new files: Header.tsx and Footer.tsx.

The <Header /> component will display a <header> that will persist for most of the app. Here is the code for it:

// app/components/Header.tsx

import { Link } from 'react-router';
import BookForm from './BookForm';

export default function Header() {
  return (
    <header className='flex justify-between items-center px-8 py-4'>
      <h1 className='text-3xl font-medium'>
        <Link to='/'>Book Tracker App</Link>
      </h1>
      <BookForm />
    </header>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Header.tsx file imported <Link /> from React Router, which is an optimized navigator — <a> tag — for the framework. It also imported a component <BookForm /> that does not exist yet. Finally, the file added some Tailwind CSS styles so that the HTML elements look good on a page.

Next, create the <BookForm /> component. But for that, you first need to install Radix’s headless dialog component. You will eventually use it to create a dialog form for adding a new book to track. This is also a good time to install React Icons as you will need it for some components later on:

npm install @radix-ui/react-dialog react-icons
Enter fullscreen mode Exit fullscreen mode

When the packages are installed, create a new file inside the app/components folder called BookForm.tsx:

// app/components/BookForm.tsx

import { useState } from 'react';
import { Form } from 'react-router';
import * as Dialog from '@radix-ui/react-dialog';
import Button from './Button';

export default function BookForm() {
  const [isOpen, setIsOpen] = useState<boolean>(false);

  return (
    <Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
      <Dialog.Trigger asChild>
        <Button>Add Book</Button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className='bg-black/50 fixed inset-0' />
        <Dialog.Content className='bg-white fixed top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 px-8 py-4 w-5/6 max-w-sm'>
          <Dialog.Title className='font-medium text-xl py-2'>
            Add New Book
          </Dialog.Title>
          <Dialog.Description>Start tracking a new book</Dialog.Description>
          <Form
            method='post'
            onSubmit={() => setIsOpen(false)}
            action='/?index'
            className='mt-2'
          >
            <div>
              <label htmlFor='title'>Book Title</label>
              <br />
              <input
                name='title'
                type='text'
                className='border border-black'
                id='title'
                required
              />
            </div>
            <div>
              <label htmlFor='author'>Author</label>
              <br />
              <input
                name='author'
                type='text'
                id='author'
                className='border border-black'
                required
              />
            </div>
            <div>
              <label htmlFor='isbn'>ISBN (Optional)</label>
              <br />
              <input
                name='isbn'
                type='text'
                id='isbn'
                className='border border-black'
              />
            </div>
            <div className='mt-4 text-right'>
              <Dialog.Close asChild>
                <Button variant='cancel'>Cancel</Button>
              </Dialog.Close>
              <Button type='submit'>Save</Button>
            </div>
          </Form>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}
Enter fullscreen mode Exit fullscreen mode

The BookForm.tsx component used React’s useState to control the dialog box and Tailwind CSS to style everything. Notice that, in turn, the file imported a component <Button /> that does not exist yet. Next, create the <Button /> component:

// app/components/Button.tsx

import type { ComponentProps, ReactNode } from 'react';

interface Props extends ComponentProps<'button'> {
  children?: ReactNode;
  variant?: 'cancel' | 'delete' | 'normal';
}

export default function Button({
  children,
  variant = 'normal',
  ...otherProps
}: Props) {
  const variantStyles: Record<NonNullable<typeof variant>, string> = {
    cancel: 'text-red-700',
    normal: 'text-white bg-purple-700 hover:bg-purple-800',
    delete: 'text-white bg-red-700 hover:bg-red-800',
  };
  return (
    <button
      className={`rounded-full px-4 py-2 text-center text-sm ${variantStyles[variant]}`}
      {...otherProps}
    >
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

As seen in this button component, it accepts some props like children and variant. It also has three variants (cancel, normal, and delete) with their own unique styling. Finally, for the home.tsx route, create the <Footer /> component:

// app/components/Footer.tsx

import { Link } from 'react-router';

export default function Footer() {
  return (
    <footer className='text-center my-5'>
      <Link to='/about' className='text-purple-700'>
        About the App
      </Link>
    </footer>
  );
}
Enter fullscreen mode Exit fullscreen mode

With that, you should have a basic structure for your app up and running: Book Tracker App Basic Structure

Static site generation in React Router v7

SSR can be roughly divided into two techniques: dynamic site generation, which is when the server generates pages for every individual request, and static site generation (SSG), which is when pages are already generated and stored on the server. For SSG pages, the content on the page is the same (static) no matter who requests it. Dynamic SSR uses server-side logic to generate pages when requested. The server sends the markup for those pages to the client side (browser) where they are subsequently hydrated. However, in static sites, all the files necessary for a page (HTML, CSS, JavaScript) are generated at build time. They are then sent to the client more quickly as there is no need for the server to generate them dynamically. There are upsides and downsides to using any of these approaches. A good rule of thumb is to use SSG when you want all the users to see the same thing (for example blog posts, contact, and About pages) and that page does not need frequent updates. On the other hand, if it is a page where the content frequently changes, or where different users need to access different resources unique to them, then dynamic SSR is the way to go. It is also worth noting that SSG pages are easy to deploy as they can be served using a CDN. For the example project, the /about route is going to be generated with SSG. React Router v7 lets developers build an application that combines these two techniques of rendering in one app if they want to. Open the React Router config and set up routes to pre-render (or statically generate). In this case, the app will only pre-render the /about route (or page):

// 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,
  async prerender() {
    return ['about'];
  },
} satisfies Config;
Enter fullscreen mode Exit fullscreen mode

Create the app/routes/about.tsx file. It will contain static content that will be in the About page:

// app/routes/about.tsx

import { Fragment } from 'react/jsx-runtime';
import { Link } from 'react-router';

export default function About() {
  return (
    <Fragment>
      <h1 className='px-8 py-4 text-3xl font-medium'>
        <Link to='/'>Book Tracker App</Link>
      </h1>
      <main className='max-w-screen-lg mx-auto my-4'>
        <p className='mb-2 mx-5'>
          This app was built for readers who love the simplicity of tracking
          what theyve read and what they want to read next. With just the
          essentials, its designed to keep your reading list organized without
          the distractions of unnecessary features.
        </p>
        <p className='mb-2 mx-5'>
          We believe the joy of reading should stay front and center. Whether
          its noting down the books youve finished or keeping a simple list of
          whats next, this app focuses on helping you stay connected to your
          reading journey in the most straightforward way possible.
        </p>
        <p className='mb-2 mx-5'>
          Sometimes less is more, and thats the philosophy behind this app. By
          keeping things minimal, it offers a clean and easy way to manage your
          reading habits so you can spend less time tracking and more time
          diving into your next great book.
        </p>
      </main>
    </Fragment>
  );
}
Enter fullscreen mode Exit fullscreen mode

Routing with React Router

This section will explain how to configure routing in the React Router framework. Before viewing the About page you just created on the browser, you need to configure React Router to display that route module (about.tsx) whenever a visitor navigates to /about. This configuration happens in app/routes.ts. The file is where one lays out the entire hierarchy of the routes in their app:

// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';

export default [
  index('routes/home.tsx'),
  route('about', 'routes/about.tsx'),
] satisfies RouteConfig;
Enter fullscreen mode Exit fullscreen mode

What the above instructions do is import the route function from React Router. The first argument of route is the URL to match and the second argument is the route module to display when that URL is matched. With all of that, you should now be able to navigate to the static About page: Static About Page Run npm run build on the terminal when you want to build your app — to bundle the app and generate the static About page inside a build/ folder. But the /home and /about routes are not the only routes the example app will have. Set up the routing for the entire application:

// app/routes.ts
import {
  type RouteConfig,
  index,
  route,
  layout,
} from '@react-router/dev/routes';

export default [
  layout('routes/home.tsx', [
    index('routes/book-list.tsx'),
    route('book/:bookId', 'routes/book.tsx'),
  ]),
  route('about', 'routes/about.tsx'),
] satisfies RouteConfig;
Enter fullscreen mode Exit fullscreen mode

As you can see here, the routes make use of a layout function that has two arguments:

  • The location of a template route module
  • An array of its nested routes

Whenever the user navigates to any of the nested routes, React Router displays the parent layout route first. After that, it takes advantage of <Outlet /> component to fill in data unique to the route the user navigated to.

Fetching and loading data in SSR routes

loader functions are a unique concept in React Router. They are functions exported from route modules that return data necessary for a route to render. They are also only supposed to be used on route modules and nowhere else. In the example app, you create the route that lists all the available books a user is tracking. That new route module will use loaders to fetch whenever the route needs to load (in this case, stored books data). For this, first create a data storage solution, which is merely a JavaScript array for illustration purposes. Create the app/model.ts file:

// app/model.ts

interface Book {
  id: number;
  title: string;
  author: string;
  isFinished: boolean;
  isbn?: string;
  rating?: 1 | 2 | 3 | 4 | 5;
}

interface Data {
  books: Book[];
}

const storage: Data = {
  books: [
    {
      id: 0,
      title: `Numbers Don't Lie: 71 Stories to Help Us Understand the Modern World`,
      author: 'Vaclav Smil',
      isbn: `978-0241454411`,
      isFinished: true,
      rating: 1,
    },
  ],
};
export { type Book, storage };
Enter fullscreen mode Exit fullscreen mode

Book list

Next, create a new route to display all the books in the storage object. To do this, create a route module named book-list.tsx:

// app/routes/book-list.tsx

import type { Route } from './+types/book-list';
import BookCard from '~/components/BookCard';
import { storage } from '~/model';

export async function loader({}: Route.LoaderArgs) {
  return storage;
}

export default function BookList({ loaderData }: Route.ComponentProps) {
  return (
    <div className='mx-5'>
      {loaderData.books
        .slice()
        .reverse()
        .map((book) => (
          <BookCard key={book.id} {...book} />
        ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

As you can see, this route module exports a loader function. Then the route’s main component gets what the loader function returns in loaderData. But before you see the output of these changes, you need to do a few extra things. Create the imported component BookCard that does not exist yet:

// app/components/BookCard.tsx

import { Link } from 'react-router';
import { IoCheckmarkCircle } from 'react-icons/io5';
import type { Book } from '~/model';

export default function BookCard({
  id,
  title,
  author,
  isFinished,
  isbn,
  rating,
}: Book) {
  return (
    <Link
      to={`book/${id}`}
      className='block flex px-5 py-4 max-w-lg mb-2.5 border border-black hover:shadow-md'
    >
      <div className='w-12 shrink-0'>
        {isbn ? (
          <img
            className='w-full h-16'
            src={`https://covers.openlibrary.org/b/isbn/${isbn}-S.jpg`}
            alt={`Cover for ${title}`}
          />
        ) : (
          <span className='w-full h-16 block bg-gray-200'></span>
        )}
      </div>
      <div className='flex flex-col ml-4 grow'>
        <span className='font-medium'>{title}</span>
        <span>{author}</span>
        <div className='flex justify-between'>
          <span>Rating: {rating ? `${rating}/5` : 'None'}</span>
          {isFinished && (
            <span className='flex items-center gap-1'>
              Finished <IoCheckmarkCircle className='text-green-600' />
            </span>
          )}
        </div>
      </div>
    </Link>
  );
}
Enter fullscreen mode Exit fullscreen mode

The <BookCard /> component is a clickable card. It contains the most important info about a book entry like title, author, and possibly a cover, among other things. After that, open the app/routes.tsx file and comment out the other route. This is so that React Router won’t throw errors as there is no route module for that defined route yet:

// app/routes.tsx

...
export default [
  layout('routes/home.tsx', [
    index('routes/book-list.tsx'),
    // route('book/:bookId', 'routes/book.tsx'),
  ]),
  route('about', 'routes/about.tsx'),
] satisfies RouteConfig;
Enter fullscreen mode Exit fullscreen mode

With all of that done, you should have a homepage that reads data from storage in app/model.ts: Homepage That Reads Data From The Storage Object This means that any book added to storage should show up in the book-list.tsx route.

Book details page

Whenever a user clicks on a book card, the app should navigate to a new page that displays details about that book. In order to set this up, first uncomment the route to the /book/:bookId page:

// app/routes.ts

...
export default [
  layout('routes/home.tsx', [
    index('routes/book-list.tsx'),
    route('book/:bookId', 'routes/book.tsx'),
  ]),
  route('about', 'routes/about.tsx'),
] satisfies RouteConfig;
Enter fullscreen mode Exit fullscreen mode

Then, create the associated route module. The file will be app/routes/book.tsx, and it will contain a loader that returns the details of whatever book the user clicks on:

// app/routes/book.tsx

import { useState, type ChangeEvent } from 'react';
import { Link, Form } from 'react-router';
import { IoArrowBackCircle, IoStarOutline, IoStar } from 'react-icons/io5';
import type { Route } from './+types/book';
import Button from '~/components/Button';
import { storage, type Book } from '~/model';

export async function loader({ params }: Route.LoaderArgs) {
  const { bookId } = params;
  const book: Book | undefined = storage.books.find(({ id }) => +bookId === id);
  return book;
}

export default function Book({ loaderData }: Route.ComponentProps) {
  const [isFinished, setIsFinished] = useState<boolean>(
    loaderData?.isFinished || false
  );
  const [rating, setRating] = useState<number>(Number(loaderData?.rating));
  return (
    <div className='mx-5'>
      <Link to='/' className='text-purple-700 flex items-center gap-1 w-fit'>
        <IoArrowBackCircle /> Back to home
      </Link>
      <div className='flex mt-5 max-w-md'>
        <div className='w-48 h-72 shrink-0'>
          {loaderData?.isbn ? (
            <img
              className='w-full h-full'
              src={`https://covers.openlibrary.org/b/isbn/${loaderData.isbn}-L.jpg`}
              alt={`Cover for ${loaderData.title}`}
            />
          ) : (
            <span className='block w-full h-full bg-gray-200'></span>
          )}
        </div>
        <div className='flex flex-col ml-5 grow'>
          <span className='font-medium text-xl'>{loaderData?.title}</span>
          <span>{loaderData?.author}</span>
          <Form method='post'>
            <span className='my-5 block'>
              <input
                type='checkbox'
                name='isFinished'
                id='finished'
                checked={isFinished}
                onChange={(e: ChangeEvent<HTMLInputElement>) =>
                  setIsFinished(e.target.checked)
                }
              />
              <label htmlFor='finished' className='ml-2'>
                Finished
              </label>
            </span>
            <div className='mb-5'>
              <span>Your Rating:</span>
              <span className='text-3xl flex'>
                {[1, 2, 3, 4, 5].map((num) => {
                  return (
                    <span key={num} className='flex'>
                      <input
                        className='hidden'
                        type='radio'
                        name='rating'
                        id={`rating-${num}`}
                        value={num}
                        checked={rating === num}
                        onChange={(e: ChangeEvent<HTMLInputElement>) =>
                          setRating(+e.target.value)
                        }
                      />
                      <label htmlFor={`rating-${num}`}>
                        {num <= rating ? <IoStar /> : <IoStarOutline />}
                      </label>
                    </span>
                  );
                })}
              </span>
            </div>
            <div className='text-right'>
              <Button type='submit'>Save</Button>
              <Button variant='delete' type='button'>
                Delete Book
              </Button>
            </div>
          </Form>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This file contains several key functionalities. First, after the imports, there’s a loader that searches the storage object and retrieves the book object corresponding to the ID in the URL parameters. For instance, if a user navigates to /book/0, the loader will fetch the details of the book with an ID of 0. Additionally, the route module allows users to modify book details. Users can mark whether they’ve finished the book, assign a rating out of five stars, and save their changes. They also have the option to delete the book entirely.

With all of that done, the app should now look like this: Book Tracking App With Basic Loaders Set Now the basic loaders of our entire application are set. It's time to move on to adding and deleting books from the book tracker.

React Router Server Actions

Like loaders, actions can only run in route modules — route modules being files inside the app/routes/ directory. Actions are functions that handle form submissions in a particular route. Actions that are supposed to run on the browser are exported as clientAction while actions that run on the server are exported as action.

The action accepts parameters such as URL params (as params), and submitted data to the route (as request). request here is implemented as an instance of the Request Web API so it works with all of the API’s functionality. These parameters all come from the Route.ActionArgs type that every route module has a unique version of inside .react-router.

The first thing this tutorial will use Server Actions to do is add a new book to storage. Add this action function to the book-list.tsx module:

// app/routes/book-list.tsx
...
export async function action({ request }: Route.ActionArgs) {
  let formData = await request.formData();
  let title = formData.get('title') as string | null;
  let author = formData.get('author') as string | null;
  let isbn = formData.get('isbn') as string | undefined;
  if (title && author) {
    storage.books.push({
      id: storage.books.length,
      title,
      author,
      isbn: isbn || undefined,
      isFinished: false,
    });
  }

  return storage;
}

...
Enter fullscreen mode Exit fullscreen mode

With that function in place, you should be able to add new books to the application: Form Filled With Information For A New Book After filling out the form, the new book should appear on the book-list.tsx route: New Book Added To Book Tracker App The next functionality this tutorial will use Server Actions to do is make sure a user can edit and delete a book entry. To achieve this, add an action to the book.tsx route. This action will update the storage object with new info that belongs to a particular book, and delete a book if the request method to the route is "DELETE":

// app/routes/book.tsx

import { useState, type ChangeEvent } from 'react';
import { Link, Form, redirect, useSubmit } from 'react-router';
import { IoArrowBackCircle, IoStarOutline, IoStar } from 'react-icons/io5';
import type { Route } from './+types/book';
import Button from '~/components/Button';
import { storage, type Book } from '~/model';

export async function action({ params, request }: Route.ActionArgs) {
  let formData = await request.formData();
  let { bookId } = params;
  let newRating = (Number(formData.get('rating')) ||
    undefined) as Book['rating'];
  let isFinished = Boolean(formData.get('isFinished'));
  if (request.method === 'DELETE') {
    storage.books = storage.books.filter(({ id }) => +bookId !== id);
  } else if (newRating && storage.books[+bookId]) {
    Object.assign(storage.books[+bookId], {
      isFinished,
      rating: newRating,
    });
  }
  return redirect('/');
}

export async function loader({ params }: Route.LoaderArgs) {
  const { bookId } = params;
  const book: Book | undefined = storage.books.find(({ id }) => +bookId === id);
  return book;
}

export default function Book({ loaderData }: Route.ComponentProps) {
  const [isFinished, setIsFinished] = useState<boolean>(
    loaderData?.isFinished || false
  );
  const [rating, setRating] = useState<number>(Number(loaderData?.rating));

  const submit = useSubmit();

  function deleteBook(bookId: number | undefined = loaderData?.id) {
    const confirmation = confirm('Are you sure you want to delete this book?');
    confirmation && bookId &&
      submit(
        { id: bookId },
        {
          method: 'delete',
        }
      );
  }

  return (
    <div className='mx-5'>
      <Link to='/' className='text-purple-700 flex items-center gap-1 w-fit'>
        <IoArrowBackCircle /> Back to home
      </Link>
      <div className='flex mt-5 max-w-md'>
        <div className='w-48 h-72 shrink-0'>
          {loaderData?.isbn ? (
            <img
              className='w-full h-full'
              src={`https://covers.openlibrary.org/b/isbn/${loaderData.isbn}-L.jpg`}
              alt={`Cover for ${loaderData.title}`}
            />
          ) : (
            <span className='block w-full h-full bg-gray-200'></span>
          )}
        </div>
        <div className='flex flex-col ml-5 grow'>
          <span className='font-medium text-xl'>{loaderData?.title}</span>
          <span>{loaderData?.author}</span>
          <Form method='post'>
            <span className='my-5 block'>
              <input
                type='checkbox'
                name='isFinished'
                id='finished'
                checked={isFinished}
                onChange={(e: ChangeEvent<HTMLInputElement>) =>
                  setIsFinished(e.target.checked)
                }
              />
              <label htmlFor='finished' className='ml-2'>
                Finished
              </label>
            </span>
            <div className='mb-5'>
              <span>Your Rating:</span>
              <span className='text-3xl flex'>
                {[1, 2, 3, 4, 5].map((num) => {
                  return (
                    <span key={num} className='flex'>
                      <input
                        className='hidden'
                        type='radio'
                        name='rating'
                        id={`rating-${num}`}
                        value={num}
                        checked={rating === num}
                        onChange={(e: ChangeEvent<HTMLInputElement>) =>
                          setRating(+e.target.value)
                        }
                      />
                      <label htmlFor={`rating-${num}`}>
                        {num <= rating ? <IoStar /> : <IoStarOutline />}
                      </label>
                    </span>
                  );
                })}
              </span>
            </div>
            <div className='text-right'>
              <Button type='submit'>Save</Button>
              <Button
                variant='delete'
                type='button'
                onClick={() => deleteBook()}
              >
                Delete Book
              </Button>
            </div>
          </Form>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now, a user should be able to save details for every book entry. They should also be able to delete any book entry from the app (or storage): Saving Book Entry Details And Deleting Them From The App With that, all the basic functionalities of the app are done.

Handling status codes in React Router

Status codes are a property of the responses from a server that shows the status of a client’s request. It can return:

  • 200, which means OK)
  • 201, which means the request was successful and an entry was created
  • 404, which means that a requested server resource was not found
  • And more

In React Router, every requested page returns with a 200 status code, which is a generic way of saying that a request was successful. It also returns a 404 status code when a URL path has no corresponding route module. However, the React Router framework also allows a developer to send custom status codes to the client. Using them makes for an improved and more communicative API for the client. The client gets to know the exact status of their requests.

Using this feature in React Router v6 requires the use of the data function from react-router. The function accepts data to return as the first argument (loaderData or actionData). The second argument is what contains a custom status code for a request.

Modify the app by responding with appropriate status codes. First, return a 201 (Created) when a user creates a new entry:

// app/routes/book-list.tsx

// Imports
import { data } from 'react-router';
...

export async function action({ request }: Route.ActionArgs) {
  let formData = await request.formData();
  let title = formData.get('title') as string | null;
  let author = formData.get('author') as string | null;
  let isbn = formData.get('isbn') as string | undefined;
  if (title && author) {
    storage.books.push({
      id: storage.books.length,
      title,
      author,
      isbn: isbn || undefined,
      isFinished: false,
    });
  }
  return data(storage, { status: 201 });
}

...
Enter fullscreen mode Exit fullscreen mode

Next is to return 404 (Not found) when the user navigates to a book/:bookId route that does not exist:

// app/routes/book.tsx

// Imports
...
import { Link, Form, redirect, useSubmit, data } from 'react-router';
...

// Route module loader
export async function loader({ params }: Route.LoaderArgs) {
  const { bookId } = params;
  const book: Book | undefined = storage.books.find(({ id }) => +bookId === id);

  if (!book) throw data(null, { status: 404 });

  return book;
}
Enter fullscreen mode Exit fullscreen mode

These examples are to illustrate how one can easily add status codes. You can add as many more status codes as you think is appropriate for the routes.

How to add HTML meta info to <head> tag in React Router

The HTML <head> tag is a very important tag for the SEO performance of a web page. The React Router framework allows developers to update the <meta> tags in the <head> tag for as many pages as they want to. These <meta> tags contain the metadata (title, description, keywords, view-port) of a particular page.

For the example project, add meta tags to the pages. Observe how you need to export a function meta in the route modules to do that:

// app/routes/home.tsx

// Imports
...
import type { Route } from './+types/home';
...

export function meta({}: Route.MetaArgs) {
  return [
    { title: 'Book Tracker App' },
    { name: 'description', content: 'Book Tracker Application' },
  ];
}

...
Enter fullscreen mode Exit fullscreen mode

<meta> tags for the About page:

// app/routes/about.tsx

// Imports
...
import type { Route } from './+types/book';
...

export function meta({}: Route.MetaArgs) {
  return [
    { title: 'About Book Tracker App' },
    { name: 'description', content: 'About this Application' },
  ];
}
Enter fullscreen mode Exit fullscreen mode

Finally, here is a <meta> tag for the book.tsx route:

// app/routes/book.tsx

// Imports
...
import type { Route } from './+types/book';
...

export function meta({ data }: Route.MetaArgs) {
  return [{ title: `Edit "${data.title}"` }];
}
Enter fullscreen mode Exit fullscreen mode

Notice the destructured data object, which is an argument for the meta function. Here, data represents whatever the loader of that route returned.

With these changes made, the app should now have updated meta info in the browser’s tab bar.

How to add HTML links to <head> tag in React Router

HTML <link> tags define the relationship between a page and an external resource. It is mostly used to import CSS files and icons. React Router allows developers to add <links> to individual pages. This can be useful for features like adding custom favicons to a route.

In a route module, export a links function:

export function links() {
  return [
    {
      rel: 'icon',
      href: '/favicon.png',
      type: 'image/png',
    },
  ];
}
Enter fullscreen mode Exit fullscreen mode

Inside the function, export an array. Each individual item of the array should be an object that contains properties that are attributes of a <link> tag. The values of those properties should be the values of their corresponding attributes in an HTML <link> tag.

Handling HTTP headers in React Router

HTTP headers in React Router allow the server to pass additional data to the client (along with the requested payload). They are used to send cookies to the browser, set up caching, and much more. You can add headers to your route modules by exporting a headers function. For example:

// Route module

export function headers(){
  return {
    "Content-Disposition": "inline",
    ...
    "Header Name": "Header value"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the client will get the response with your custom set headers.

Comparing React Router v7 to Remix

Instead of releasing Remix v3, the team behind the framework merged Remix with React Router, resulting in React Router v7. With the release of React v19, the official React documentation now recommends using a framework to take full advantage of the new version. It specifically mentions Remix, now integrated as React Router v7, as one of the suggested frameworks for developers.

Despite this integration, there are notable differences between React Router v7 and Remix beyond one simply being the latest major version of the other. Here are a few of those differences:

  • Routing configuration: The Remix framework works with file-based routing by default. Developers could still use custom routing methods — like a config file — however, they would have to install a plugin. On the other hand, in React Router v7, the default routing method is a configuration of routes inside the app/routes.ts file
  • Data loading: Pulling loader data into a route module used to be done using the useLoaderData() Hook in Remix. Action data was also received using the useActionData() Hook. While you can still do this in React Router v7, the framework recommends instead using the route’s component props for both loader and actions. The Route.ComponentProps type is an object that contains loaderData and actionData you can destructure and use inside your route components. This is surely an improvement as it ensures better type safety in applications
  • SSG functionality: Remix v2 supported dynamic server-side rendering (SSR) but lacked functionality for static site generation (SSG). At the time, the team explained that they didn’t recommend SSG due to its tradeoffs, even though nearly every other React SSR framework offered it. However, this changed with the latest version, which now includes support for SSG. It seems peer pressure still has its influence
  • Type safety: This is a huge improvement in React Router v7 over Remix. The development server generates types for every route module. These types are found inside the .react-router/ folder. Because of this, there are generated types for a Route’s component props, loader arguments, action arguments, meta-function arguments, and so much more. This enhances the type safety of an app’s source code tremendously
  • Developer experience: Overall, React Router v7 has an improved developer experience compared to Remix. It is also intuitive, which makes for a less confusing process when working with it

There are other differences between the two frameworks apart from the ones listed above. However, the React Router framework is definitely an improvement over the Remix framework.

Wrapping up

This article explores server-side rendering (SSR) with React Router v7, which combines React Router and Remix into a full-stack framework for building modern SSR and static site generation (SSG) applications. We demonstrated these concepts by creating a book tracking app, and highlighting improvements in developer experience, type safety, and React v19 features, while comparing React Router v7 to Remix.

By following this guide, developers can learn to implement SSR, SSG, and advanced functionalities like loaders, actions, and meta tags in React applications.

The final code for the example project can be found here.


Get set up with LogRocket's modern React error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.

NPM:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
Enter fullscreen mode Exit fullscreen mode

Script Tag:

Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now

Top comments (1)

Collapse
 
codewander profile image
Kanishka

Why adopt SSR now, as opposed to waiting until Remix or Nextjs and RSC settle down further? It seems like you are setting yourself up for having to regularly rewrite your codebase, whereas CSR is very stable.