Route masking displays a different URL in the browser than the actual route being rendered. This is useful for creating modal experiences where you want a clean, shareable URL while still rendering a nested route.
For example, you might navigate to /photos/5/modal to show a photo in a modal overlay, but display /photos/5 in the browser URL bar. When the user shares that URL or refreshes the page, you control what happens.
Common use cases include:
/photos/[id]/modal but display /photos/[id] in the URLRoute masks are configured in your vite.config.ts under router.routeMasks:
import { one } from 'one/vite'
export default { plugins: [ one({ router: { routeMasks: [ { from: '/photos/[id]/modal', to: '/photos/[id]', params: true, }, ], }, }), ],}Now when you navigate to /photos/5/modal, the browser URL shows /photos/5, but the modal route renders.
You can also configure route masks imperatively in your root layout using setRouteMasks. This is useful when you need custom param transformation functions:
import { setRouteMasks, createRouteMask, Slot } from 'one'
setRouteMasks([ createRouteMask({ from: '/photos/[id]/modal', to: '/photos/[id]', params: (matched) => ({ id: matched.id }), }),])
export default function Layout() { return <Slot />}The createRouteMask function accepts the following options:
The route pattern to match (the actual route being navigated to). Supports dynamic segments.
createRouteMask({ from: '/photos/[id]/modal', // ...})The route pattern to display in the browser URL (the masked URL). Dynamic segments are populated from the matched params.
createRouteMask({ from: '/photos/[id]/modal', to: '/photos/[id]', // ...})Controls how parameters are forwarded to the masked URL:
true (default): Forward all matched paramsfalse: Don’t forward any params// Forward all params (default)createRouteMask({ from: '/users/[userId]/settings/[tab]', to: '/users/[userId]', params: true,})
// Custom transformationcreateRouteMask({ from: '/products/[category]/[id]/edit', to: '/products/[category]', params: (matched) => ({ category: matched.category, // Don't include id in the masked URL }),})Controls what happens when the user refreshes the page or navigates directly to the masked URL:
false (default): Keep showing the actual route content (the modal)true: Show the masked URL’s content instead// SPA-style: Refresh restores the modalcreateRouteMask({ from: '/photos/[id]/modal', to: '/photos/[id]', unmaskOnReload: false,})
// Share-friendly: Refresh shows the photo detail pagecreateRouteMask({ from: '/photos/[id]/modal', to: '/photos/[id]', unmaskOnReload: true,})Encodes the actual route as a base64 postfix in the URL pathname, enabling SSR to render the correct content without a flash.
false (default): Store the actual route in history.state (clean URL, but SSR can’t read it)true: Encode the actual route in the URL as a __ postfix (SSR-safe, no flash on refresh)// SSR-safe: URL includes encoded actual route// URL will look like: /photos/5__L3Bob3Rvcy81L21vZGFscreateRouteMask({ from: '/photos/[id]/modal', to: '/photos/[id]', useSearchParam: true, // Encode actual route in URL for SSR support})When useSearchParam is enabled, the URL includes a __ separator followed by a base64-encoded version of the actual route. This lets the server decode and render the correct route on first load, avoiding any flash of wrong content.
Tradeoff: The URL has a base64 suffix visible (e.g., /photos/5__L3Bob3Rvcy81L21vZGFs), but there’s no query parameter and SSR works correctly.
Here’s a full example with a photo gallery that opens photos in a modal:
import { one } from 'one/vite'
export default { plugins: [ one({ router: { routeMasks: [ { from: '/photos/[id]/modal', to: '/photos/[id]', params: true, unmaskOnReload: false, }, ], }, }), ],}import { Slot } from 'one'
export default function RootLayout() { return ( <html> <body> <Slot /> </body> </html> )}import { Link, Slot } from 'one'
const photos = [ { id: '1', title: 'Photo 1' }, { id: '2', title: 'Photo 2' }, { id: '3', title: 'Photo 3' },]
export default function PhotosLayout() { return ( <div> <h1>Photos</h1> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}> {photos.map((photo) => ( <Link key={photo.id} href={`/photos/${photo.id}/modal`}> {photo.title} </Link> ))} </div>
{/* Modal renders here via Slot */} <Slot /> </div> )}import { useParams, useRouter } from 'one'
export default function PhotoModal() { const { id } = useParams<{ id: string }>() const router = useRouter()
const handleClose = () => { router.navigate('/photos') }
return ( <div className="modal-overlay" onClick={handleClose}> <div className="modal-content" onClick={(e) => e.stopPropagation()}> <h2>Photo {id}</h2> <img src={`/images/photo-${id}.jpg`} alt={`Photo ${id}`} /> <button onClick={handleClose}>Close</button> </div> </div> )}import { useParams, Link } from 'one'
export default function PhotoDetail() { const { id } = useParams<{ id: string }>()
return ( <div> <h1>Photo {id}</h1> <img src={`/images/photo-${id}.jpg`} alt={`Photo ${id}`} /> <Link href="/photos">Back to gallery</Link> </div> )}When you navigate to a masked route:
history.state or encoded in the URL)useSearchParam: false)Uses the browser’s history.state to store the actual route path. The URL is completely clean (e.g., /photos/5), but the server can’t read history.state, so SSR will render the masked URL’s content first, then the client corrects it (causing a flash).
On page refresh:
unmaskOnReload: false: One reads the actual path from history.state and restores itunmaskOnReload: true: One navigates to what the URL shows (the masked URL)useSearchParam: true)Encodes the actual route as a base64 postfix in the URL (e.g., /photos/5__L3Bob3Rvcy81L21vZGFs). The server decodes the postfix and renders the correct route immediately — no flash.
On page refresh:
__ postfix from the URL pathnamevite.config.ts under router.routeMasks for a declarative setupsetRouteMasks() in your root _layout.tsx when you need custom param transformation functionsuseSearchParam: true when you need SSR to render the correct content without a flashunmaskOnReload: true when the masked URL should be shareable as a standalone pageunmaskOnReload: false when you want to preserve the exact app state on refresh[id] and catch-all segments like [...rest]