Skip to content

Conversation

@brophdawg11
Copy link
Contributor

RFC: #9864

Add support for <Link unstable_rewrite> which allows users to navigate to one URL in the browser but "rewrite" the url that is processed by the router, permitting contextual routing usages such as displaying an image in a model on top of a gallery.

This brings the long-standing example of doing this manually in declarative mode into Data/Framework Mode for client side navigations.

// routes/gallery.tsx
export function clientLoader({ request }: Route.LoaderArgs) {
  let sp = new URL(request.url).searchParams;
  return {
    images: getImages(),
    modalImage: sp.has("image") ? getImage(sp.get("image")!) : null,
  };
}

export default function Gallery({ loaderData }: Route.ComponentProps) {
  return (
    <>
      <GalleryGrid>
       {loaderData.images.map((image) => (
         <Link
           key={image.id}
           to={`/images/${image.id}`}
           rewrite={`/gallery?image=${image.id}`}
         >
           <img src={image.url} alt={image.alt} />
         </Link>
       ))}
      </GalleryGrid>

      {data.modalImage ? (
        <dialog open>
          <img src={data.modalImage.url} alt={data.modalImage.alt} />
        </dialog>
      ) : null}
    </>
  );
}

@changeset-bot
Copy link

changeset-bot bot commented Jan 8, 2026

🦋 Changeset detected

Latest commit: 3d54c92

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
react-router Patch
@react-router/architect Patch
@react-router/cloudflare Patch
@react-router/dev Patch
react-router-dom Patch
@react-router/express Patch
@react-router/node Patch
@react-router/serve Patch
@react-router/fs-routes Patch
@react-router/remix-routes-option-adapter Patch
create-react-router Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

"window.history.replaceState({ ...window.history.state, rewrite: undefined }, null);",
"}",
].join("")
: "";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we cannot support history.state-driven rewrites during SSR, we just clear out the rewrite location on SSR renders so the client just works with the normal browser URL location to avoid hydration issues.

push(to, state) {
action = Action.Push;
let nextLocation = createMemoryLocation(to, state);
let nextLocation = isLocation(to) ? to : createMemoryLocation(to, state);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accept Locations in our history.push/replace API so that we can just proxy the rewrite along in the location

...initialLocation,
...initialLocation.rewrite,
};
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hydrate the rewrite location if it exists

? // `matchRoutes()` has already been called if we're in here via `router.initialize()`
state.matches
: matchRoutes(routesToUse, location, basename);
: matchRoutes(routesToUse, routerPath, basename);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matching/data loading operate against the routerPath, which is the rewrite if it exists

!(opts && opts.submission && isMutationMethod(opts.submission.formMethod))
) {
completeNavigation(location, { matches }, { flushSync });
completeNavigation(externalLocation, { matches }, { flushSync });
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But when we complete the navigation, we do it to the external/user-facing to location

search: state.location.rewrite.search,
hash: state.location.rewrite.hash,
};
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: I think this should probably move deeper now that rewrite lives on location and isn't just a magic field in location.state

@@ -1,9 +1,25 @@
import { useLoaderData, useLocation } from "react-router";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

playground/framework is updated with an example usage - can toggle between ssr:true/ssr:false to see the differences in behavior on hard reloads

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Revert these playground changes before merging

@OliverJAsh
Copy link

@brophdawg11 Just tested it for the Unsplash asset page modal and it seems to work perfectly. 😘

@OliverJAsh
Copy link

An interesting edge case I just encountered:

  1. User navigates to a link that has a rewritten URL e.g. /?page_modal=a rewritten as /photos/a.
  2. That page has another link which simply adds another query param to the current URL e.g. foo=bar.
  const location = useLocation();
  const [searchParams] = useSearchParams();
  searchParams.set('foo', 'bar');

  return <Link to={{ pathname: location.pathname, search: searchParams.toString() }}>
    Add query param
  </Link>

Result: /?page_modal=a&foo=bar. We lose the "route masking".

The old modal setup (using location state) didn't have this problem. The URL in this case would be /photos/a?foo=bar.

TanStack has declarative route masking. I wonder if this would help: https://tanstack.com/router/v1/docs/framework/react/guide/route-masking##declarative-route-masking

For context, where this shows up in Unsplash is our (nested) modals:

Screen.Recording.2026-01-09.at.16.13.58.mov

@OliverJAsh
Copy link

Something else I noticed is that the location exposed by router.subscribe doesn't seem to use the rewritten location, unlike the location exposed by useLocation. I'm not sure if this is intentional? Reduced test case:

https://stackblitz.com/edit/github-p8mgs2ph?file=src%2Fapp.tsx

image

@brophdawg11
Copy link
Contributor Author

brophdawg11 commented Jan 9, 2026

yeah that's part of the quick nature of this POC - we just stick the rewrite field on there. It sounds like you'd prefer that useLocation returns the rewritten location? Would you want/need access to the "URL location" via useLocation as well?

nvm got my wires crossed. That's because of this quick hack: #14716 (comment)

Just to clarify - do you want useLocation to expose the URL location or the rewritten location? And do you want/need the other exposed as well?

@OliverJAsh
Copy link

OliverJAsh commented Jan 9, 2026

Just to clarify - do you want useLocation to expose the URL location or the rewritten location? And do you want/need the other exposed as well?

I expected both the hook and router.subscribe to expose the same representation, i.e. the URL that is being rendered (unstable_rewrite) rather than the one in the address bar (to). But it might be good to have the address bar URL in there as well? Just not as the main value?

@OliverJAsh
Copy link

Side note: I wonder if it's easier to think about this feature in terms of "route masking" (like TanStack Router) rather than "rewriting". It flips them around:

Rewriting:

<Link to="/photos/abc" rewrite="/?photo=abc" />

Route masking:

<Link to="/?photo=abc" mask="/photos/abc" />

@brophdawg11 brophdawg11 marked this pull request as draft January 12, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants