DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Why Your Next.js Modal Route Works Perfectly Until Someone Refreshes the Page

This one isn't really a bug, it's a genuinely common point of confusion about a feature working exactly as designed, and understanding why it behaves this way changes how you'd actually build around it, rather than fighting it.

The Pattern: A Photo Grid That Opens Items as a Modal

Intercepting routes are what makes an Instagram-style interaction possible in the App Router, click a photo in a grid, it opens as a modal overlay on top of the current page, without a full navigation, while the URL still updates to something shareable and bookmarkable.

app/
├── @modal/
│   ├── default.tsx
│   └── (.)photo/
│       └── [id]/
│           └── page.tsx    // renders as a modal when intercepted
├── photo/
│   └── [id]/
│       └── page.tsx        // the actual full page
├── layout.tsx
└── page.tsx                 // the grid
Enter fullscreen mode Exit fullscreen mode

Click a photo from the grid, and the (.)photo/[id] route intercepts the navigation, rendering the modal version in the @modal slot on top of the grid, without a full page transition. The URL correctly updates to /photo/123. It feels seamless, and it's a genuinely well-built pattern for exactly this kind of interaction.

Where the Confusion Starts

Someone building this, testing by clicking through the grid, sees exactly the intended modal behavior. Then they refresh the page while the modal is open, or share that /photo/123 URL with someone else, or that someone else pastes it directly into a new browser tab, and instead of the modal, they get the full, standalone photo/[id]/page.tsx page, no grid behind it, no modal chrome, just the plain page rendering on its own.

The natural first reaction is that something's broken, the modal "isn't working" on refresh. It's actually working exactly as designed, just not in the way that first reaction assumes.

Why This Is Correct, Not Broken

Interception is specifically a client-side navigation behavior. It only intercepts a navigation that happens through Next.js's client-side router, clicking a <Link>, calling router.push(), moving from one already-loaded page to another within the same app session. A hard refresh, a direct URL visit, or someone opening that link in a completely fresh browser tab isn't a client-side navigation at all, it's a fresh, full server request for that specific URL, with no prior page state to intercept from, no grid already rendered behind it to interrupt. Next.js correctly falls back to rendering the actual underlying route, the full page, because that's genuinely the only thing that makes sense in a context where there's no previous client-side navigation to interrupt in the first place.

This is why the folder structure includes both the intercepted route ((.)photo/[id], the modal version) and the actual route (photo/[id], the full page) as separate, real routes. The full page isn't a fallback or an error state, it's the intended, correct experience for anyone arriving at that URL directly, someone who shared or bookmarked the link, a search engine crawling it, anyone without an existing client-side session to interrupt.

The Actual Design Implication

This isn't something to work around, it's something to design for deliberately. The full, non-modal version of the route needs to be a genuinely complete, standalone page on its own merits, not a stripped-down fallback that only half-works, since a real share of your actual traffic to that URL, direct visits, refreshes, shared links, search engines, will land there specifically, not on the modal version.

// app/photo/[id]/page.tsx
// This needs to be a genuinely complete page, not an afterthought,
// since direct visits, refreshes, and shared links all land here specifically
export default async function PhotoPage({ params }) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    <div>
      <BackToGridLink /> {/* since there's no grid rendered behind this version */}
      <PhotoDisplay photo={photo} />
      <PhotoMetadata photo={photo} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Including something like a clear way back to the grid, since this version genuinely doesn't have the grid rendered behind it the way the modal does, treats this as the real, complete experience it needs to be for a meaningful share of actual visitors, rather than assuming everyone always arrives via the modal path.

Why This Actually Matters for SEO Too

This split has a genuine upside worth being deliberate about. Since the full page is a real, standalone route, it's exactly what search engines actually crawl and index, a real, complete, server-rendered page for that specific photo, with its own metadata, its own content, rather than something only reachable through a client-side modal interaction a crawler would never trigger. Building the full page version to be genuinely complete isn't just handling an edge case gracefully, it's the version doing real SEO work for that specific piece of content.

The Actual Rule

Intercepting routes are a client-side navigation enhancement layered on top of real, independently functional routes, not a replacement for them. The full, non-intercepted version of the route needs to be built as a genuinely complete experience on its own, since refreshes, direct visits, shared links, and search engine crawlers all land there specifically, not on the modal version, and treating it as an afterthought means a real share of your actual traffic gets an incomplete experience.


If you're building or have built an intercepting route pattern, worth checking whether the full, non-modal version genuinely stands on its own, or was built assuming everyone always arrives through the modal. Drop your experience with this pattern in the comments, curious how many people hit this exact confusion the first time before understanding why it's actually correct.

Get the templates: https://pixelanas.gumroad.com


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)