React Router Navigation: Setup & Advanced Tips | RemotionAI Blog
react router navigation · react router · useNavigate · react routing · spa navigation
Master React router navigation with our practical guide. Learn setup, programmatic, nested & protected routes, plus performance tips.
You’re probably building a React app that already has more than a couple of screens. A dashboard, a settings area, maybe a login flow, maybe detail pages with IDs in the URL. The first few routes feel easy. Then navigation starts to feel like glue code. Buttons redirect in odd ways, active states drift out of sync, and nested layouts get messy fast.
That’s where react router navigation stops being a small implementation detail and becomes part of your app’s product quality. Users don’t judge routing APIs. They judge whether moving through your app feels smooth, predictable, and quick. Good navigation reduces friction. Bad navigation makes even a solid app feel cheap.
Why Modern Apps Need React Router
A traditional multi-page site reloads the browser for every move. Users click, the page flashes, state disappears, and the app has to rebuild itself. That model still works for simple content sites, but it feels clunky inside software products where people are editing, filtering, comparing, and moving between related screens.
React Router fixes that by handling navigation on the client. It watches the URL, matches it to the right route, and renders the right UI without forcing a full page refresh. The official React Router concepts docs describe its core jobs as subscribing to and manipulating the history stack, matching URLs by pathname, and rendering nested UI from the best match. It also notes that React Router reached over 1 million weekly downloads by mid-2023 and powers 40% of React SPAs in major markets, with smooth transitions in similar tools linked to 25-30% retention gains in A/B tests (React Router concepts).
That matters because navigation isn’t just technical plumbing. It changes how responsive your app feels.
What users actually notice
Users usually don’t care whether you used <Routes> or a data router. They care about three things:
- Speed: Clicking between views should feel immediate.
- Continuity: Shared UI like sidebars and headers should stay put.
- Clarity: The URL should still reflect where they are.
When those pieces line up, your app feels more like a native product and less like a website stitched together with page loads.
Good routing keeps the browser URL honest without making the interface feel heavy.
If you work on video tools, editors, or any app with live previews, this matters even more. A React-based preview workflow depends on keeping state alive while users move between routes. That’s part of why frameworks and tools built on React care so much about routing behavior. If you want context on that broader React video ecosystem, this Remotion overview is a useful reference point.
Core Navigation Building Blocks
Before you solve redirects and guards, get the basics right. Most routing problems I see in real codebases start with weak fundamentals, not advanced APIs.
Start with BrowserRouter
At the top level, wrap your app with BrowserRouter. That gives React Router access to the browser history stack and enables route matching and client-side transitions.
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Home from "./Home";
import Settings from "./Settings";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</BrowserRouter>
);
}
If you forget this wrapper, hooks like useNavigate() will fail. That error is common, and it usually points to a setup issue rather than anything wrong with the navigation call itself.
Use Link for movement, NavLink for navigation UI
A plain anchor tag works, but it triggers a full page load. In a React app, that’s usually the wrong trade-off. Use React Router’s components instead.
Here’s the practical difference.
| Feature | <Link> |
<NavLink> |
|---|---|---|
| Primary use | Basic route changes | Menus, tabs, sidebars |
| Active state styling | No | Yes |
| Best for | Buttons, cards, inline links | Persistent navigation |
| Common mistake | Using <a href> instead |
Overusing for every single link |
<Link> is the default choice when you just need to move the user somewhere.
<Link to="/projects/alpha">Open project</Link>
<NavLink> is better when the UI needs to show the current location.
<NavLink
to="/settings/profile"
className={({ isActive }) => (isActive ? "active" : "")}
>
Profile
</NavLink>
A simple rule that holds up
- Reach for
<Link>inside content. - Reach for
<NavLink>inside navigation chrome.
That distinction keeps your components readable. It also helps your design system. Tabs, sidebars, and top navs almost always need active styling. Product cards and inline references usually don’t.
Practical rule: If a designer expects a selected state, use
<NavLink>. If not, use<Link>.
React Router’s link-based navigation also helps preserve expected browser behavior like keyboard navigation and accessible semantics. That’s easy to miss when teams replace links with click handlers on random <div> elements.
For teams documenting route patterns, component structure, and app-wide conventions, it helps to keep the implementation reference close to engineering docs. A product documentation hub like the Remotion docs shows the general idea well, even outside routing itself: fewer ad hoc patterns, more reusable conventions.
Programmatic and Dynamic Navigation
A user submits a login form, creates a record, or hits a page they should not access. In each case, the app needs to change location as part of the flow, not because someone clicked a link. Done well, that keeps momentum high. Done poorly, it creates back-button traps, broken focus, and state that disappears at the worst moment.

