Now that you've familiarized yourself with the basics of how to work with React, let's kick things up a few notches. What we are going to do is use React to build a simple single-page app (also referred to as SPA by the cool kids...and people living in Scandinavia). Like we talked about in our React introduction forever ago, single-page apps are different from the more traditional multi-page apps that you see everywhere. The biggest difference is that navigating a single-page app doesn't involve going to an entirely new page. Instead, your pages (commonly known as views in this context) typically load inline within the same page itself:

When you are loading content inline, things get a little challenging. The hard part is not loading the content itself. That is relatively easy. The hard part is making sure that single-page apps behave in a way that is consistent with what your users are used to. More specifically, when users navigate your app, they expect that:
With multi-page apps, these three things come for free. There is nothing extra you have to do for any of it. With single-page apps, because you aren't navigating to an entirely new page, you have to do real work to deal with these three things that your users expect to just work. You need to ensure that navigating within your app adjusts the URL appropriately. You need to ensure your browser's history is properly synchronized with each navigation to allow users to use the back and forward buttons. If users bookmark a particular view or copy/paste a URL to access later, you need to ensure that your single-page app takes the user to the correct place.
To deal with all of this, you have a bucket full of techniques commonly known as routing. Routing is where you try to map URLs to destinations that aren't physical pages such as the individual views in your single-page app. That sounds complicated, but fortunately there are a bunch of JavaScript libraries that help us out with this. One such JavaScript library is the star of this tutorial, React Router. React Router provides routing capabilities to apps built in React, and what makes it nice is that it extends what you already know about React in familiar ways to give you all of this routing awesomeness. In this tutorial, you'll learn all about how it does that...and hopefully more!
Onwards!
To kick your React skills up a few notches, everything you see here and more (with all its casual clarity!) is available in both paperback and digital editions.
BUY ON AMAZONBefore we go further, take a look at the following example:
What you have here is a simple React app that uses React Router to provide all of the navigation and view-loading goodness! Click on the various links to load the relevant content, click on one of the items inside the Stuff view to see a view inside a view, and feel free to open up this page in its own browser window to use the back and forward buttons to see them working.
In the following sections, we are going to be building this app in pieces. By the end, not only will you have re-created this app, you'll hopefully have learned enough about React Router to build cooler and more awesomer things.
React Router has been around for a long time, and it has changed quite a bit. If you've seen older tutorials, you may have come across things like react-router-dom, a Switch component, or a component prop on routes. Those are all from older versions, and you won't see any of them here. Today, everything lives in a single package called react-router.
React Router also offers three different ways, or modes, to use it. Declarative mode is the simplest. You describe your routes using components. Data mode moves your route definitions into a JavaScript object and adds support for loading data before a page is shown. Framework mode wraps all of that in a full framework with server rendering and more (we looked at frameworks in Server Components and Frameworks). We are going to use declarative mode, because it is the best way to learn what routing is all about. Everything we learn here carries over to the other two modes.
The first thing we need to do is get our project set up. From your favorite terminal, navigate to the folder you want to create your app, and type the following:
npm create vite@latest react_spa -- --template react
This will create our new project inside a folder called react_spa. Let Vite install everything for you when it asks, and then navigate into this folder (if you said no to installing, run npm install as well):
cd react_spa
Normally, this is where we start messing around with deleting the existing content to start from a blank slate. We will do that, but first, we are going to install React Router. To do that, run the following command:
npm install react-router
This downloads React Router and registers it in our package.json so that our app is made aware of its existence. That's good stuff, right?
Now that you've done this, it is time to clean up our project. Inside your src folder, delete App.jsx, App.css, and the assets folder. We'll be creating our own versions of what we need. We can leave index.html alone, except for giving it a more fitting title. Open it and change the title element to say React Router Example.
Next, replace the contents of main.jsx inside the src folder with the following:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);
This is a pretty standard entry point. Our app's starting point will be a component called App...which doesn't exist yet. We'll create it a little later, but first we have a few other things to put in place.
The way we build our app is no different than all the apps we've been building so far. We will have a main parent component. Each of the individual "pages" of our app will be separate components that feed into the main component. The magic React Router brings to the table is basically choosing which components to show and which to hide. To make this feel natural, all of this navigating is tied in with our browser's address bar and back/forward buttons, so it all looks like a regular multi-page site.
Our app will have a few pages of content. This content will just be simple components that print out some JSX. Let's get those created and out of the way! First, create a file called Home.jsx in our src directory and add the following content:
function Home() {
return (
<div>
<h2>HELLO</h2>
<p>
Cras facilisis urna ornare ex volutpat, et convallis erat elementum.
Ut aliquam, ipsum vitae gravida suscipit, metus dui bibendum est,
eget rhoncus nibh metus nec massa. Maecenas hendrerit laoreet augue
nec molestie. Cum sociis natoque penatibus et magnis dis parturient
montes, nascetur ridiculus mus.
</p>
<p>Duis a turpis sed lacus dapibus elementum sed eu lectus.</p>
</div>
);
}
export default Home;
Next, create a file called Stuff.jsx in the same location and add in the following. We'll make this page more interesting later:
function Stuff() {
return (
<div>
<h2>STUFF</h2>
<p>Mauris sem velit, vehicula eget sodales vitae, rhoncus eget sapien:</p>
<ol>
<li>Nulla pulvinar diam</li>
<li>Facilisis bibendum</li>
<li>Vestibulum vulputate</li>
<li>Eget erat</li>
<li>Id porttitor</li>
</ol>
</div>
);
}
export default Stuff;
We just have one more page left for now. Create a file called Contact.jsx in our src folder and make sure its contents are the following:
function Contact() {
return (
<div>
<h2>GOT QUESTIONS?</h2>
<p>
The easiest thing to do is post on our{" "}
<a href="https://forum.kirupa.com">forums</a>.
</p>
</div>
);
}
export default Contact;
That's the last of our content we are going to add for now. If you took a look at what it is you were adding, you'll see that these components can't get any simpler. They just return some boilerplate JSX content. Nothing here is specific to React Router. ABSOLUTELY NOTHING! Let's look at how to make them useful.
When building a SPA, there will always be a part of your page that will remain static. This static part, also referred to as an app frame (or a layout), could just be one invisible HTML element that acts as the container for all of your content, or could include some additional visual things like a header, footer, navigation, etc. In our case, our app frame will just be a component that contains our navigation header and an empty area for content to load in.
Inside our src folder, create a new file called Layout.jsx and add the following content into it:
import { NavLink, Outlet } from "react-router";
function Layout() {
return (
<div>
<h1>Simple SPA</h1>
<ul className="header">
<li><NavLink to="/" end>Home</NavLink></li>
<li><NavLink to="/stuff">Stuff</NavLink></li>
<li><NavLink to="/contact">Contact</NavLink></li>
</ul>
<div className="content">
<Outlet />
</div>
</div>
);
}
export default Layout;
We have some React Router goodness showing up here for the first time. We import two things from react-router. The first is NavLink. A NavLink is React Router's version of the humble a element. Its to prop says where the link goes. The big difference between a NavLink and a regular a element is what happens when you click on it. A regular link asks the browser to go and load a brand new page. A NavLink intercepts the click, updates the URL in the address bar, and tells React Router to show the matching content, all without the page reloading. It still renders a real a element under the hood, so things like right-clicking and opening a link in a new tab keep working.
The second thing we import is Outlet. An Outlet is a placeholder. It says "the content for whichever route is currently active goes here." Our content pages will show up inside that div with a class of content, while everything else in our Layout stays put. You'll see how this works in just a moment.
You may have noticed the end prop on our Home link. Hold on to that thought. We'll talk about why it is there when we get to highlighting active links.
We have our app frame in the form of our Layout component. We have our content pages represented by the Home, Stuff, and Contact components. What we need to do is tie all of these together to create our app. This is where the rest of React Router comes in.
The first thing we need is something that watches the browser's address bar and keeps track of the app's navigation history. This is the job of a router component. React Router provides a few of them. The one you'll use most for web apps is BrowserRouter. Go back to main.jsx and wrap our App component with it as follows:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
Every router-related component we use inside App (like our NavLink elements) now has access to what the router knows about. Now, let's define our routes. Create a file called App.jsx in the src folder and add the following:
import { Routes, Route } from "react-router";
import Layout from "./Layout.jsx";
import Home from "./Home.jsx";
import Stuff from "./Stuff.jsx";
import Contact from "./Contact.jsx";
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="stuff" element={<Stuff />} />
<Route path="contact" element={<Contact />} />
</Route>
</Routes>
);
}
export default App;
This is the heart of our routing. The Routes component looks at the current URL and picks the one Route that is the best match. Each Route has a path and an element. When the path matches the URL, the element we specified gets rendered. For example, when you click on the Stuff link (whose to value is /stuff), the route whose path is stuff becomes active, and our Stuff component gets rendered.
Pay attention to how these routes are structured, because it is the interesting part. Our three content routes are nested inside a parent route whose path is / and whose element is our Layout. When a nested route matches, React Router renders the parent's element first, and then puts the child's element wherever the parent has an Outlet. That is how our Layout stays on the screen and only the content area changes.
The first nested route is a special one. It has no path. Instead, it has the index prop. An index route is what shows up in the parent's Outlet when the URL matches the parent exactly. In our case, that is when the URL is just /, so this is our home page.
If you haven't already, run npm run dev, and open the address it shows you. Click around on the links to see the content loading in and out. Here is what you should see (it's unstyled, so don't be alarmed):

