Handling Global Modals in Next.js
Cover photo by A.Rahmat MN on Unsplash
What is wrong with this?
<button onClick={() => setShow(true)}>Open</button>{show && <Modal />}I see this pattern everywhere. It feels simple, but it creates a mess. You end up with local state scattered across components, and suddenly you’re passing setters down five layers deep. It’s fragile. When you want to trigger that modal from a different part of the page, or better yet, make it deep-linkable, this local state approach breaks.
Use the URL instead
Next.js works best when your UI reflects your URL. Instead of toggling a boolean, change the query parameter. I use search params to drive the state. It makes the modal shareable (a feature users actually love) and keeps your state synchronized with the browser history.
'use client';import { useSearchParams } from 'next/navigation';
export default function ModalHandler() { const searchParams = useSearchParams(); const isOpen = searchParams.get('modal') === 'true';
if (!isOpen) return null;
return <div className="modal">Content</div>;}Managing the state
You’ll want a small helper to update the URL without refreshing the whole page. Using router.push with shallow: true (if it were still an option, though we rely on standard navigation now) works. In the App Router, just updating the query string using URLSearchParams inside a useQueryState hook from a library like Nuqs is usually the cleanest path. I’ll admit, sometimes adding an external library feels like overkill for a simple modal, but it saves me from writing the same parsing logic ten times over.
“But why add the complexity of URL state just for a simple notification modal?” You’re right. If it’s a transient, non-navigational alert, local state is fine. I’m talking about data-driven modals here, like edit forms or item previews. You want those to persist when a user hits the back button (or at least, not crash their experience).
The practical implementation
First, define a layout component that checks for that search param. Wrap your main content and place the modal component at the root. This keeps the modal outside your nested layout constraints, preventing weird z-index or overflow issues (which, frankly, are the bane of my existence).
export default function RootLayout({ children }) { return ( <html> <body> {children} <GlobalModalContainer /> </body> </html> );}By pulling the modal out of the local component tree, you stop worrying about which parent container is clipping your view. It’s a small change, but it makes the application feel robust rather than duct-taped together. Does this cover every edge case? Probably not, but it certainly beats prop drilling.