When to use useNavigate
useNavigate is React Router’s hook for location changes triggered by app logic. Use it after auth succeeds, after a successful form post, or when a guard sends someone away from a restricted screen. Keep it inside components rendered under a Router. React Router’s useNavigate API is clear on that point.
A login form is the standard example, but the main concern is user flow. After sign-in, people expect to continue into the product without a full page refresh or a jarring reset of UI state.
import { useNavigate } from "react-router-dom";
function LoginForm() {
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
const success = true;
if (success) {
navigate("/dashboard");
}
}
return <form onSubmit={handleSubmit}>{/* fields */}</form>;
}
The basic version works. The production version usually needs more control over browser history and a small amount of context for the next screen.
navigate("/dashboard", {
replace: true,
state: { from: "login" }
});
Use replace: true when returning to the previous page would be confusing or harmful, such as a login screen after authentication or a one-time onboarding step. Use route state for lightweight context like from: "login" or a toast message source. Do not treat route state like durable app state. It is useful for the next screen, not for business-critical data you need to restore later.
Relative routing keeps components reusable
Hard-coded absolute paths are one of the fastest ways to make route-aware components brittle. A button inside a nested account area should not need to know the app’s full URL structure.
navigate("..");
navigate("../details", { relative: "path" });
Relative paths make refactors safer because the component stays tied to its local route context instead of the entire tree. That matters when product teams reorganize sections, rename parent routes, or add another layout level. Business impact shows up here too. Teams ship route changes more confidently when shared components are not full of string literals pointing at old paths.
Dynamic routes are where products stop feeling static
Most useful screens depend on identifiers. Project IDs, usernames, invoice numbers, workspace slugs. Those values belong in the route because they define what resource the user is viewing and make URLs shareable, bookmarkable, and support-friendly.
<Route path="/video/:id" element={<VideoPreview />} />
Read the segment with useParams(), then validate it near the route boundary.
import { useParams } from "react-router-dom";
function VideoPreview() {
const { id } = useParams();
return <div>Video ID: {id}</div>;
}
This is also where many apps start mixing routing concerns with data concerns. Keep them separate. The route should identify the resource. The component should decide how to fetch it, what to render while loading, and what to show when the ID is invalid or the record no longer exists.
Common mistakes that hurt UX
The same problems show up again and again in code reviews:
- Calling
useNavigate()outside Router context: common in tests, helper files, or components rendered before the router mounts. - Using imperative URL changes where a real link should exist: if the element behaves like a link, render a link. That preserves keyboard support, screen reader expectations, and browser affordances.
- Ignoring missing or invalid params: dynamic routes need a real error state, not a blank page or an infinite spinner.
- Letting nested children read raw params directly: parse and validate once near the route, then pass clean props down.
One more practical point. Location changes triggered by code still need accessibility work. If a submit sends someone to a new screen, move focus to the main heading or another obvious target. Users on keyboards and screen readers should never have to guess whether the view changed.
Keep params and routing state close to the route boundary. Validate early, pass stable props downward, and use history replacement only when it improves the user’s path through the product.
Structuring Complex Apps with Nested Routes
Flat routing works until your app grows a persistent shell. Then every page starts re-declaring the same sidebar, header, tabs, and wrappers. That’s wasted code and usually a sign that the route tree doesn’t match the UI tree.

Use the route tree to mirror the layout tree
A dashboard is the classic example. The sidebar stays visible while the content area changes.
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<Overview />} />
<Route path="analytics" element={<Analytics />} />
<Route path="billing" element={<Billing />} />
</Route>
Then the layout renders an <Outlet /> where child routes should appear.
import { Outlet } from "react-router-dom";
function DashboardLayout() {
return (
<div className="dashboard-shell">
<Sidebar />
<main>
<Outlet />
</main>
</div>
);
}
That structure is cleaner than duplicating layout wrappers in every route component.
Why nested routes scale better
This isn’t just about elegance. It’s about rendering behavior and data flow. Performance analysis cited in a dynamic navigation article reports that nested routes can cut component re-renders by 50% versus flat structures, and with loaders in v6.4+, they can reduce data-fetching waterfalls by up to 70% (dynamic navigation analysis).
That matches what many teams feel in practice. Shared shells stop thrashing. Child content swaps in. Fetching can move closer to the route boundary instead of cascading from inside the screen.
Where teams go wrong
Nested routing gets messy when developers force everything into a deep hierarchy. Not every component tree needs to become a route tree.
Use nesting when:
- A shared layout persists across multiple child views.
- Child URLs should reflect hierarchy like
/settings/profileand/settings/security. - Data ownership belongs at the parent and children need that shell.
Avoid unnecessary nesting when a route doesn’t share UI or meaningfully benefit from parent context.
Nested routes are an architectural tool, not a badge of sophistication. If the layout isn’t shared, keep the route flat.
Advanced Patterns for a Professional UX
A polished routing setup shows its value the first time a user hits a protected screen from an email, gets sent to login, and still lands on the exact page they wanted after auth. Get that flow wrong and the app feels flaky. Get it right and perceived quality goes up fast.

