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.
Node.js v20 is the minimum requirement for running React Router, so make sure your device runs that version or something higher:
node --version
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
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:
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
After that, open your browser and visit the URL http://localhost:5173
, where you should be greeted with a homepage that looks like this:
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.
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;
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; */ } }
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> ); }
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:
app/welcome
folder and create a new folder named app/components
,welcome
folder to components
and delete all the files inside itNext, 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> ); }
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
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> ); }
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> ); }
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> ); }
With that, you should have a basic structure for your app up and running:
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;
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 they’ve read and what they want to read next. With just the essentials, it’s 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 it’s noting down the books you’ve finished or keeping a simple list of what’s 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 that’s 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> ); }
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;
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:
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;
As you can see here, the routes make use of a layout
function that has two arguments:
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.
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 };
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> ); }
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> ); }
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;
With all of that done, you should have a homepage that reads data from storage
in app/model.ts
:
This means that any book added to storage
should show up in the book-list.tsx
route.
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;
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> ); }
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:
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.
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; } ...
With that function in place, you should be able to add new books to the application:
After filling out the form, the new book should appear on the book-list.tsx
route:
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> ); }
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
):
With that, all the basic functionalities of the app are done.
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 created404
, which means that a requested server resource was not foundIn 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 }); } ...
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; }
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.
<head>
tag in React RouterThe 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' }, ]; } ...
<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' }, ]; }
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}"` }]; }
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.
<head>
tag in React RouterHTML <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', }, ]; }
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.
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" } }
Now the client will get the response with your custom set headers.
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:
app/routes.ts
fileuseLoaderData()
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.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 tremendouslyThere 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.
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.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowWith the right tools and strategies, JavaScript debugging can become much easier. Explore eight strategies for effective JavaScript debugging, including source maps and other techniques using Chrome DevTools.
This Angular guide demonstrates how to create a pseudo-spreadsheet application with reactive forms using the `FormArray` container.
Implement a loading state, or loading skeleton, in React with and without external dependencies like the React Loading Skeleton package.
The beta version of Tailwind CSS v4.0 was released a few months ago. Explore the new developments and how Tailwind makes the build process faster and simpler.