Skip to content

Dynamic and Nested Routes Interview Questions & Answers

20 questions Updated 2026-06-24 Share:

React Router v6 dynamic and nested routes interview questions — useParams, Outlet, layout routes, index routes, useOutletContext, splat routes, and data loaders.

Read the in-depth guideReact Router v6 Dynamic & Nested Routes — Complete Interview Guide(opens in new tab)
20 of 20

A dynamic segment is a path token prefixed with :. React Router captures whatever the user types in that position and makes it available via useParams(), which returns an object keyed by segment name.

// Route definition
<Route path="/users/:userId" element={<UserProfile />} />

// Inside UserProfile
import { useParams } from 'react-router-dom';

function UserProfile() {
  const { userId } = useParams(); // e.g. "42"
  // userId is always a string — coerce if you need a number
  return <h1>User {userId}</h1>;
}

Rule of thumb: Name segments descriptively (:userId, not :id) so reading useParams() reads like documentation.

useParams() always returns strings, even when the URL contains what looks like a number. This is a common source of subtle bugs when comparing params with strict equality (===) against numbers.

function PostDetail() {
  const { postId } = useParams(); // "5" — a string

  // Bug: 5 === "5" is false in JavaScript
  const post = posts.find(p => p.id === postId); // undefined!

  // Fix: coerce before comparing
  const post2 = posts.find(p => p.id === Number(postId));

  return <div>{post2?.title}</div>;
}

Rule of thumb: Always coerce params at the top of the component — Number(id), parseInt(id, 10), or validate with Zod — before passing them to any data layer.

Yes. A path can contain multiple dynamic segments, each with a unique name. useParams() returns all of them in one object.

// Route definition — two segments
<Route path="/orgs/:orgId/repos/:repoId" element={<RepoDetail />} />

function RepoDetail() {
  const { orgId, repoId } = useParams();
  // e.g. orgId = "acme", repoId = "dashboard"

  return (
    <p>
      Org: {orgId} / Repo: {repoId}
    </p>
  );
}

Rule of thumb: Destructure all params at once at the top of the component; if you need more than three segments, reconsider whether a flatter URL design would be clearer.

A splat route (also called a catch-all) uses * as the final segment. It matches everything after that point, and the captured string is available as params["*"]. It is most often used for custom 404 pages or legacy URL migration.

// Catch-all at the root level — renders for any unmatched path
<Route path="*" element={<NotFound />} />

// Catch-all inside a layout — only unmatched paths under /docs
<Route path="/docs">
  <Route index element={<DocsHome />} />
  <Route path="*" element={<DocsFallback />} />
</Route>

function DocsFallback() {
  const params = useParams();
  // params["*"] = "api/v2/missing-page"
  return <p>Could not find docs page: {params["*"]}</p>;
}

Rule of thumb: Place the * route last among siblings — React Router matches in definition order and will never reach it if an earlier route already matches.

Append ? to any dynamic segment token to make it optional. The param will be undefined when the segment is absent.

// Both /search and /search/advanced match this route
<Route path="/search/:mode?" element={<Search />} />

function Search() {
  const { mode } = useParams();
  // mode is "advanced" | undefined

  return (
    <div>
      {mode === 'advanced' ? <AdvancedFilters /> : <BasicSearch />}
    </div>
  );
}

Rule of thumb: Prefer two explicit routes (/search and /search/:mode) over an optional segment when the two layouts differ significantly — it keeps each component focused.

<Outlet /> is a placeholder rendered by a parent route component that tells React Router where to inject the matched child route's element. Without it, child routes render nowhere — the URL changes but nothing appears on screen.

// Route config — Dashboard wraps two child routes
<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">
      <nav>
        <NavLink to="/dashboard">Overview</NavLink>
        <NavLink to="/dashboard/settings">Settings</NavLink>
      </nav>
      {/* child route renders here */}
      <main><Outlet /></main>
    </div>
  );
}

Rule of thumb: Every parent route element that has <Route> children must render <Outlet />; forgetting it is the most common nested-route bug.

A layout route is a <Route> that has an element for shared UI (nav, sidebar, wrappers) but whose path is intentionally omitted or set to the parent prefix. It exists purely to provide structure — it never matches by itself; children match the actual URLs.

// createBrowserRouter style — no path on the layout route
const router = createBrowserRouter([
  {
    element: <AppShell />,   // nav + footer — no "path" key
    children: [
      { path: '/',          element: <Home /> },
      { path: '/about',     element: <About /> },
      { path: '/blog/:slug', element: <BlogPost /> },
    ],
  },
]);

// AppShell renders Outlet — no URL segment consumed
function AppShell() {
  return (
    <>
      <GlobalNav />
      <Outlet />
      <Footer />
    </>
  );
}

Rule of thumb: Omit path on a layout route entirely; adding path="" also works but is less readable.

