One Logo Pool Ball

Route Masking

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.

Why Use Route Masking?

Common use cases include:

Basic Setup

Route masks are configured in your vite.config.ts under router.routeMasks:

vite.config.ts
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.

Imperative Setup

You can also configure route masks imperatively in your root layout using setRouteMasks. This is useful when you need custom param transformation functions:

app/_layout.tsx
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 />
}

Route Mask Options

The createRouteMask function accepts the following options:

from

The route pattern to match (the actual route being navigated to). Supports dynamic segments.

createRouteMask({
from: '/photos/[id]/modal',
// ...
})

to

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]',
// ...
})

params

Controls how parameters are forwarded to the masked URL:

// Forward all params (default)
createRouteMask({
from: '/users/[userId]/settings/[tab]',
to: '/users/[userId]',
params: true,
})
// Custom transformation
createRouteMask({
from: '/products/[category]/[id]/edit',
to: '/products/[category]',
params: (matched) => ({
category: matched.category,
// Don't include id in the masked URL
}),
})

unmaskOnReload

Controls what happens when the user refreshes the page or navigates directly to the masked URL:

// SPA-style: Refresh restores the modal
createRouteMask({
from: '/photos/[id]/modal',
to: '/photos/[id]',
unmaskOnReload: false,
})
// Share-friendly: Refresh shows the photo detail page
createRouteMask({
from: '/photos/[id]/modal',
to: '/photos/[id]',
unmaskOnReload: true,
})

useSearchParam

Encodes the actual route as a base64 postfix in the URL pathname, enabling SSR to render the correct content without a flash.

// SSR-safe: URL includes encoded actual route
// URL will look like: /photos/5__L3Bob3Rvcy81L21vZGFs
createRouteMask({
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.

Complete Example

Here’s a full example with a photo gallery that opens photos in a modal:

_layout.tsx

Root layout

photos

_layout.tsx

Photo grid layout

index.tsx

Photo list (empty, grid is in layout)

[id]

index.tsx

Photo detail page - /photos/[id]

modal.tsx

Photo modal - /photos/[id]/modal

Vite Config

vite.config.ts
import { one } from 'one/vite'
export default {
plugins: [
one({
router: {
routeMasks: [
{
from: '/photos/[id]/modal',
to: '/photos/[id]',
params: true,
unmaskOnReload: false,
},
],
},
}),
],
}

Root Layout

app/_layout.tsx
import { Slot } from 'one'
export default function RootLayout() {
return (
<html>
<body>
<Slot />
</body>
</html>
)
}

Photos Layout

app/photos/_layout.tsx
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>
)
}

Photo Modal

app/photos/[id]/modal.tsx
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>
)
}

Photo Detail Page

app/photos/[id]/index.tsx
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>
)
}

How It Works

When you navigate to a masked route:

  1. One detects the route matches a mask pattern
  2. The browser URL is updated to show the masked URL
  3. The actual route path is stored (in history.state or encoded in the URL)
  4. The actual route component renders

Default mode (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:

SSR mode (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:

Tips