
Next.js Parallel Routes
Most routing systems assume a page renders one thing at one URL. That assumption breaks down the moment you build a dashboard with independently-updating widgets, a social feed with a persistent sidebar, or a photo gallery where clicking a thumbnail opens a modal that's also a real, shareable, bookmarkable page. Parallel Routes is the App Router convention built specifically for that class of problem: rendering more than one page, side by side, inside the same layout, each with its own navigation history, its own loading state, and its own error boundary.
It's one of the more unusual conventions in the App Router's file-system vocabulary, and it's easy to reach for useState and manual conditional rendering instead the first time you hit this problem. This article walks through what Parallel Routes actually gives you that client-side state doesn't, how the @folder slot convention works mechanically, and the two behaviors — soft navigation and hard navigation — that trip people up the most when they first use it.
What Problem Parallel Routes Actually Solves
Say you're building a dashboard with a team panel and an analytics panel that both live inside /dashboard. The naive approach is one page.tsx that fetches both datasets and renders two components. That works, right up until you want:
- Each panel to have its own loading skeleton, independent of the other.
- Each panel to have its own error boundary, so a failure in analytics doesn't take down team.
- Each panel to be navigable on its own — clicking something inside the analytics panel should update analytics without re-rendering team.
- Both panels to be addressable by URL, so a deep link can restore the exact state the user was looking at.
A single page component with local component state can approximate the first two, but the third and fourth are genuinely hard to bolt on afterward — you'd be reinventing client-side routing state by hand, and getting URL-driven navigation, back-button behavior, and server-side data fetching all synchronized would take real, ongoing engineering effort. Parallel Routes gives you all four for free, because each parallel segment is a first-class route with its own file-system location, not a client-side view switch.
Convention: Slots
Parallel routes are created using slots, defined with the @folder naming convention. Given this structure:
app/
├── layout.tsx
├── page.tsx
├── @team/
│ └── page.tsx
└── @analytics/
└── page.tsx
You've defined two slots: @team and @analytics. Slots are passed to the nearest shared parent layout as props, using the folder name (minus the @) as the prop name:
export default function Layout({
children,
team,
analytics,
}: {
children: React.ReactNode;
team: React.ReactNode;
analytics: React.ReactNode;
}) {
return (
<>
{children}
{team}
{analytics}
</>
);
}
The first thing that surprises people here: children is not special-cased syntax, it's an implicit slot. app/page.tsx is functionally equivalent to app/@children/page.tsx — Next.js just doesn't make you write the @children folder because every route already has a default, un-named slot. Once you internalize that, the layout signature above stops looking like magic — it's just three slots, one of which happens to have a conventional shorthand.
The second thing that surprises people: slots don't appear in the URL. app/@analytics/views/page.tsx doesn't produce /@analytics/views — it produces /views. The slot name is purely a file-system/prop-passing mechanism; it has zero effect on route matching or the address bar. This matters because it means you can't have two slots defining conflicting routes at the same segment level with different rendering strategies — if @analytics renders a route dynamically, every other slot resolves at that segment must also be dynamic. The slots are combined into a single logical page; they can't independently choose static vs. dynamic rendering.
The default.js Fallback
Here's where Parallel Routes gets genuinely tricky, and where default.js earns its keep.
Picture this structure:
app/
├── @team/
│ ├── page.tsx (matches /)
│ └── settings/
│ └── page.tsx (matches /settings)
└── @analytics/
└── page.tsx (matches /)
The @team slot has a /settings sub-route. @analytics does not. Now a user, sitting on /, clicks a link to /settings. What happens to @analytics, which has no matching route for /settings?
If this were a soft navigation (client-side, via <Link> or router.push), Next.js keeps @analytics showing whatever it was last showing — its previously active subpage persists, because the client already has that state in memory. This is actually the behavior you want most of the time: your analytics widget doesn't blank out just because you clicked into settings on an unrelated part of the page.
But if the user hits refresh, or lands on /settings from a fresh page load — a hard navigation — the client has no memory to fall back on. Next.js needs something to render for @analytics at that URL, and it has no matching route. This is exactly the gap default.js fills:
export default function Default() {
return null;
}
If you don't define default.js for a slot and a hard navigation hits an unmatched route, Next.js renders a 404 for the entire page, not just that slot — which is almost never what you want. And because children is an implicit slot, don't forget it needs the same treatment: if your root page can't recover its own active state on a hard navigation and you haven't defined a default.js at that level, you get the same 404 behavior for the primary content, not just a secondary slot.
The practical rule: any slot that has more than one possible subpage needs a default.js. Treat its absence as a bug waiting for a page refresh to surface it, not an edge case you can defer.
Reading the Active Segment: useSelectedLayoutSegment(s)
Both useSelectedLayoutSegment and useSelectedLayoutSegments accept a parallelRouteKey argument specifically so they can tell you which subpage is active within a given slot — useful for building UI (like active-tab styling) that reflects a slot's current state from the layout that owns it:
"use client";
import { useSelectedLayoutSegment } from "next/navigation";
export default function Layout({ auth }: { auth: React.ReactNode }) {
const loginSegment = useSelectedLayoutSegment("auth");
// loginSegment === "login" when the user is on /@auth/login
// ...
}
Without the parallelRouteKey argument, these hooks report the segment for the default (unnamed) slot — so if you're building UI that reacts to a named slot's state, passing the key isn't optional convenience, it's the only way to get the right answer.
Conditional Routes — and a Security Trap the Docs Only Mention in Passing
Parallel Routes lets you swap which slot renders based on a runtime condition, which is a genuinely useful pattern for role-based dashboards:
import { checkUserRole } from "@/lib/auth";
export default function Layout({
user,
admin,
}: {
user: React.ReactNode;
admin: React.ReactNode;
}) {
const role = checkUserRole();
return role === "admin" ? admin : user;
}
Here's the part that's easy to skim past and genuinely important: both slots render on the server, regardless of which one the condition selects. The if statement in the layout decides which output reaches the browser — it does not decide which code runs. @admin/page.tsx executes its data-fetching logic for every request to this layout, admin or not, and that output is generated before the layout even decides whether to show it to the user.
If @admin/page.tsx fetches sensitive data and you're relying on this conditional to be your authorization boundary, you have a bug, not a feature. A regular user's request still causes the admin data fetch to run server-side; it's merely discarded before rendering. The actual fix is to authorize inside each slot's own page (or deeper, in your Data Access Layer) — never treat the layout-level conditional as an access-control mechanism. Think of it purely as a rendering decision made after the work is already done, not a gate that prevents the work from happening.
Tab Groups: Giving a Slot Its Own Sub-Navigation
Because a slot is a real route subtree, you can give it its own layout.tsx to create independently navigable tabs inside it, without affecting anything outside the slot:
import Link from "next/link";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<nav>
<Link href="/page-views">Page Views</Link>
<Link href="/visitors">Visitors</Link>
</nav>
<div>{children}</div>
</>
);
}
Clicking between /page-views and /visitors only re-renders the @analytics subtree. Every other slot on the page — @team, the main children content — is completely untouched, both visually and in terms of network requests. This is the payoff for the added file-system complexity: independent navigation without independent page loads.
Modals That Are Also Real Pages
This is the pattern most people actually reach for Parallel Routes to solve, and it's the one worth understanding in full because it combines Parallel Routes with Intercepting Routes (a related, separate convention).
The goal: clicking a "login" link opens a modal via client-side navigation, but visiting /login directly (or refreshing on it) renders the same content as a real, full page — not a broken modal shell.
Step 1 — the real page. Create /login as an ordinary route that renders the login UI directly, with no modal wrapper:
import { Login } from "@/app/ui/login";
export default function Page() {
return <Login />;
}
Step 2 — the slot's fallback. Inside an @auth slot on the parent layout, add a default.js that renders nothing — this is what shows when the modal isn't active:
export default function Default() {
return null;
}
Step 3 — intercept the route. Inside @auth, use an Intercepting Routes folder to catch client-side navigations to /login and wrap them in a modal instead:
import { Modal } from "@/app/ui/modal";
import { Login } from "@/app/ui/login";
export default function Page() {
return (
<Modal>
<Login />
</Modal>
);
}
The (.) prefix is the Intercepting Routes convention for "match a segment at the same level" — it's what lets a client-side navigation to /login render this intercepted version instead of the real page, while a hard navigation or refresh on /login bypasses the interception entirely and renders the real app/login/page.tsx.
Step 4 — wire it into the layout. Render @auth alongside children in the parent layout, and link to /login normally:
import Link from "next/link";
export default function Layout({
auth,
children,
}: {
auth: React.ReactNode;
children: React.ReactNode;
}) {
return (
<>
<nav>
<Link href="/login">Open modal</Link>
</nav>
<div>{auth}</div>
<div>{children}</div>
</>
);
}
Clicking that link performs a soft navigation, which the interception catches and renders as a modal. Refreshing the browser on /login performs a hard navigation, which skips the interception and renders the plain app/login/page.tsx instead. Same URL, two different rendering paths, decided entirely by navigation type — which is exactly the property that makes this pattern solve the "shareable, refreshable modal" problem that plain client-side modal libraries can't.
Step 5 — closing the modal correctly. The natural instinct is router.back():
"use client";
import { useRouter } from "next/navigation";
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
return (
<>
<button onClick={() => router.back()}>Close modal</button>
<div>{children}</div>
</>
);
}
This works for closing via the browser's own history stack, but it doesn't cover every case — specifically, navigating forward to some other page via <Link> while the modal is open. Because of how Parallel Routes preserves a slot's last-active state during soft navigation (the same behavior discussed in the default.js section), navigating to an unrelated route with a plain <Link> won't automatically make @auth stop rendering the modal — the slot's state doesn't know it should reset. The docs' recommended fix is to explicitly match the slot to a route that renders null for the destinations you care about:
export default function Page() {
return null;
}
Or, to cover any other destination in one shot, a catch-all inside the slot:
export default function CatchAll() {
return null;
}
This is the single most subtle part of the whole pattern: Parallel Routes' "preserve state during soft navigation" behavior, which is a feature everywhere else in this article, becomes a bug specifically for a modal you want to reliably close. The catch-all is the standard workaround, and it's worth adding preemptively rather than discovering the stuck-open-modal bug in production.
Independent Loading and Error UI
Because each slot is its own route subtree, each one streams independently and can define its own loading.tsx and error.tsx, completely decoupled from every sibling slot. A slow analytics query shows its own skeleton without blocking the team panel from rendering the moment its data is ready; a thrown error in one slot's page trips only that slot's nearest error boundary, leaving the rest of the page fully interactive. This is the same streaming/Suspense machinery that powers the rest of the App Router's rendering model — Parallel Routes just gives you a file-system-native way to apply it per-region of a page instead of only per-route.
Common Mistakes
Forgetting default.js on a slot with more than one possible subpage. This is invisible in development if you only ever soft-navigate around your app, and then breaks the moment someone refreshes on a deep sub-route. Test with hard reloads, not just client-side clicks.
Treating a conditional slot render as an authorization boundary. Both branches execute server-side. If a slot's data-fetching code has access to something a user shouldn't see, gate it inside that code — not in the layout that decides which slot to display.
Forgetting the catch-all for a modal slot. If your modal doesn't close reliably when navigating to unrelated pages via <Link>, this is almost always the missing piece — not a bug in the router.
Assuming slots can independently choose static vs. dynamic rendering. They can't, at the same segment level. If any slot needs to be dynamic, plan for the whole segment to be dynamic.
Key Takeaways
| Concept | What it means |
|---|---|
@folder slot | Defines a named parallel route, passed as a prop to the parent layout |
children | An implicit, unnamed slot — every route already has one |
| Slots and the URL | Slot names never appear in the URL; they're purely file-system/prop plumbing |
| Soft navigation | Unmatched slots keep their last-active state |
| Hard navigation | Unmatched slots fall back to default.js, or 404 the whole page without it |
| Conditional slots | Both branches render server-side; authorize inside the slot, not the condition |
| Modals via interception | Combine with Intercepting Routes ((.)folder) for shareable, refreshable modals |
| Closing modals reliably | Use a catch-all route inside the slot that renders null |
| Loading/error UI | Fully independent per slot, same streaming model as the rest of the App Router |
Parallel Routes is more file-system ceremony than most conventions in the App Router, and it's easy to reach for it prematurely on a page that doesn't actually need independently-navigable regions. But for dashboards, feeds, and shareable modals — the class of UI where "more than one thing is happening on this page at once" is the actual product requirement — it replaces what would otherwise be a hand-rolled client-side router with something the framework already understands, streams, and caches correctly.