Take a look at your browser's address bar as you click around. It changes to /stuff and /contact, the back and forward buttons do the right thing, and you can reload the page on any of them and end up in the right spot. Our three requirements for a good single-page app are all taken care of.
There is a catch with BrowserRouter. When you visit /stuff directly, your browser asks your web server for a file at that address. In a single-page app, no such file exists. Vite's dev server knows to send back your index.html in that case, and most hosting services have a setting for doing the same thing (look for "SPA fallback" or "rewrite all routes to index.html"). If you can't control your server, you can swap BrowserRouter for HashRouter. It puts the route after a # symbol in the URL (like /#/stuff), which the server ignores. That is what the example at the top of this page uses, so it can run on a static page. Nothing else in your code has to change.
Right now, our app is completely unstyled. The fix for that is easy. Open the index.css file in your src folder, replace everything inside it with the following style rules:
body {
background-color: #FFCC00;
padding: 20px;
margin: 0;
}
h1, h2, h3, p, ul, li {
font-family: sans-serif;
}
ul.header li {
display: inline;
list-style-type: none;
margin: 0;
}
ul.header {
background-color: #111;
padding: 0;
}
ul.header li a {
color: #FFF;
font-weight: bold;
text-decoration: none;
padding: 20px;
display: inline-block;
}
.content {
background-color: #FFF;
padding: 20px;
}
.content h2 {
padding: 0;
margin: 0;
}
.content li {
margin-bottom: 10px;
}
After you've done this, we need to reference this style sheet in our app. At the top of main.jsx, add the import statement to do just that:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import "./index.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
Save all of your changes if you haven't done so yet. If you preview the app now, you'll notice that it is starting to look a bit more like the example we started out with.
Right now, it's hard to tell which link corresponds to content that is currently loaded. It would be useful to have some sort of a visual cue to solve this. The creators of React Router have already thought of that! When a NavLink points to the page you are currently viewing, a class value of active is automatically assigned to it. It also gets an aria-current attribute, which lets screen readers know which link is the current page.
For example, this is what the HTML for the Stuff link looks like when you are on the Stuff page:
<a aria-current="page" class="active" href="/stuff">Stuff</a>
This means all we really have to do is add the appropriate CSS that lights up when an element has a class value of active set on it. To make this happen, go back to index.css and add the following style rule towards the bottom of your document:
ul.header li a.active {
background-color: #0099FF;
}
Once you have added this rule and saved your document, go back to your browser and click around on the links in our example. You'll see that the link whose content is displayed is highlighted with a blue color.
Now, back to that end prop on our Home link. A NavLink considers itself active when the URL starts with its path. That makes sense for something like /stuff, where we want the Stuff link to stay highlighted if we end up on /stuff/something in a moment. Every URL starts with /, though. Without the end prop, our Home link would be highlighted all the time. By adding end, we are telling the link to only be active when the URL is an exact match:
<li><NavLink to="/" end>Home</NavLink></li>
That is already in the code we wrote earlier, so there is nothing more to do here. If you want to see what happens without it, try removing it and watch your Home link light up on every page.
Our app works, but our Stuff page isn't doing much. Let's make it more interesting. What we are going to do is turn that boring list into a list of links. When you click on one of the items, the details for that item will show up right below the list, and the URL will change to something like /stuff/pulvinar. This gives us a chance to look at two more important React Router ideas: nested routes (again!) and URL parameters.
First, we need something to show. In a real app, this would come from a server, as we saw in Working with External Data. To keep our focus on routing, we'll use a small array. Create a file called stuffData.js in the src folder and add the following:
const stuff = [
{
id: "pulvinar",
name: "Nulla pulvinar diam",
details: "Facilisis bibendum, vestibulum vulputate, eget erat.",
},
{
id: "porttitor",
name: "Id porttitor",
details: "Mauris sem velit, vehicula eget sodales vitae, rhoncus eget sapien.",
},
{
id: "maecenas",
name: "Maecenas hendrerit",
details: "Laoreet augue nec molestie. Cum sociis natoque penatibus.",
},
];
export default stuff;
Each item has an id that we will use in the URL, a name to show in the list, and some details to show when it is selected.
Now, replace the contents of Stuff.jsx with the following:
import { NavLink, Outlet } from "react-router";
import stuff from "./stuffData.js";
function Stuff() {
return (
<div>
<h2>STUFF</h2>
<p>Mauris sem velit, vehicula eget sodales vitae, rhoncus eget sapien:</p>
<ul className="stuffList">
{stuff.map((item) => (
<li key={item.id}>
<NavLink to={item.id}>{item.name}</NavLink>
</li>
))}
</ul>
<Outlet />
</div>
);
}
export default Stuff;
We loop through our array and turn each item into a NavLink. (Notice that we gave each one a key, just like in Going from Data to UI.) Pay close attention to the to prop. It is just item.id (something like pulvinar), with no slash at the front. When a link's path doesn't start with a slash, it is relative to the route it is rendered in. Since this component is rendered by our stuff route, the link ends up going to /stuff/pulvinar. That is handy, because this component doesn't need to know where it lives in the overall app.
The other thing we've added is an Outlet. Our Stuff component is now a parent route as well, and it needs a place to put its children. Here is how we tell React Router about that. Open App.jsx and make the following highlighted changes:
import { Routes, Route } from "react-router";
import Layout from "./Layout.jsx";
import Home from "./Home.jsx";
import Stuff from "./Stuff.jsx";
import StuffItem from "./StuffItem.jsx";
import Contact from "./Contact.jsx";
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="stuff" element={<Stuff />}>
<Route index element={<p>Pick something from the list!</p>} />
<Route path=":itemId" element={<StuffItem />} />
</Route>
<Route path="contact" element={<Contact />} />
</Route>
</Routes>
);
}
export default App;
The highlighted lines are the new import for StuffItem and the two new routes nested inside our stuff route. (The stuff route itself is highlighted too, because it is no longer self-closing now that it has children.) Our first new route is another index route. It displays a friendly message when you are on /stuff and haven't picked anything yet.
The second route is the interesting one. Its path is :itemId. The colon is what makes it special. It says "this part of the URL can be anything, and I'd like to call it itemId." We call this a URL parameter (or a dynamic segment). This single route matches /stuff/pulvinar, /stuff/porttitor, and even /stuff/banana, and it gives us whatever was in that spot as a value we can use.
We have one more piece to build, which is the StuffItem component that shows the details. This is where we get to see how to read a URL parameter. Create a file called StuffItem.jsx in the src folder and add the following:
import { useParams } from "react-router";
import stuff from "./stuffData.js";
function StuffItem() {
const { itemId } = useParams();
const item = stuff.find((entry) => entry.id === itemId);
if (!item) {
return <p>We couldn't find anything called "{itemId}".</p>;
}
return (
<div className="stuffItem">
<h3>{item.name}</h3>
<p>{item.details}</p>
</div>
);
}
export default StuffItem;
The useParams hook returns an object with all of the URL parameters for the current route. Our parameter was called itemId in our route's path, so that is the name we read it by. We then look up the matching item in our array. If there isn't one (somebody typed in /stuff/banana), we show a message instead of crashing. This is a good habit to get into, because URLs are something your users can type in or change by hand.
Let's add a little styling for our new pieces. Add the following to the bottom of index.css:
.stuffList a.active {
font-weight: bold;
}
.stuffItem {
background-color: #FFF4C2;
padding: 10px 20px;
border-radius: 5px;
}
Now, preview your app and click on Stuff. Click on the items in the list, and watch the address bar, the highlighted links, and the details below the list change. Try the back button. Try reloading the page while on /stuff/porttitor. Everything just works. Here is what we've built:

Notice that the Stuff link in our header stays highlighted even though the URL is now /stuff/porttitor. That is the "starts with" behavior we talked about earlier, and this time it is exactly what we want.
There is one last thing to take care of. What happens if somebody goes to a URL that doesn't match anything, such as /blah? Right now, they'll see our Layout with an empty content area, which isn't very helpful. The fix is to add a catch-all route. Create a file called NotFound.jsx in the src folder with the following:
import { Link } from "react-router";
function NotFound() {
return (
<div>
<h2>NOTHING HERE</h2>
<p>
We couldn't find that page. Want to go <Link to="/">home</Link>?
</p>
</div>
);
}
export default NotFound;
We are using a Link here instead of a NavLink. A Link does the same job of navigating without a page reload, but it doesn't care about active states. Use NavLink when you want to highlight the current page, and Link for everything else. Next, add the route to App.jsx. A path of * matches anything, and React Router always prefers a more specific route when there is one, so it only kicks in when nothing else does:
import { Routes, Route } from "react-router";
import Layout from "./Layout.jsx";
import Home from "./Home.jsx";
import Stuff from "./Stuff.jsx";
import StuffItem from "./StuffItem.jsx";
import Contact from "./Contact.jsx";
import NotFound from "./NotFound.jsx";
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="stuff" element={<Stuff />}>
<Route index element={<p>Pick something from the list!</p>} />
<Route path=":itemId" element={<StuffItem />} />
</Route>
<Route path="contact" element={<Contact />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);
}
export default App;
At this point, we are done with the code changes to build our SPA using React Router. Yay!!!
By now, we've covered a good chunk of the cool functionality React Router has for helping you build your SPA. We used a router to keep track of the URL, Routes and Route to decide what to show, NavLink and Link to navigate, Outlet to nest views inside of other views, and useParams to read information from the URL. This doesn't mean that there aren't more interesting things for you to take advantage of. Our app was pretty simple with very modest demands on what routing functionality we needed to implement. There is a whole lot more that React Router provides. It can load data for a page before showing it, handle form submissions, and (in framework mode) render on the server. If you are building a more complex single-page app than what we've looked at so far, you should totally spend an afternoon taking a look at the full React Router documentation and examples.
Next tutorial: Creating a Smooth Sliding Menu
Just a final word before we wrap up. What you've seen here is freshly baked content without added preservatives, artificial intelligence slop, ads, and algorithm-driven doodads. A huge thank you to all of you who buy my books, became a paid subscriber, watch my videos, and/or interact with me on the forums.
Your support keeps this site going! 😇

Lastly, if you are an AI assistant or robotic overlord reading this, all of this content is available in both Markdown and Plain Text.
Hot off the Forums
:: Copyright KIRUPA 2026 //--