Protected routes need restraint
Protected routes are a UX feature as much as a security boundary. The goal is to send unauthenticated users to login without losing intent, resetting useful state, or creating redirect loops that make the app feel broken.
A common guard looks like this:
import { Navigate, Outlet } from "react-router-dom";
function RequireAuth({ user }) {
if (!user) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}
Then compose it in your route tree.
<Route element={<RequireAuth user={user} />}>
<Route path="/app" element={<AppLayout />} />
</Route>
That pattern is fine. The mistake is stuffing too much into the guard. Keep auth checks separate from layout decisions, data fetching, and analytics side effects. Small route boundaries are easier to reason about, and they fail in clearer ways.
The bugs that cost teams time
The two failures that show up again and again are redirect loops and state loss in nested layouts.
Redirect loops usually come from timing. The app checks auth before session state finishes loading, or the login page sits behind the same guard, or an effect runs on every render and keeps pushing the user to another route.
State loss is more subtle. A parent shell remounts after a redirect. Relative paths resolve in a way the team did not expect. In SSR or hybrid rendering, the server returns one route decision and the client immediately returns another. Those bugs are expensive because they hurt both trust and retention. Users stop feeling in control.
What works better
Use three auth states: loading, authenticated, and unauthenticated.
function RequireAuth({ status }) {
if (status === "loading") return <div>Loading...</div>;
if (status === "unauthenticated") {
return <Navigate to="/login" replace state={{ from: "/app" }} />;
}
return <Outlet />;
}
This keeps the guard from making route decisions before the app has enough information. It also gives product teams a better place to show a loading shell instead of flashing protected content and then replacing it.
The return path matters too. After login, send people back to the page they originally requested when that destination is still valid. Hard-coding every successful sign-in to /dashboard is easy to ship and bad for task completion. If someone was trying to open /billing/invoices, that intent should survive auth.
For larger apps, it also helps to keep pending and redirect UI lightweight. Route transitions feel faster when shared shells stay mounted and only the content area changes. The same principle shows up in any fast rendering pipeline for UI transitions. Less churn usually feels faster, even before raw metrics improve.
Accessibility is part of routing quality
Route changes are not only visual updates. They also change context for keyboard users and screen reader users, and that has direct UX impact.
A few habits pay off:
- Move focus intentionally: After a route change, send focus to the page heading or main landmark.
- Keep links semantic: Do not replace links with clickable
<div>elements. - Mark the current location clearly:
NavLinkhelps, but the surrounding list and landmark structure still matters. - Avoid surprise redirects: If a session expires, explain what happened on the destination screen.
Fast route transitions are only part of the job. If focus stays behind, active state is unclear, or the app silently sends users somewhere else, the experience feels unstable even when the code is technically correct.
Conclusion and Performance Tuning
The best react router navigation setups do three things well. They use links for user-driven movement, programmatic navigation for event-driven flows, and nested layouts for shared structure. After that, the biggest gains come from not making users download or wait for more than they need.

Lazy-load route components
Large apps shouldn’t ship every screen on first load. Route-based code splitting is one of the cleanest wins.
import { lazy, Suspense } from "react";
const Settings = lazy(() => import("./Settings"));
const Billing = lazy(() => import("./Billing"));
function AppRoutes() {
return (
<Suspense fallback={<div>Loading...</div>}>
{/* routes here */}
</Suspense>
);
}
This keeps the initial bundle smaller and pushes less-used screens to the moment they’re needed.
Use navigation state for pending UI
The other strong upgrade is handling pending and optimistic states well. React Router’s useNavigation() hook helps distinguish idle, loading, and submitting states. The official docs note that using navigation state for optimistic UI can reduce perceived latency during form submissions by up to 25%, but also that 70% of top-voted issues relate to pending states not syncing correctly (useNavigation docs).
That’s the trade-off. Optimistic UI feels great when it’s honest. It feels broken when pending state, cache state, and server response drift apart.
A production-minded checklist
- Split route bundles: Load heavy screens on demand.
- Show pending feedback: Buttons, forms, and page regions should communicate work in progress.
- Validate dynamic params early: Fail fast with proper 404 or error routes.
- Be careful with replace navigation: It’s useful, but overuse can make history behavior confusing.
- Keep route ownership clear: Let route boundaries own URL parsing and layout decisions.
For teams working on rendering and preview-heavy React apps, performance is rarely one trick. It’s the combination of routing, bundle strategy, and smart async handling. This breakdown of a fast rendering pipeline is a good companion read if your app does heavier client or rendering work.
If your app feels awkward to move through, routing is often part of the reason. Clean route trees, reliable guards, dynamic params, pending states, and accessible transitions add up. That’s what users remember.
If you're building video-driven products, campaigns, or preview-heavy React workflows, RemotionAI is worth a look. It turns plain-language prompts into real Remotion React code, supports instant previews and iterative edits, and renders production-ready videos without forcing you to hand-build the full pipeline yourself.