Last 30 Days
No notifications
React Router enables single-page application (SPA) navigation without full page reloads. It maps URL paths to React components and keeps the UI in sync with the browser's address bar.
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/users/:id" element={<UserProfile />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>Use :param in the path and read it with useParams():
const { id } = useParams(); // from path="/users/:id"<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<Overview />} />
<Route path="settings" element={<Settings />} />
</Route>The parent uses to render child routes.
const navigate = useNavigate();
navigate("/login"); // push
navigate(-1); // go back
navigate("/home", { replace: true }); // replace historyA traditional website asks the server for a brand-new HTML page on every link click. A single-page app loads once and then swaps content in place β much faster, no full-page flicker. But the URL still has to change so the back button, refresh, and shareable links all work.
That's what a router does: keep the URL in sync with what's on screen, *without* a real navigation.
> The library you'll most often see is React Router. Next.js has its own built-in router (covered separately).
npm install react-router-domimport { BrowserRouter, Routes, Route, Link } from "react-router-dom";function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
Three pieces:
once at the top. is the switch. It picks the best-matching child . (not ) navigates without a page reload.Put :name in the path to capture part of the URL:
<Route path="/users/:id" element={<UserProfile />} />import { useParams } from "react-router-dom";
function UserProfile() {
const { id } = useParams();
return <p>User ID: {id}</p>;
}
/users/42 β id === "42".
When you can't use a (e.g., after form submit), use useNavigate:
import { useNavigate } from "react-router-dom";function LoginForm() {
const navigate = useNavigate();
async function onSubmit(e) {
e.preventDefault();
await login(...);
navigate("/dashboard");
}
return <form onSubmit={onSubmit}>β¦</form>;
}
navigate(-1) is "back". navigate("/x", { replace: true }) replaces the current entry instead of pushing a new one (useful after login).
Routes can nest to share layout (a navbar, a sidebar):
<Routes>
<Route element={<DashboardLayout />}>
<Route path="dashboard" element={<Overview />} />
<Route path="dashboard/users" element={<Users />} />
<Route path="dashboard/orders" element={<Orders />} />
</Route>
</Routes>Inside DashboardLayout you render an where the matched child should appear:
import { Outlet } from "react-router-dom";function DashboardLayout() {
return (
<div className="grid grid-cols-[200px_1fr]">
<Sidebar />
<main><Outlet /></main>
</div>
);
}
is a that knows when *its* route is the current one:
<NavLink
to="/about"
className={({ isActive }) => isActive ? "text-blue-500" : ""}
>
About
</NavLink>Anything after ? is search params (also called query string). Use useSearchParams:
import { useSearchParams } from "react-router-dom";function Products() {
const [params, setParams] = useSearchParams();
const page = Number(params.get("page") ?? 1);
return (
<button onClick={() => setParams({ page: page + 1 })}>
Next
</button>
);
}
URL params are great for filters, pagination, and sorting β they're shareable and the back button just works.
1. Using for internal links β full page reload, state lost.
2. Forgetting to render in a layout β child routes silently vanish.
3. Mismatched param names β then useParams().id returns undefined.
4. Putting outside β runtime error.
5. Hard-coded URLs everywhere β keep paths in a constants file or generate with generatePath.
When you click a :
1. The router calls history.pushState() β the URL bar updates, but no server request happens.
2. The router compares the new URL against your route definitions.
3. The matched element renders. React reconciles the difference.
4. The page never reloads β only the parts that changed are updated.
This is why you must use (which talks to the router) instead of (which the browser handles).
In React Router v6, routes are ranked, not first-match:
/users/new β beats /users/:id
/users/:id/posts β beats /users/:id
/users/:id β beats /users
- β always last
Order in the file doesn't matter; specificity does.A small wrapper redirects unauthenticated users:
import { Navigate, useLocation } from "react-router-dom";function ProtectedRoute({ children }) {
const { user } = useAuth();
const location = useLocation();
if (!user) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return children;
}
<Route element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
<Route path="dashboard" element={<Overview />} />
</Route>
The state={{ from: location }} trick lets the login page redirect back where the user wanted to go.
The data router lets you fetch *before* the route renders, eliminating the "spinner inside a spinner" problem:
import { createBrowserRouter, RouterProvider, useLoaderData } from "react-router-dom";const router = createBrowserRouter([
{
path: "/users/:id",
loader: ({ params }) => fetch(/api/users/${params.id}).then(r => r.json()),
element: <UserProfile />,
errorElement: <ErrorPage />,
},
]);
function UserProfile() {
const user = useLoaderData();
return <h1>{user.name}</h1>;
}
Loaders kick off in parallel for nested routes, so you don't waterfall.
Routes can also declare action functions for form submissions β they run on submit, return a result, then the loaders re-run automatically:
{
path: "/posts/new",
action: async ({ request }) => {
const data = await request.formData();
await createPost(data);
return redirect("/posts");
},
element: <NewPostForm />,
}This pattern (loaders + actions) is straight from server-side frameworks like Remix and is React Router's modern direction.
Don't ship the entire app on first load β split per route:
const Settings = lazy(() => import("./Settings"));<Route
path="settings"
element={
<Suspense fallback={<Spinner />}>
<Settings />
</Suspense>
}
/>
Or, with the data router:
{ path: "settings", lazy: () => import("./Settings") }By default, the browser keeps the scroll position from the previous page. Usually you want to scroll to top on route change:
import { ScrollRestoration } from "react-router-dom";
<ScrollRestoration />(Or do it manually in a small useEffect watching useLocation().)
| Router | URL looks like | Use case |
BrowserRouter | /about | Default; needs server fallback to index.html |
HashRouter | /#/about | Static hosting where you can't configure the server |
MemoryRouter | (none) | Tests, native apps |
If you deploy to a plain static host and refreshing /about 404s, that's the server fallback issue β either configure it or switch to HashRouter.
useLocation & TransitionsuseLocation() gives you the current URL info, including pathname, search, and state (anything you passed via navigate(..., { state })).
Pair with useTransition (React 18) to keep the UI snappy while loading the next route's data:
const [pending, startTransition] = useTransition();
startTransition(() => navigate("/heavy-page"));1. Build a 3-page site with /, /about, /users/:id and a -based navbar with active styling.
2. Add a nested /dashboard layout with sidebar + child routes overview, users, orders.
3. Build a search page that stores its query in URL search params (so refreshing keeps results).
4. Add a ProtectedRoute wrapper that bounces unauthenticated users to /login and back.