How toBuild a Master-Detail UI with React Router

Many applications need a master-detail interface where users can browse a list of items and view details of individual items. Think Linear's issue tracker, Notion's page hierarchy, or any dashboard with a sidebar list and main content area.

The challenge is making this work smoothly with direct URL access and page reloads. Users should be able to share /inbox/issue-123 and have it work correctly, but the experience should adapt based on how they accessed it. showing the full layout when browsing, but focusing on just the detail when accessing directly.

TL;DR: Here's a repository with a working example of this tutorial.

Create the Route Structure

The key insight is having two ways to access the same content: one for browsing (/inbox/:issue) and one for direct access (/:issue). Here's the route configuration:

app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/_index.tsx"), route("inbox", "routes/inbox.tsx", [ index("routes/inbox-empty.tsx"), route(":issue", "routes/issue.tsx", { id: "routes/inbox/:issue" }), ]), route(":issue", "routes/issue.tsx", { id: "routes/:issue" }), ] satisfies RouteConfig;

This creates:

  • / redirects to /inbox
  • /inbox shows the inbox layout with empty state
  • /inbox/:issue shows inbox layout + issue detail
  • /:issue shows only the issue detail

Both /inbox/:issue and /:issue use the same route module but render differently based on context.

Create Context to Track Layout State

The issue component needs to know if it's inside the inbox layout or standalone. React Router's context system handles this:

app/context/inbox.ts
import { createContext } from "react-router"; export const InboxContext = createContext<boolean>(false);

This context will be true when rendering inside the inbox layout and false when showing just the detail view.

Build the Inbox Layout

The inbox route module creates the master-detail layout and sets the context:

app/routes/inbox.tsx
import { InboxContext } from "~/context/inbox"; import type { Route } from "./+types/inbox"; import { issues } from "~/data"; import { href, NavLink, Outlet } from "react-router"; export const middleware: Route.MiddlewareFunction[] = [ ({ context }) => { context.set(InboxContext, true); }, ]; export async function loader() { return { issues: Array.from(issues.values()) }; } export default function Component({ loaderData }: Route.ComponentProps) { return ( <div className="flex divide-x divide-black dark:divide-white"> <ol className="w-1/4 p-4"> {loaderData.issues.map((issue) => ( <li key={issue.id}> <NavLink to={href("/inbox/:issue", { issue: issue.id })}> {issue.title} </NavLink> </li> ))} </ol> <div className="w-3/4 p-4"> <Outlet /> </div> </div> ); }

The middleware runs for all nested routes and sets InboxContext to true. The component renders a two-column layout with issues on the left and the detail outlet on the right.

Handle Direct Access with Smart Redirects

The issue route module works in both contexts but includes logic to handle direct URL access:

app/routes/issue.tsx
import { InboxContext } from "~/context/inbox"; import type { Route } from "./+types/issue"; import { href, Link, redirect } from "react-router"; import { issues } from "~/data"; export async function loader({ request, params, context }: Route.LoaderArgs) { let isInbox = context.get(InboxContext); // If we're in the inbox, and it's a document request, redirect to the // non-inbox version of the issue if (isInbox && request.headers.get("Sec-Fetch-Dest") === "document") { return redirect(href("/:issue", params)); } let issue = issues.get(params.issue); if (issue) return { issue, isInbox }; throw new Error("Issue not found"); } export default function Component({ loaderData }: Route.ComponentProps) { return ( <div className="relative data-[isinbox=false]:p-4" data-isinbox={loaderData.isInbox} > {loaderData.isInbox ? ( <Link to={href("/inbox")} className="text-blue-500 underline absolute top-0 right-0" > ⅹ Close </Link> ) : ( <Link to={href("/inbox")} className="text-blue-500 underline absolute top-4 right-4" > ← Back to inbox </Link> )} <h1 className="text-2xl"> {loaderData.issue.title} ({loaderData.issue.id}) </h1> <p className="mt-4">{loaderData.issue.description}</p> </div> ); }

The key is the redirect logic: when someone directly visits /inbox/1 (like typing in the URL or refreshing), it redirects to /1 to show only the detail. But client-side navigation stays in the inbox context.

Add Empty State and Root Redirect

Create an empty state for when no issue is selected:

app/routes/inbox-empty.tsx
export default function Component() { return <h1>👈 Pick an issue</h1>; }

And redirect the root to the inbox:

app/routes/_index.tsx
import { href, redirect } from "react-router"; export async function loader() { return redirect(href("/inbox")); }

Understanding the Redirect Logic

The core of this pattern is the conditional redirect in the issue loader:

if (isInbox && request.headers.get("Sec-Fetch-Dest") === "document") {
  return redirect(href("/:issue", params));
}

The Sec-Fetch-Dest header tells us if this is a full page request (typing URL, refresh, external link) versus client-side navigation. This allows the same URL to behave differently based on how the user accessed it:

  • Direct access to /inbox/1 redirects to /1 (focused view)
  • Clicking link to /inbox/1 stays at /inbox/1 (preserves context)

Why This Works

This pattern adapts to user intent:

  • Browsing users see the full interface with context
  • Direct access users get focused content without distractions
  • URLs remain shareable and bookmarkable

The result is a responsive interface that works whether users are exploring your app or landing on specific content from external sources.