Modal Dialogs Are a Routing Problem: Why Your Overlays Keep Breaking
Most modal bugs aren't styling issues — they're state management issues pretending to be UI. Here's how to treat overlays like routes and stop shipping broken back buttons.

The single most common bug we inherit when auditing a client's frontend isn't a broken form or a sluggish list. It's a modal that opens, closes, and quietly destroys the user's ability to trust the back button. Nine times out of ten, the fix isn't CSS — it's realising the modal was never a component in the first place. It was a route.
The bug you keep shipping
Here's the pattern. A user clicks "Edit profile" in a settings page. A modal opens. They edit a field, get distracted, and hit the browser back button expecting the modal to close. Instead, they get punted two pages back to the dashboard, losing their edits. Or worse: the modal closes, but the URL still points at /settings?edit=true, so a refresh reopens it with stale data.
We've seen variations of this on every stack — Next.js, Remix, plain React, Vue, even server-rendered Rails apps sprinkled with Stimulus. The root cause is always the same: modal open state lives in a component, not in the URL. That means the browser has no idea a modal exists, so history, refresh, deep linking, and share-a-link-to-this-view all fall over.
A modal that can't be linked to, refreshed, or dismissed with the back button isn't a dialog. It's a UI accident.
Why component-local state loses
The default React pattern looks like this:
function ProfilePage() {
const [isEditOpen, setEditOpen] = useState(false);
return (
<>
<button onClick={() => setEditOpen(true)}>Edit</button>
{isEditOpen && <EditModal onClose={() => setEditOpen(false)} />}
</>
);
}
This works for a demo. It fails the moment your product grows real users, because:
- The back button does nothing useful. Browsers navigate history, not component state.
- You can't share a link to "the edit modal open on user 42." Support tickets become guesswork.
- Refresh loses context. Users mid-task hit F5 and lose everything.
- Analytics lie. Your funnel shows one pageview for a screen that was actually three distinct user intents.
- Deep-linking from email or push notifications requires custom glue for every modal.
The alternative — treat the modal as a route — solves all five in one move.
Modals as routes: the mental model
A modal is a view. It has an entry, an exit, its own data dependencies, and its own accessibility contract. That's a route. The fact that it renders on top of another view is a presentation detail, not a state detail.
Most modern routers support this now, either natively or through convention:
- Next.js App Router has parallel routes and intercepting routes designed exactly for this.
@modalslots let a modal render over a page while keeping the underlying route mounted. - React Router v6+ supports the
useLocationbackground-location pattern, where you stash the previous location instateand render the modal route on top. - Remix and TanStack Router both handle nested layouts cleanly enough that you can render a
<Dialog>inside a nested route without unmounting the parent. - SvelteKit has layout routes that give you the same behaviour with
+layout.svelteslots.
The pattern is: the URL changes when the modal opens. Closing the modal navigates back. Refresh reopens it. Sharing the URL sends the recipient straight into the same view.
A concrete React Router example
// App routes
<Routes location={background || location}>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/profile" element={<SettingsPage />} />
</Routes>
{background && (
<Routes>
<Route
path="/settings/profile"
element={<EditProfileModal />}
/>
</Routes>
)}
The background location trick keeps SettingsPage mounted underneath. The modal route renders on top. navigate(-1) or the browser back button closes it cleanly. A cold visit to /settings/profile still works — it just renders the modal over a freshly-mounted settings page.
The three modal categories (and only one of them is a route)
Before you rewrite every overlay in your app, split them into three buckets:
- Navigational modals — edit dialogs, detail views, confirmation flows that could reasonably be their own page. These belong in the router.
- Ephemeral prompts — "Are you sure you want to delete?" These are transient, don't need URLs, and should stay as local state. If a user refreshes mid-confirm, the correct behaviour is to not reopen the dialog.
- System interrupts — session expired, offline banner, forced update. These are global UI state, not routes. They live in a context or store.
Routing the wrong category is its own bug. A confirmation dialog with a URL creates a nasty back-button loop: user confirms, action fires, URL still says ?confirm=true, refresh triggers the dialog again over a now-completed action.
Accessibility doesn't come free
Routing solves history. It doesn't solve focus, escape handling, or screen reader semantics. You still need to:
- Use the native
<dialog>element or a battle-tested primitive like Radix Dialog or React Aria'suseDialog. Both handle focus trapping,aria-modal, and escape correctly. - Return focus to the trigger element on close. If the trigger unmounted (common with routed modals), fall back to a sensible landmark — the main heading, usually.
- Announce the modal's purpose.
aria-labelledbypointing at the modal's heading is the minimum bar. - Respect
prefers-reduced-motionon your open/close transitions. A 200ms scale-and-fade becomes an instant swap.
The focus-return gotcha with routed modals
Because the trigger button and the modal live in different route trees, the usual "store a ref to the trigger, focus it on unmount" approach breaks. Two options that actually work:
- Focus a stable landmark on close — the section heading the modal was launched from. Screen reader users get context, keyboard users get a predictable anchor.
- Use
document.referrer-style logic: on modal mount, capturedocument.activeElement. On close, if that element is still in the DOM, focus it. Otherwise fall back to the landmark.
We lean on the second pattern with a small hook and it's held up across a few production apps.
What changes on the design side
This isn't purely an engineering decision. If you're routing modals, your design team needs to think about:
- Empty-state rendering. What does
/orders/1234look like when someone lands cold, before the underlying orders list has data? Design the background state, don't leave it blank. - Titles and meta. Routed modals can — and should — update
<title>. "Edit profile — Acme" is more useful in a tab strip than "Settings — Acme" repeated four times. - Loading choreography. The modal shell should render immediately, with content streaming in. Otherwise the URL changes and the user stares at nothing for 400ms wondering if they clicked.
This is the kind of design-engineering handoff we spend a lot of time on when we're building product surfaces — the stuff that separates a UI that demos well from one that survives a week of real use.
The migration path
Don't rewrite everything at once. In our experience, the order that works:
- Audit your modals and bucket them into the three categories above.
- Pick the highest-traffic navigational modal — usually an edit or detail view. Move that one first.
- Add analytics events on modal open/close using the route change, not the component mount. You'll finally see accurate funnels.
- Standardise on one dialog primitive across the app before you scale the pattern. Mixing Radix, Headless UI, and a bespoke component multiplies your accessibility surface area.
- Document the decision. Write down when a modal gets a route and when it doesn't. Otherwise the next engineer will guess wrong.
Where we'd start
Open your app, pick your most-used modal, and try three things: refresh the page while it's open, share the URL with a colleague, and press the browser back button. If any of those do the wrong thing, you've got a routing bug wearing a dialog costume. Fix that one first, measure the support-ticket drop over the next month, then work outward. The pattern pays for itself faster than almost any other frontend refactor we've shipped.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Form Errors Are a Timing Problem: When to Validate, When to Shut Up
Most form validation fails not because the rules are wrong, but because the timing is. Here's a field-tested model for when to validate, when to wait, and when to say nothing at all.

Toast Notifications Are a Memory Test: Designing Feedback That Respects Attention
Toasts fade in three seconds and expect users to catch, read, and act on them. Here's how we redesigned notification patterns to stop punishing distracted users and keyboard-only users alike.

Design Tokens That Survive Contact With Engineering: A Naming Convention That Scales
Most design token systems die at the second theme. Here's a three-tier naming convention that survives dark mode, brand refreshes, and the inevitable Tailwind migration.
