intercepted-route-using-tanstack-start
GitHub实现 TanStack Start 中的拦截路由模式,支持客户端导航时以模态框展示但 URL 保持真实路径,刷新后渲染完整页面。
Trigger Scenarios
Install
npx skills add Melvynx/Parler --skill intercepted-route-using-tanstack-start -g -y
SKILL.md
Frontmatter
{
"name": "intercepted-route-using-tanstack-start",
"description": "Implement intercepted\/modal routes in NowStack TanStack Start using route masks, real shareable URLs, refresh-to-full-page behavior, and browser verification."
}
Intercepted Route Using TanStack Start
Use this skill when a UI should open as a modal during client navigation while the browser URL shows the real destination path, and a refresh or direct visit should render the destination as a full page.
This is the TanStack Start equivalent of a Next.js intercepted route. Do not use ?modal=... as the visible URL for this UX.
Desired Behavior
For a list-to-detail flow:
- User starts on
/items. - User clicks an item.
- Browser URL becomes
/items/$id. - UI still shows
/itemscontext with a modal on top. - Back closes the modal and returns to
/items. - Refresh or direct visit on
/items/$idrenders the full detail page.
For a global modal like sign-in:
- User starts on
/docs. - User clicks
Sign in. - Browser URL becomes
/auth/signin. - UI shows the sign-in modal over
/docs. - Back or close returns to
/docs. - Refresh on
/auth/signinrenders the full sign-in page.
Core Pattern
Navigate to the source route internally, store modal state in that source route's search, and mask the browser URL as the real destination route.
<Link
to="/changelog"
search={{ entry: changelog.slug }}
mask={{
to: "/changelog/$slug",
params: { slug: changelog.slug },
unmaskOnReload: true,
}}
resetScroll={false}
>
{changelog.attributes.title}
</Link>
The source route owns the modal state:
type ChangelogSearch = {
entry?: string;
};
export const Route = createFileRoute("/(layout)/changelog/")({
validateSearch: (search: Record<string, unknown>): ChangelogSearch => ({
entry:
typeof search.entry === "string" && search.entry.length > 0
? search.entry
: undefined,
}),
loader: changelogLoader,
component: ChangelogPage,
pendingComponent: ChangelogPageSkeleton,
});
function ChangelogPage() {
const { changelogs } = Route.useLoaderData();
const { entry } = Route.useSearch();
return <ChangelogTimeline changelogs={changelogs} selectedSlug={entry} />;
}
The destination route remains a normal route. It must be usable on direct visit and refresh:
export const Route = createFileRoute("/(layout)/changelog/$slug/")({
loader: async ({ params }) => {
const item = await loadItem(params.slug);
if (!item) throw notFound();
return item;
},
component: ChangelogDetailPage,
pendingComponent: ChangelogDetailPageSkeleton,
});
Closing the Modal
Do not blindly call router.history.back() for every close. Only do that when the current location is masked.
const router = useRouter();
const location = useLocation();
const closeModal = () => {
if (location.maskedLocation) {
router.history.back();
return;
}
void router.navigate({
to: "/changelog",
search: {},
replace: true,
});
};
Use this close handler for dialog onOpenChange.
Opening the Full Page from the Modal
If the modal has an "Open page" action, avoid firing the close handler first. Closing a masked modal usually calls history.back(), which races with the full-page navigation and can return to the source page.
void router.navigate({
to: "/changelog/$slug",
params: { slug },
replace: true,
});
If the same dialog component is also used outside an intercepted route, add explicit props:
<ChangelogDialog
changelog={selectedChangelog}
openPageReplace
closeOnOpenPage={false}
onOpenChange={(open) => {
if (!open) closeModal();
}}
/>
Global Modal Variant
For global modals mounted at the root, keep the visible URL masked but the internal state on the current route:
<Link
to="."
search={(previous) => ({ ...previous, modal: "signin" })}
mask={{ to: "/auth/signin", unmaskOnReload: true }}
>
Sign in
</Link>
The root/global dialog reads the internal search state:
const search = useSearch({ strict: false }) as { modal?: string };
const isOpen = search.modal === "signin";
Close with the same masked-location guard:
const closeDialog = () => {
if (location.maskedLocation) {
router.history.back();
return;
}
void router.navigate({
to: ".",
search: (previous) => ({ ...previous, modal: undefined }),
replace: true,
});
};
Rules
- Always keep the destination route as a real route with its own loader, SEO/head metadata when needed, and
pendingComponent. - Use
mask={{ to: realDestination, unmaskOnReload: true }}for refresh-to-full-page behavior. - Keep modal state internal to the source route search, not visible as
?modal=...in the browser URL. - Use
location.maskedLocationbefore deciding whether close should callhistory.back(). - Do not call a modal close handler immediately before "open full page" navigation from inside the modal.
- Preserve callback/search data in the internal route search when the modal flow needs it.
- Add or update e2e coverage for click, close/back, refresh, and direct destination visit.
Verification
For routes/UI flows, use the repo workflow:
pnpm ts
pnpm lint:ci
pnpm start-all -p <port>
PLAYWRIGHT_TEST_BASE_URL=http://localhost:<port> HEADLESS=TRUE pnpm exec playwright test e2e/<spec>.ts
Also verify manually with dev-browser when behavior matters:
- Visit the source page.
- Click the masked/intercepted link.
- Confirm the browser URL is the destination URL.
- Confirm the modal is visible.
- Refresh.
- Confirm the modal is gone and the full destination page is visible.
Existing References
- Changelog route mask:
src/features/changelog/changelog-timeline.tsx - Changelog source route search:
src/routes/(layout)/changelog/index.tsx - Changelog destination route:
src/routes/(layout)/changelog/$slug/index.tsx - Sign-in global modal mask:
src/features/auth/sign-in-button.tsx - Sign-in global dialog close behavior:
src/features/auth/sign-in-dialog.tsx - E2E examples:
e2e/changelog.spec.ts,e2e/signin-modal.spec.ts
Version History
- 1aaa38b Current 2026-08-20 07:35


