React Router v6 rewrote the mental model for routing in React applications. Where v5 scattered <Switch> blocks across your component tree, v6 centralises everything into a single, hierarchical route config that mirrors the actual URL hierarchy. Two features sit at the centre of nearly every interview question on the topic: dynamic segments — URL tokens that change per request — and nested routes — parent/child relationships that let you compose layouts out of smaller pieces. Getting both right is what separates candidates who have skimmed the docs from those who have shipped production apps.
This guide walks through every concept interviewers test, with runnable code for each. By the end you will be able to explain useParams, Outlet, useOutletContext, index routes, splat routes, and the Data API loader pattern without hesitation.
Why Dynamic and Nested Routes Matter
Real applications never have purely static URLs. A user profile lives at /users/42, a blog post at /posts/my-post-slug, a repository at /orgs/acme/repos/dashboard. Each of those variable tokens is a dynamic segment — a placeholder in the route pattern that React Router fills in at match time. Without them you would need a separate <Route> for every possible ID, which is obviously impossible.
Nested routes solve a different problem: shared UI. Almost every page in a product shares a navigation bar, a sidebar, or an authentication boundary. Duplicating that shell into every leaf component is brittle. Nested routes let you declare the shared wrapper once at a parent route and render child content inside a designated <Outlet /> slot — the same pattern server-rendered frameworks like Next.js call "layouts."
The two features combine constantly. A /dashboard/:userId/settings URL has a dynamic segment and lives inside at least two layout layers. Interviewers use exactly this kind of path to probe whether you understand both axes.
Dynamic Segments and useParams
Define a dynamic segment by prefixing a path token with :. React Router captures whatever string occupies that position in the URL and makes it available via the useParams() hook.
// Route definition
<Route path="/users/:userId/posts/:postId" element={<PostDetail />} />
// PostDetail.jsx
import { useParams } from 'react-router-dom';
function PostDetail() {
const { userId, postId } = useParams();
// Both values are strings — always. Coerce before comparing to numbers.
const numericPostId = Number(postId);
return <p>User {userId} — Post {numericPostId}</p>;
}
The critical interview trap here: params are always strings. useParams() returns { userId: "42", postId: "7" }, not numbers. A strict equality check like posts.find(p => p.id === postId) silently returns undefined because 7 !== "7" in JavaScript. Coerce at the top of the component — Number(id), parseInt(id, 10), or a Zod parse — before any data lookup.
A splat route extends this idea to catch arbitrary path suffixes. The pattern path="*" matches everything beyond that point and stores it in params["*"]. It is most useful at the end of a route tree for custom 404 pages:
// Catches any URL that nothing else matched
<Route path="*" element={<NotFound />} />
React Router matches routes in definition order, so place your splat last.
Nested Routes, Outlet, and Layout Routes
Nesting routes means placing <Route> elements as children of other <Route> elements. The parent's element renders unconditionally whenever any of its children match; the child's element renders inside the parent via <Outlet />.
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<Overview />} />
<Route path="settings" element={<Settings />} />
</Route>
// DashboardLayout.jsx
import { Outlet, NavLink } from 'react-router-dom';
function DashboardLayout() {
return (
<div className="dashboard">
<aside>
<NavLink to="/dashboard" end>Overview</NavLink>
<NavLink to="/dashboard/settings">Settings</NavLink>
</aside>
{/* child route element renders here */}
<main>
<Outlet />
</main>
</div>
);
}
Forgetting <Outlet /> is the single most common nested-route bug — the URL changes, no error is thrown, but the child simply never appears on screen.
A layout route is a route that exists purely to provide shared UI, with no URL segment of its own. You achieve this by omitting path from the route definition. Using the object-config style:
const router = createBrowserRouter([
{
// no "path" — this is a layout-only route
element: <AppShell />,
children: [
{ path: '/', element: <Home /> },
{ path: '/about', element: <About /> },
],
},
]);
AppShell renders its <Outlet /> and the children match their own paths directly. No URL segment is consumed by the layout.
Index Routes and Catch-All Routes in Nested Layouts
When a user navigates to /dashboard exactly, React Router renders DashboardLayout but finds no matching child route — so <Outlet /> renders nothing, leaving a blank content area. The fix is an index route: a child with index instead of path, which renders at the parent's exact URL.
<Route path="/dashboard" element={<DashboardLayout />}>
{/* renders at /dashboard exactly */}
<Route index element={<Overview />} />
<Route path="analytics" element={<Analytics />} />
</Route>
Every layout that owns an <Outlet /> should have an index route. Without it you are shipping a partial blank page that will confuse users and puzzle your future self.
The counterpart is a nested catch-all — a path="*" child that renders a 404 within the layout. This matters because a root-level splat renders outside all layouts, stripping navigation away from the 404 page. A nested splat keeps the layout visible:
<Route path="/app" element={<AppLayout />}>
<Route index element={<Home />} />
<Route path="posts/:id" element={<PostDetail />} />
{/* 404 inside the layout — navigation stays visible */}
<Route path="*" element={<NotFound />} />
</Route>
Sharing Data with useOutletContext
Sometimes the parent layout fetches data — a logged-in user object, a workspace — that child routes need. Threading it down via props is impossible because React Router, not your code, instantiates the child element. The solution is <Outlet context={...} /> in the parent and useOutletContext() in the child.
// DashboardLayout.jsx
import { Outlet } from 'react-router-dom';
function DashboardLayout() {
const user = useCurrentUser(); // fetch or read from store
return (
<div>
<DashboardNav user={user} />
{/* pass user to every child route */}
<Outlet context={{ user }} />
</div>
);
}
// ProfilePage.jsx
import { useOutletContext } from 'react-router-dom';
function ProfilePage() {
const { user } = useOutletContext();
return <h1>Welcome, {user.name}</h1>;
}
In TypeScript, create a typed wrapper hook and export it from the layout file:
// Export a typed hook — children import this, not useOutletContext directly
export function useDashboardCtx() {
return useOutletContext<{ user: User }>();
}
This gives full IDE autocomplete in child routes and catches shape mismatches at compile time.
Data API Loaders (React Router v6.4+)
The Data API, introduced in v6.4, attaches async loader functions to routes in the object config. React Router calls them before the component renders, passing { params, request }. The component reads the resolved data via useLoaderData() — no useEffect, no loading spinner, no manual error state.
// Loader function — runs before the component
async function postLoader({ params }) {
const res = await fetch(`/api/posts/${params.postId}`);
if (!res.ok) throw new Response('Not Found', { status: 404 });
return res.json(); // this becomes the loader data
}
// Route definition
const router = createBrowserRouter([
{
path: '/posts/:postId',
element: <PostDetail />,
loader: postLoader,
errorElement: <PostError />, // renders when loader throws
},
]);
// Component — data is already resolved
function PostDetail() {
const post = useLoaderData();
return <h1>{post.title}</h1>;
}
Loaders are also the right place to validate params. If :postId is not a valid integer, throw redirect('/404') from the loader before the component ever mounts. The Data API requires createBrowserRouter — the older JSX <Routes> approach does not support loaders or actions.
Common Interview Questions at a Glance
- What type does
useParams()return? AlwaysRecord<string, string>— coerce numbers before comparing. - What happens if a parent route doesn't render
<Outlet />? Child routes match but render nowhere — silent blank content area. - What is an index route? A child with
indexinstead ofpath; renders at the parent's exact URL as the default child. - How do you pass data from a layout to its child routes?
<Outlet context={value} />in the parent,useOutletContext()in the child. - What is a layout route? A route with an element but no
path; provides shared UI without consuming a URL segment. - When do you use
useSearchParamsinstead ofuseParams? Route params for identity (which resource); search params for display state (sort order, pagination, filters). - What does
createBrowserRouterunlock that<Routes>cannot do? Loaders, actions,errorElement,defer, and form-submission handling via the Data API. - How does
errorElementbubble? React Router walks up the route tree to the nearest ancestor witherrorElementand renders it there, preserving all higher layouts.