Next.js Parallel & Intercepting Routes: Modals Done Right
How Next.js parallel routes (@slot) and intercepting routes ((.), (..), (...)) combine to build shareable, refreshable modals — verified against Next.js 16.3.

- 1Next.js Cache Components Explained (with Cheat Sheet)14 min
- 2Next.js Server Actions: Mutations & Security (Cheat Sheet)15 min
- 3Next.js Parallel & Intercepting Routes: Modals Done Rightyou are here
You build a photo grid. Clicking a thumbnail should pop up a modal with the full photo — the feed stays visible and scrolled to where the user left it. useState and a {open && <PhotoModal />} conditional get this working in about ten minutes. Then someone refreshes the page while the modal is open, and the photo is just gone — back to the bare feed, because that boolean lived in memory and the URL never knew a modal was open. Someone else shares the link expecting to send a specific photo, and it opens to... the feed. The modal was never a place; it was a client state flag.
This is one of the few UI problems the App Router's own routing model was built to solve, and it does it with two conventions that are easy to skim past in the docs and hard to use correctly from memory: parallel routes and intercepting routes. This article is written against Next.js 16.3 (the current Active LTS release, verified against the framework's own file-convention docs and its GitHub releases in September 2026); the conventions below have been stable since Next.js 13 and are not part of the newer Cache Components model, so nothing here changes if you're on an app that hasn't adopted cacheComponents yet.
By the end of this article you'll be able to:
- Explain what a parallel route slot (
@slot) actually is, and why it doesn't add a segment to the URL - Use
default.tsxcorrectly, and explain exactly when Next.js needs it and why its absence produces a 404 - Read the
(.),(..),(..)(..), and(...)intercepting-route matchers and know which one a given folder move needs - Combine both conventions to build a modal that has a real, shareable, refreshable URL
- Recognize the difference between a client-side navigation into an intercepted route and a hard navigation to the same URL, and why they render different things on purpose
You've built at least a small App Router project — you know what page.tsx and layout.tsx do, and you've used <Link> for client-side navigation. You don't need any prior experience with parallel or intercepting routes; we build both from nothing.
- The problem: a modal that isn't really a place
- The mental model: slots, and routes that fill them differently
- Stage 1: a parallel route slot on its own
- Stage 2: default.tsx and the 404 it prevents
- Stage 3: intercepting the photo route into the slot
- Stage 4: closing the modal
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
Here's the naive version, and it's genuinely how most people reach for this first:
This works exactly as long as the user never leaves the tab. The moment they do any of the following, it falls apart:
- Refresh the page.
openPhotowas never anywhere but React state — it's gone. The URL is still just/feed. - Share the link. There's nothing to share; the modal was never addressable.
- Use the back button. The browser doesn't know a modal was "opened" — there's no history entry for it.
The fix people reach for next is a separate route, /photo/[id]/page.tsx. That solves the URL problem, but now clicking a thumbnail navigates away from the feed entirely — the grid, its scroll position, and any in-flight state are gone, replaced by a page whose whole job is to show one photo. You've traded "not a real place" for "a real place that destroys the one you were just looking at."
What you actually want is a route that is real — refreshable, shareable, back-button-able — but that, when reached by clicking a link from the feed, renders as an overlay on top of the feed instead of replacing it. That's not a state management problem. It's a routing problem, and Next.js has a routing answer.
The mental model: a layout can have more than one independently-rendered subtree — Next.js calls each one a slot, written as a folder named @slotname. A slot is not a route segment; it doesn't appear in the URL and doesn't count as a level of nesting for anything else in the app. It exists purely so a layout can accept several pieces of UI as named "slots" and place them wherever it wants, each one navigable on its own.
An intercepting route is the second, separate piece: a way for one route to say "when the user gets to me by clicking a link from somewhere specific, render this UI instead of the destination's normal page — but if they land on me any other way (a fresh visit, a refresh, a shared link), render the real thing." The folder name encodes how far away "somewhere specific" is, using a dot convention measured in route segments: (.) the same level, (..) one level up, (..)(..) two levels up, (...) all the way from the app's root. Because slots aren't segments, they don't count when you're counting dots — this is the detail that trips people up first, and it's covered below.
Put together: the feed's layout gets a @modal slot. Normally that slot renders nothing. A link to /photo/[id] from inside the feed gets intercepted and rendered into the @modal slot as an overlay — same URL, same address bar, same shareable link, but rendered as a modal because of how the user arrived. Land on /photo/[id] directly, and the intercepting route steps aside; the real, full /photo/[id]/page.tsx renders instead.
Start with just the slot mechanic, no interception yet. A layout can declare extra props beyond children by naming folders @something:
Key concept: @analytics and @team are props on the layout, matched by folder name, not routes a visitor can navigate to directly. Each one is its own subtree with its own loading.tsx and error.tsx if you want them — the analytics panel can stream in behind its own <Suspense> boundary while the team panel is already sitting there rendered, because Next.js renders each slot independently. This alone is useful even with zero interception: it's how you give one section of a page its own loading and error state without wrapping the whole route in a single boundary.
Slots need a default.tsx for a specific reason: Next.js has to render something in every slot on every request, and on a hard navigation — a fresh visit, a refresh, a link from outside the app — it has no idea what a slot "was previously showing." It can only know that from client-side navigation history. So it needs a fallback per slot to fall back to when it has nothing else to go on.
Skip this file, and a hard navigation to a route that doesn't explicitly fill every slot renders a 404 for the whole page — not a silently empty slot, a 404. This is the single most common first bug with parallel routes, and it looks nothing like its cause: a page that works fine when you click into it from elsewhere in the app, then 404s the instant you hit refresh.
Now the actual feature. The full, real photo page lives at its own route:
The folder (.)photo sits inside feed/@modal. Reading the dot convention: (.) matches a segment at the same level — and because @modal is a slot, not a segment, "the same level" here means the same level as feed itself. That's the detail from the mental model section made concrete: if @modal counted as a level, you'd reach for (..) instead, and it would be wrong.
Click a <Link href="/photo/42"> from inside the feed, and Next.js's client-side router resolves it through the interception: the URL becomes /photo/42, but the component that renders is the one in @modal/(.)photo/[id]/page.tsx — layered over the still-mounted feed. Paste that same /photo/42 URL into a new tab, or hit refresh while it's open, and there's no "previous client-side location" to intercept from — Next.js renders the real app/photo/[id]/page.tsx instead, full-page, no feed underneath.
Key concept: the interception only fires for a client-side navigation whose previous route matches the dot-convention target. The URL is identical either way; only how you arrived decides which component runs. That's what makes the link genuinely shareable — the person you send it to always gets the real, full page, never a modal with no feed behind it.
Closing is just a navigation back to a URL that doesn't render the intercepted route — most simply, the browser back button, or a <Link> back to /feed, or router.back() from a close button:
Once the route no longer matches (.)photo/[id], the @modal slot falls back to its default.tsx — which renders null — and the modal disappears while the feed underneath was never unmounted.
Runs right in your browser — poke at it and watch the concept react live.
- Slots don't count toward the dot level. This is the error that produces no error message — the interception simply never fires, and a link just does an ordinary full navigation. If a dot convention "should" work by folder depth but silently doesn't, recount the levels using only real route segments, ignoring every
@slotfolder in between. - A missing
default.tsx404s on hard navigation, not on client navigation. Test parallel routes with an actual page refresh, not just by clicking around — clicking around is exactly the case that already works. - Parallel slots render sequentially within their shared layout, not concurrently with each other in the sense of wall-clock overlap on the server — each one still needs its own render pass. Three heavy slots are three render passes, not one; give the expensive ones their own
loading.tsxso the cheap ones don't wait behind them. - Route groups aren't slots. A folder in parentheses without an
@, like(marketing), organizes routes without adding a segment — a different feature that happens to share the "doesn't affect the URL" property. Don't reach for one when you mean the other.
- Reach for this when the UI is genuinely two things at once: a list and an overlay detail, a page and a login prompt, a cart and a drawer — cases where the underlying page must stay mounted and the overlay needs its own shareable URL.
- Skip it for UI that has no reason to be a URL — a confirm-delete dialog, a tooltip, a dropdown. Reaching for parallel + intercepting routes there is solving a problem you don't have; plain component state is simpler and correct.
- Always ship the real route. The whole value of this pattern comes from the full page at
/photo/[id]existing and being correct on its own — never make it a stub that assumes it's always reached through the modal. - Give each slot its own loading and error boundaries rather than one boundary for the whole layout — that's what lets, say, an analytics panel stream independently of a sidebar that's already ready.
No. A slot folder (@modal, @analytics) is a prop-passing mechanism for the layout above it, not a route a visitor can navigate to, and it adds no segment to the URL.
A hard navigation (a fresh visit or a refresh) to any route that doesn't explicitly fill that slot renders a 404 for the whole page, because Next.js has no client-side history to fall back on and no explicit fallback to use instead.
Yes — (..) for one segment up, (..)(..) for two, and (...) to intercept all the way from the app's root, however many real segments that spans. Count only actual route segments; folders that are slots or route groups don't add to the count.
No, and that's the point. A client-side modal (a portal plus some open/close state) has no URL of its own — refreshing or sharing the page loses the modal entirely. Parallel + intercepting routes give the modal a real route, so it survives a refresh and can be shared as a link, while still rendering as an overlay when reached by clicking through the app.
No — parallel routes and intercepting routes are App Router conventions. A Pages Router app building this same pattern has to reach for a client-side modal library or a custom routing layer instead; there's no filesystem equivalent to migrate.
| Convention | Syntax | What it does |
|---|---|---|
| Parallel route slot | @slotname/ folder | Adds a named prop to the parent layout; doesn't add a URL segment |
| Slot fallback | @slotname/default.tsx | Rendered when Next.js has no other match for the slot (required to avoid a 404 on hard navigation) |
| Intercept same level | (.)segment/ | Intercepts a route at the same level as the intercepting folder |
| Intercept one level up | (..)segment/ | Intercepts a route one segment above |
| Intercept two levels up | (..)(..)segment/ | Intercepts a route two segments above |
| Intercept from root | (...)segment/ | Intercepts a route from the app's root, regardless of depth |
| Route group (not a slot) | (name)/ folder | Organizes routes without a @; also adds no URL segment, but carries no slot prop |
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
- A parallel route slot (
@slotname) is a named subtree passed to a layout as a prop — it never appears in the URL and doesn't count as a segment for anything else. - Every slot needs a
default.tsx, or a hard navigation that doesn't fill it renders a 404 for the whole page. - Intercepting routes (
(.),(..),(..)(..),(...)) render different UI for the same URL depending on whether the user arrived by client-side navigation from a matching location or by a hard navigation — the URL itself never lies about what's really there. - Together, they build a modal, drawer, or overlay that is a real, shareable, refreshable route — not client state pretending to be one.
That photo modal from the top of this article can now survive a refresh, get shared as a link, and still feel like an overlay to anyone who clicked their way there — because it was never one thing pretending to be another. It's a route, and a modal, at the same time, and the App Router's file conventions are what make that not a contradiction.
If you've hit a case where the dot-level math didn't add up the way you expected, or a slot 404'd on you before you found default.tsx, drop it in the comments — that's exactly the kind of gotcha worth comparing notes on.
Earlier in this series: Next.js Cache Components Explained covers how the App Router decides what's static, cached, or streamed — a good companion if you're deciding how each slot here should fetch its data. And Next.js Server Actions: Mutations & Security is the natural next step if your modal needs to submit a mutation without a full page navigation.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.