Part 5: Routing
Single-page apps need a router to map URLs to components without a full page reload. React Router v7 is the standard choice. This part covers the config-based API (the most explicit and debuggable approach), nested routes for shared layouts, loaders for pre-fetching data, and how to lock routes behind auth.
Install
npm install react-routerReact Router v7 ships as a single react-router package (the v6 react-router-dom split is gone).
Basic setup
Define a router in main.tsx and render it:
import { StrictMode } from 'react'import { createRoot } from 'react-dom/client'import { createBrowserRouter, RouterProvider } from 'react-router'
import Root from './routes/Root'import Home from './routes/Home'import About from './routes/About'import NotFound from './routes/NotFound'
const router = createBrowserRouter([ { path: '/', element: <Root />, // layout component children: [ { index: true, element: <Home /> }, { path: 'about', element: <About /> }, ], errorElement: <NotFound />, },])
createRoot(document.getElementById('root')!).render( <StrictMode> <RouterProvider router={router} /> </StrictMode>)Root layout with Outlet
The root route renders a persistent layout. <Outlet /> is where child routes render.
import { Outlet, NavLink } from 'react-router'
function Root() { return ( <div> <nav> <NavLink to="/" end>Home</NavLink> <NavLink to="/about">About</NavLink> </nav> <main> <Outlet /> </main> </div> )}
export default RootNavLink automatically adds an active class when its to matches the current URL. The end prop on the root link prevents it from matching every route that starts with /.
Route parameters
Define dynamic segments with :paramName:
const router = createBrowserRouter([ { path: '/', element: <Root />, children: [ { index: true, element: <Home /> }, { path: 'users', element: <UserList /> }, { path: 'users/:userId', element: <UserDetail /> }, ], },])Read the parameter with useParams:
import { useParams } from 'react-router'
function UserDetail() { const { userId } = useParams<{ userId: string }>()
return <p>Showing user {userId}</p>}Loaders: data before render
React Router v7 supports loaders: async functions that fetch data before the route component renders. No loading spinners needed for initial data.
import { useLoaderData, type LoaderFunctionArgs } from 'react-router'
interface User { id: number name: string email: string}
export async function loader({ params }: LoaderFunctionArgs): Promise<User> { const res = await fetch(`/api/users/${params.userId}`) if (!res.ok) throw new Response('User not found', { status: 404 }) return res.json()}
function UserDetail() { const user = useLoaderData() as User
return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> </div> )}
export default UserDetailWire the loader into the route config:
import UserDetail, { loader as userLoader } from './routes/UserDetail'
const router = createBrowserRouter([ { path: '/', element: <Root />, children: [ { path: 'users/:userId', element: <UserDetail />, loader: userLoader }, ], },])Loader errors are caught by the nearest errorElement. The 404 throw new Response(...) renders the error element automatically.
Navigation
Link and NavLink
import { Link, NavLink } from 'react-router'
function Nav() { return ( <nav> <Link to="/">Home</Link> <Link to="/users/42">User 42</Link> <NavLink to="/about" style={({ isActive }) => ({ fontWeight: isActive ? 'bold' : 'normal' })} > About </NavLink> </nav> )}Programmatic navigation
import { useNavigate } from 'react-router'
function LoginForm() { const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() await login(credentials) navigate('/dashboard') // go to dashboard after login }
return <form onSubmit={handleSubmit}>...</form>}navigate(-1) goes back, navigate(1) goes forward. navigate('/path', { replace: true }) replaces instead of pushing (useful for redirects).
useLocation and useSearchParams
import { useLocation, useSearchParams } from 'react-router'
function SearchPage() { const [searchParams, setSearchParams] = useSearchParams() const query = searchParams.get('q') ?? ''
return ( <input value={query} onChange={e => setSearchParams({ q: e.target.value })} placeholder="Search..." /> )}
// URL updates to /search?q=react as the user typesNested routes and shared layouts
Nest routes to share a layout among a group of related pages:
const router = createBrowserRouter([ { path: '/', element: <Root />, children: [ { index: true, element: <Home /> }, { path: 'dashboard', element: <DashboardLayout />, // second-level layout children: [ { index: true, element: <DashboardHome /> }, { path: 'settings', element: <Settings /> }, { path: 'profile', element: <Profile /> }, ], }, ], },])import { Outlet, NavLink } from 'react-router'
function DashboardLayout() { return ( <div className="dashboard"> <aside> <NavLink to="/dashboard">Overview</NavLink> <NavLink to="/dashboard/settings">Settings</NavLink> <NavLink to="/dashboard/profile">Profile</NavLink> </aside> <section> <Outlet /> </section> </div> )}The root Root renders the top nav. DashboardLayout renders the sidebar. The actual page (e.g., Settings) renders in the inner Outlet. Each layer is only concerned with its own layout.
Protected routes
Redirect unauthenticated users by checking auth state in a wrapper component:
import { Navigate, useLocation, Outlet } from 'react-router'import { useAuth } from '../auth-context'
function RequireAuth() { const { user } = useAuth() const location = useLocation()
if (!user) { // save the current location so we can redirect back after login return <Navigate to="/login" state={{ from: location }} replace /> }
return <Outlet />}
export default RequireAuthWrap protected routes:
const router = createBrowserRouter([ { path: '/', element: <Root />, children: [ { index: true, element: <Home /> }, { path: 'login', element: <Login /> }, { element: <RequireAuth />, // no path, just wraps children children: [ { path: 'dashboard', element: <Dashboard /> }, { path: 'profile', element: <Profile /> }, ], }, ], },])After login, redirect back to the original destination:
import { useNavigate, useLocation } from 'react-router'import { useAuth } from '../auth-context'
function Login() { const { login } = useAuth() const navigate = useNavigate() const location = useLocation() const from = (location.state as { from?: Location })?.from?.pathname ?? '/dashboard'
const handleLogin = async () => { await login() navigate(from, { replace: true }) }
return <button onClick={handleLogin}>Log in</button>}Gotchas at this stage
index: truevspath: ''. An index route renders when the parent path matches exactly.path: ''would render on any sub-path too. Useindex: truefor the default child.- Loader
throwvsreturn. Throwing from a loader (especiallythrow new Response(...)) hands control to theerrorElement. Returning data (evennull) renders the component normally. <Link>vs<a>. Always use<Link>from react-router for internal navigation. Native<a href>causes full page reloads and loses all client-side state.- Hash router vs browser router.
createBrowserRouteruses the History API and requires server config to redirect all paths toindex.html.createHashRouteruses/#/pathand works with static file hosts without config. Use browser router for apps with a proper server.
What’s next
Part 6 covers data fetching: the fetch + useEffect pattern, TanStack Query (React Query) for caching and background updates, and how to handle loading and error states without boilerplate.