An index route is a child route with the index prop instead of a path. It renders inside the parent's <Outlet /> when the URL matches the parent's path exactly — acting as the default child.

<Route path="/team" element={<TeamLayout />}>
  {/* renders at /team exactly */}
  <Route index element={<TeamOverview />} />
  {/* renders at /team/:memberId */}
  <Route path=":memberId" element={<MemberDetail />} />
</Route>

// Without the index route, navigating to /team shows
// TeamLayout with an empty Outlet — a blank content area.

Rule of thumb: Every layout route that owns an <Outlet /> should have an index route; otherwise the parent path renders a blank content area.

In React Router v6, child path values are relative by default — they are appended to the parent's path. A leading / makes a path absolute, bypassing the nesting entirely, which is usually a mistake inside a nested <Route> tree.

<Route path="/app" element={<AppLayout />}>
  {/* relative — matches /app/profile */}
  <Route path="profile" element={<Profile />} />

  {/* absolute — matches /settings, NOT /app/settings
      The parent layout is still rendered because it is
      an ancestor in the tree, but the URL ignores /app */}
  <Route path="/settings" element={<Settings />} />
</Route>

// Link usage follows the same rule:
// <Link to="profile">   — relative, resolves to /app/profile
// <Link to="/profile">  — absolute, resolves to /profile

Rule of thumb: Never start a nested child path with /; reserve absolute paths for top-level routes only.

Each level adds one more <Route> wrapper and one more <Outlet /> in the corresponding component. React Router renders the entire ancestor chain, threading <Outlet /> down the tree.

const router = createBrowserRouter([
  {
    // Level 1 — app shell (nav + footer)
    element: <AppShell />,
    children: [
      {
        // Level 2 — section layout (sidebar)
        path: 'docs',
        element: <DocsLayout />,
        children: [
          { index: true, element: <DocsHome /> },
          {
            // Level 3 — detail page
            path: ':slug',
            element: <DocPage />,
          },
        ],
      },
    ],
  },
]);

// AppShell renders <Outlet /> → DocsLayout renders <Outlet /> → DocPage

Rule of thumb: Keep nesting to three levels maximum; deeper trees are hard to reason about and usually signal that the URL design needs flattening.

Pass a value to <Outlet context={...} /> in the parent and read it with useOutletContext() in any descendant. This avoids threading props through multiple intermediate components.

// Parent layout — fetches the user and passes it down
function DashboardLayout() {
  const user = useCurrentUser(); // some hook or loader result

  return (
    <div>
      <DashboardNav user={user} />
      {/* provide user to all child routes */}
      <Outlet context={{ user }} />
    </div>
  );
}

// Child route — reads the context
import { useOutletContext } from 'react-router-dom';

function ProfilePage() {
  const { user } = useOutletContext();
  return <h1>Hello, {user.name}</h1>;
}

Rule of thumb: Type the context with a custom hook — export function useDashboardCtx() { return useOutletContext<DashCtx>(); } — so TypeScript catches mismatches.

Add a splat child route (path="*") inside the parent's children. It matches any unrecognised path under the parent and renders inside <Outlet />, so the layout's navigation remains visible.

<Route path="/app" element={<AppLayout />}>
  <Route index element={<Home />} />
  <Route path="posts" element={<Posts />} />
  <Route path="posts/:id" element={<PostDetail />} />

  {/* catches /app/anything-unrecognised */}
  <Route path="*" element={<NotFound />} />
</Route>

function NotFound() {
  return (
    <div>
      <h2>404 – Page not found</h2>
      <Link to="/app">Back home</Link>
    </div>
  );
}

Rule of thumb: Add a path="*" child to every significant layout; relying on only a root-level catch-all means users lose their navigation context when they land on a 404.

Route params (:id) are part of the URL path and identify a resource — they are read with useParams(). Search params (?sort=asc&page=2) are query string key-value pairs used for filtering, sorting, or pagination — they are read and written with useSearchParams().

// URL: /products/42?color=blue&size=M

function ProductDetail() {
  const { productId } = useParams();       // "42"
  const [searchParams, setSearchParams] = useSearchParams();

  const color = searchParams.get('color'); // "blue"
  const size  = searchParams.get('size');  // "M"

  function sortByPrice() {
    // updates the query string without a full navigation
    setSearchParams({ sort: 'price' });
  }

  return <p>{productId} — {color} / {size}</p>;
}

Rule of thumb: Use route params for identity (which resource), search params for state (how to display it) — mixing them leads to bloated URLs and broken back-button behaviour.

createBrowserRouter is the modern Data API introduced in v6.4. It accepts a plain-object route tree and unlocks loaders, actions, errorElement, and defer at each route. The older <Routes>/<Route> JSX approach still works but cannot use any Data API features.

