Give a Modal Its Own URL in TanStack Router

I recently built an app whose main screen is a wall of photos. Every item is a card in a masonry grid, and a sidebar of filters narrows the wall down. Click a card, and a detail dialog opens over the grid.

I wanted that dialog to have a URL. Copy the address bar, send it to someone, and they see the same item over the same filtered grid. Refresh, and it is still open. Press back, and it closes. The TanStack Router docs cover layouts and outlets, and the route masking guide mentions a modal route in passing, but none of them spell this case out.
It does come up in the project’s GitHub discussions, so here is what worked. The samples below come from a stripped-down demo: TanStack Start, a static array of items, one color filter, and no auth.

Sibling routes unmount the grid.

My first try made the grid a page and the detail a sibling page:

routes/dashboard/
  index.tsx        the grid
  $itemId.tsx      the detail</pre >

The URLs come out right, and for a minute I thought I was done. /dashboard/item-3 and /dashboard are separate routes, though, so the router unmounts the grid and mounts the detail. The dialog opens over an empty page. Close it and the grid has to fetch and lay itself out again.

Make the grid a layout route and the dialog its child.

The fix is to keep the URLs and change the tree, which took me longer to see than I would like to admit. The grid becomes a layout route that owns the data and renders an <Outlet />. The detail becomes its child.

 

routes/dashboard/
  route.tsx        layout: filter, loader, sidebar, grid, <Outlet />
  index.tsx        matches /dashboard, renders nothing
  $itemId.tsx      matches /dashboard/:id, renders the dialog into the outlet</pre >

Navigating to a child keeps the parent mounted. The grid stays put and the router renders the dialog into the outlet on top of it.

 

The layout reads the filter from the search params, feeds it to the loader, and warms the query cache before anything paints:

 

// routes/dashboard/route.tsx
export const Route = createFileRoute("/dashboard")({
  validateSearch: (search: Record<string, unknown>): Filters => ({
    color: colors.find((c) => c === search.color),
  }),
  loaderDeps: ({ search }) => search,
  loader: ({ context, deps }) =>
    context.queryClient.query(itemsQueryOptions(deps)),
  component: RouteComponent,
});

function RouteComponent() {
  return (
    <div className="grid min-h-dvh grid-cols-[200px_1fr] gap-8 p-8">
      <FilterSidebar />
      <main>
        <MasonryGrid />
        <Outlet />
      </main>
    </div>
  );
}

validateSearch is a plain function here. It also accepts any Standard Schema, so a Zod object drops in once the filters grow.

The index route is the one that felt wrong when I wrote it. It has to exist so that /dashboard matches something, and it renders nothing:

// routes/dashboard/index.tsx
export const Route = createFileRoute("/dashboard/")({
  component: () => null,
});

With no child matched, the outlet stays empty, and you see the plain grid.

The grid reads the filter from the URL.

The filter lives in the search params, so the grid reads it from the route and asks the cache for the matching items, which the loader already fetched:

// components/masonry.tsx
export function MasonryGrid() {
  const search = useSearch({ from: "/dashboard" });
  const { data } = useSuspenseQuery(itemsQueryOptions(search));

  return (
    <ClientOnly fallback={<Skeleton count={Math.min(data.length, 12)} />}>
      <Masonry
        key={JSON.stringify(search)}
        items={data}
        itemKey={(d) => d.id}
        render={MasonryCard}
        columnGutter={16}
      />
    </ClientOnly>
  );
}

Two details. The grid is masonic, which measures the DOM to lay itself out, so it cannot run on the server. ClientOnly renders a skeleton with the right count during SSR and swaps in the grid on the client. The key on Masonry is the serialized filter, so a filter change discards the old layout instead of animating cards into new columns.

Cards carry the filter to the child route.

A card is a Link to the child route. It passes the current search params along so the filter survives the trip:

// components/masonry-card.tsx
export const MasonryCard = ({ data }: { data: Item }) => (
  <Link
    to="/dashboard/$itemId"
    params={{ itemId: data.id }}
    search={(prev) => prev}
  >
    ...
  </Link>
);

I missed this the first time. Without search={(prev) => prev}, opening an item drops the filter, the layout’s loader refetches the unfiltered wall, and the grid behind the dialog changes while you look at it.

The detail preloads its item and closes by navigating.

The child route has its own loader. It fetches the one item before the component renders, then reads it back with useSuspenseQuery on the same query options, so the dialog paints with data in it:

// routes/dashboard/$itemId.tsx
export const Route = createFileRoute("/dashboard/$itemId")({
  loader: ({ context, params }) =>
    context.queryClient.query(itemQueryOptions(params.itemId)),
  component: RouteComponent,
});

function RouteComponent() {
  const { itemId } = Route.useParams();
  const navigate = Route.useNavigate();
  const { data: item } = useSuspenseQuery(itemQueryOptions(itemId));

  const close = () =>
    navigate({ to: "/dashboard", search: (prev) => prev, replace: true });

  if (!item) return null;

  return <Dialog onClose={close}>...</Dialog>;
}

The dialog is a native <dialog> with no open state. If the component is mounted, the URL says it should be showing, so it calls showModal() once on mount. The browser fires the element’s close event on Escape, and a click on the backdrop calls close() by hand. Both land in onClose, which navigates back to the layout. That unmounts the child, and the child takes the dialog with it.

// components/dialog.tsx
export function Dialog({ onClose, children }: Props) {
  const ref = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    ref.current?.showModal();
  }, []);

  return (
    <dialog
      ref={ref}
      onClose={onClose}
      onClick={(e) => e.target === ref.current && ref.current.close()}
    >
      <div onClick={(e) => e.stopPropagation()}>{children}</div>
    </dialog>
  );
}

Why close with replace?

close keeps the search params for the same reason the card did. It also passes replace: true. Opening a card pushes a history entry, so back closes the dialog. Closing with replace overwrites that entry, so back after closing takes you to wherever you were before the dashboard instead of reopening the item. That felt right to me for a browse-and-peek screen. For the admin forms in the same app I left replace off, because reopening a half-done form with back is what you want there.

Here’s what the child route gives you.

  • Every item has a URL you can bookmark, share, and refresh.
  • Back and forward work with no extra code.
  • The grid keeps its layout, scroll position, and filter while the dialog is open, because the router never unmounted it.
  • The grid and the dialog each read what they need from the URL and the cache. No prop drilling, and no open-state boolean to keep in sync.

Clone the demo, run npm run dev, and open http://localhost:3000/dashboard/item-3?color=slate to see the dialog land over a filtered grid on a cold load.

 
Conversation

Join the conversation

Your email address will not be published. Required fields are marked *