// Object config — enables loaders and actions
const router = createBrowserRouter([
  {
    path: '/posts/:id',
    element: <PostDetail />,
    loader: ({ params }) => fetchPost(params.id), // data API
    errorElement: <PostError />,
  },
]);
// Render: <RouterProvider router={router} />

// JSX approach — no loader support
function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/posts/:id" element={<PostDetail />} />
      </Routes>
    </BrowserRouter>
  );
}

Rule of thumb: Use createBrowserRouter for all new projects; it strictly supersedes the JSX approach and is what the React Router team recommends going forward.

A loader is an async function attached to a route in the object config. React Router calls it before rendering the route, passing { params, request }. The component reads the resolved data via useLoaderData() — no useEffect or loading state needed.

// Define the loader
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(); // returned value becomes loader data
}

// Attach it to the route
const router = createBrowserRouter([
  {
    path: '/posts/:postId',
    element: <PostDetail />,
    loader: postLoader,
    errorElement: <PostError />,
  },
]);

// Consume in the component
import { useLoaderData } from 'react-router-dom';

function PostDetail() {
  const post = useLoaderData(); // fully resolved — no loading state
  return <h1>{post.title}</h1>;
}

Rule of thumb: Throw a Response with an appropriate status code from a loader on errors — React Router will render errorElement and useRouteError() can read the response status.

When a loader or renderer throws, React Router walks up the route tree looking for the nearest ancestor that declares an errorElement. The first match renders in place of the failed subtree, preserving all ancestor layouts above it.

const router = createBrowserRouter([
  {
    element: <AppShell />,        // no errorElement — bubbles further up
    children: [
      {
        path: 'docs',
        element: <DocsLayout />,
        errorElement: <DocsError />, // catches errors in any docs child
        children: [
          {
            path: ':slug',
            element: <DocPage />,
            loader: docLoader,     // throws 404 → DocsError renders
          },
        ],
      },
    ],
  },
]);

function DocsError() {
  const error = useRouteError(); // the thrown Response or Error
  return <p>Docs error: {error.statusText}</p>;
}

Rule of thumb: Add an errorElement at every meaningful layout boundary so a child failure shows a scoped error UI instead of wiping the whole page.

Use the useNavigate() hook. Call the returned function with the full path string (interpolate params yourself) or with a relative path and a replace flag when you want to skip history entries.

import { useNavigate } from 'react-router-dom';

function UserList({ users }) {
  const navigate = useNavigate();

  function handleSelect(userId) {
    // absolute path with the param interpolated
    navigate(`/users/${userId}`);

    // replace current history entry (e.g. after a form submission)
    // navigate(`/users/${userId}`, { replace: true });
  }

  return (
    <ul>
      {users.map(u => (
        <li key={u.id} onClick={() => handleSelect(u.id)}>
          {u.name}
        </li>
      ))}
    </ul>
  );
}

Rule of thumb: Prefer <Link> over useNavigate for user-initiated navigation — it is more accessible and handles middle-click/open-in-new-tab correctly.

The cleanest place is inside a loader — parse and validate the param, then throw a redirect Response if it fails. The component never renders with invalid data.

import { redirect } from 'react-router-dom';

async function postLoader({ params }) {
  const id = Number(params.postId);

  // Redirect to 404 layout if id is not a valid integer
  if (!Number.isInteger(id) || id <= 0) {
    throw redirect('/404');           // or throw new Response('', { status: 404 })
  }

  const res = await fetch(`/api/posts/${id}`);
  if (!res.ok) throw new Response('Post not found', { status: 404 });
  return res.json();
}

const router = createBrowserRouter([
  {
    path: '/posts/:postId',
    loader: postLoader,
    element: <PostDetail />,
    errorElement: <PostError />,
  },
]);

Rule of thumb: Validate params in loaders, not in render; by the time the component runs the data should already be known-good.

Create a typed wrapper hook that calls useOutletContext with the correct generic. Export it from the layout file so child routes import the typed version, not the raw hook.

// DashboardLayout.tsx
import { Outlet, useOutletContext } from 'react-router-dom';

interface DashboardCtx {
  user: { id: number; name: string; role: string };
  refetch: () => void;
}

// Typed wrapper — export this, not useOutletContext directly
export function useDashboardCtx() {
  return useOutletContext<DashboardCtx>();
}

export function DashboardLayout() {
  const user = useCurrentUser();
  const refetch = useRefetch();

  return (
    <div>
      <DashboardNav />
      <Outlet context={{ user, refetch } satisfies DashboardCtx} />
    </div>
  );
}

// ProfilePage.tsx — fully typed, no any
import { useDashboardCtx } from './DashboardLayout';

function ProfilePage() {
  const { user, refetch } = useDashboardCtx(); // typed!
  return <h1>{user.name}</h1>;
}

Rule of thumb: Co-locate the typed hook with the layout component that provides the context — it acts as the single source of truth for the context shape.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel