Skip to content

Conversation

@fzaninotto
Copy link
Member

@fzaninotto fzaninotto commented Jan 2, 2026

Problem

React-admin is tied to react-router. But the two main frameworks used to build new apps today are Next.js and TanStack Start, both NOT using react-router. Although it's possible to integrate RA with Next.js (by opting out of SSR and using a HashRouter), it's cumbersome. It's impossible to do it with TanStack Start.

Solution

Add a routing abstraction layer to allow react-admin to use another router.

Add a TanStack Router implementation to check that the abstraction works for at least 2 routers.

import { Admin, Resource, ListGuesser, tanStackRouterProvider } from 'react-admin';
import { dataProvider } from './dataProvider';

const App = () => (
    <Admin
        dataProvider={dataProvider}
        routerProvider={tanStackRouterProvider}
    >
        <Resource name="posts" list={ListGuesser} />
        <Resource name="comments" list={ListGuesser} />
    </Admin>
);

export default App;

Closes #10743

How To Test

React-admin should use react-router by default, and nothing should be broken. The demo apps should work as before.

A demo app powered by TanStack Router was added:

http://localhost:9010/?path=/story/react-admin-frameworks-tanstack--full-app

It contains CRUD routes and custom routes, as well as sub-routes (TabbedForm) and blocker (useWarnWhenUnsavedChanges). All routing features should work, and are covered by integration tests.

The same demo app can be tested in a second story, where it's embedded into an existing TanStack router app:

http://localhost:9010/?path=/story/react-admin-frameworks-tanstack--embedded

In this case, react-admin doesn't create a router but adds its own routes.

Note: these stories don't reset their route properly, so navigating from one to the other will fail. Refresh the browser when opening one of TanStack router's stories.

Reviewing this PR

This is a large PR, so I rebased the history to allow reviewers to read it commit-by-commit.

The tests should pass at the end of each commit.

Additional Checks

  • The PR targets master for a bugfix or a documentation fix, or next for a feature
  • The PR includes unit tests (if not possible, describe why)
  • The PR includes one or several stories (if not possible, describe why)
  • The documentation is up to date

@fzaninotto fzaninotto added the RFR Ready For Review label Jan 2, 2026
Copy link
Collaborator

@djhi djhi left a comment

Choose a reason for hiding this comment

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

Awesome work 💪

  • react-admin still has a dependency on react-router so users that want TanStack Router will have both routers installed. This is probably a breaking change for some people though.
  • TanStack router stories conflict with each other. If you navigate from some deep link in one to another story, you may end up with an empty screen.
  • We should probably split tan stack related code in multiple files, only re-export the provider from ra-core and make the rest available under a sub-path export (https://github.com/colinhacks/zshy/?tab=readme-ov-file#2-specify-your-entrypoints-in-packagejsonzshy)

Comment on lines 48 to 49
import { tanStackRouterProvider } from 'ra-core';
const { Route } = tanStackRouterProvider;
Copy link
Collaborator

Choose a reason for hiding this comment

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

We could have a sub-export path to make this more natural: import { Route } from 'ra-core/tanstack';

Copy link
Member Author

Choose a reason for hiding this comment

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

Nice to have, but I'm afraid of the bundler config it requires.

@fzaninotto
Copy link
Member Author

Thanks for the review, I have fixed the problems you raised.

react-admin still has a dependency on react-router so users that want TanStack Router will have both routers installed. This is probably a breaking change for some people though.

You're right, but that's something we can't change in a minor release.

TanStack router stories conflict with each other. If you navigate from some deep link in one to another story, you may end up with an empty screen.

I know, but I don't know how to fix them.

We should probably split tan stack related code in multiple files, only re-export the provider from ra-core and make the rest available under a sub-path export

Not sure this is necessary or desireable.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a routing abstraction layer to react-admin, decoupling it from react-router and enabling support for alternative routing libraries like TanStack Router. The abstraction is implemented via a RouterProvider interface similar to dataProvider and authProvider, with react-router as the default and TanStack Router as an alternative implementation.

Changes:

  • Added RouterProvider interface with hooks (useNavigate, useLocation, useParams, etc.) and components (Link, Navigate, Routes, etc.)
  • Implemented reactRouterProvider (default) and tanStackRouterProvider adapters
  • Updated react-admin core to use router abstraction hooks instead of direct react-router imports
  • Added comprehensive tests and documentation for TanStack Router integration

Reviewed changes

Copilot reviewed 140 out of 141 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/ra-core/src/routing/RouterProvider.ts Core interface defining the router abstraction contract
packages/ra-core/src/routing/adapters/reactRouterProvider.tsx Default react-router implementation of RouterProvider
packages/ra-core/src/routing/adapters/tanStackRouterProvider.tsx TanStack Router implementation with custom path matching
packages/ra-core/src/routing/RouterProviderContext.tsx Context for providing router throughout the app
packages/ra-core/src/routing/useNavigate.ts Router-agnostic navigation hook
packages/ra-core/src/routing/useLocation.ts Router-agnostic location hook
packages/ra-core/src/routing/useParams.ts Router-agnostic params hook
packages/ra-core/src/routing/LinkBase.tsx Router-agnostic Link component
packages/ra-core/src/core/CoreAdminContext.tsx Added routerProvider prop and context setup
packages/react-admin/src/TanStackRouterAdmin.stories.tsx Demo app showcasing TanStack Router integration
yarn.lock Added TanStack Router dependencies
docs/TanStackRouter.md Documentation for TanStack Router usage
Comments suppressed due to low confidence (3)

packages/ra-core/src/routing/adapters/tanStackRouterProvider.spec.tsx:1

  • Test expectation is incorrect. Based on the implementation in tanStackRouterProvider.tsx lines 110-124, when matching '/*' against '/', the params should be { '*': '' } (empty string), not { '*': '/' }. The test at line 65 correctly expects { '*': '' } for the same pattern.
    packages/ra-core/src/routing/adapters/tanStackRouterProvider.spec.tsx:1
  • Test expectation appears incorrect based on the implementation. The implementation strips the leading / from pathname when the pattern is '*' (not '/*'), so params['*'] should be 'anything' not '/anything'. See tanStackRouterProvider.tsx lines 112-118.
    packages/ra-core/src/routing/adapters/tanStackRouterProvider.spec.tsx:1
  • Test expectation is incorrect. When matching pattern '*' against pathname '/', based on the implementation (lines 112-118), the result should have params: { '*': '' } (empty string after stripping leading /), not { '*': '/' }.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

@slax57 slax57 left a comment

Choose a reason for hiding this comment

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

I reviewed all files. Now I need to do some testing...

But really not much to say so far, that is incredible work!!

Copy link
Contributor

@slax57 slax57 left a comment

Choose a reason for hiding this comment

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

Here are some issues I found:

  1. TabbedShowLayout doesn't work (can be reproduced in the simple demo by using the TanStack router provider)
Warning: A notFoundError was encountered on the route with ID "__root__", but a notFoundComponent option was not configured, nor was a router level defaultNotFoundComponent configured. Consider configuring at least one of these to avoid TanStack Router's overly generic defaultNotFoundComponent (<p>Not Found</p>) Error Component Stack
  at OutletImpl (Match.tsx:317:18)
  at Outlet (<anonymous>)
  at div (<anonymous>)
  at Routes (tanStackRouterProvider.tsx:473:19)
  at div (<anonymous>)
  at emotion-element-489459f2.browser.development.esm.js:33:17
  at OptionalRecordContextProvider (OptionalRecordContextProvider.tsx:23:5)
  at TabbedShowLayout (TabbedShowLayout.tsx:84:19)
  1. Navigating to the 2nd page of a list (or changing any list param actually) fails (attempts to redirect to http://localhost:8080/#/[object%20Object])
  2. Story /story/react-admin-resource--nested doesn't work with TanStack router
    (e.g. browsing to /authors/1/books resolves to /authors/1 instead of /authors/1/books)
  3. Ids that require URL encoding (such as '衣類/衣類') are not supported
    (can be reproduced in the simple demo by changing the id of a Post in the dataProvider)

One more thing: from what I understand, TanStack router provider's <Routes> component resorts to scanning its children to register the routes, which means it won't work with wrapped routes like for example

<Routes>
  <Route />
  <CanAccess>
    <Route />
  </CanAccess>
</Routes>

We may want to document this limitation somewhere.
UPDATE: this is not supported by react-router either

@slax57
Copy link
Contributor

slax57 commented Jan 14, 2026

One more thing, it seems having this new optional peerDep is gonna be an issue for bundlers with bad tree-shaking support like Webpack.

I tested using this new version in a sample webpack app and got errors:

image

UPDATE: Fixed by extracting to a separate package

  1. Fix nested routes with Outlet (TabbedShowLayout)
     - Add RouteChildrenContext to pass children from Routes to Outlet
     - Outlet now renders nested Routes when children exist

  2. Fix empty path route matching
     - Change `if (route.path)` to `if (route.path !== undefined)`
     - Empty string paths were incorrectly treated as falsy

  3. Fix Route children inside Resource
     - Move children routes before catch-all `/*` in Resource.tsx
     - Ensures custom routes match before the list catch-all

  4. Fix query parameter navigation (sorting, pagination, filters)
     - Handle navigate({ search }) without pathname
     - Use history API directly to avoid double-encoding

  5. Fix UTF-8/special characters in URL params
     - Add decodeURIComponent() for dynamic params (:id)
     - Add decodeURIComponent() for splat params (*)
Copy link
Collaborator

@WiXSL WiXSL left a comment

Choose a reason for hiding this comment

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

First of all, great work 🥇

I've worked on some tests that failed.

IMPORTANT:
I've made equivalent tests for react-router/-dom that in master and this branch, pass.
The failing cases are only the following using tanStackRouterProvider on this branch. (If you like, I can provide those react-router/-dom tests as well)

The following tests should go inside packages/ra-router-tanstack/src/tanStackRouterProvider.spec.tsx.

1 - Navigate with object to fails

Use case: Redirect with a pathname + query string using the Navigate component.
Expected: /posts?from=redirect
Actual: Navigates to /#/[object Object] (invalid path).
Test:

Expand to View Code
const { matchPath } = tanStackRouterProvider;
const {
    Routes,
    Route,
    Outlet,
    Link,
    Navigate,
    RouterWrapper,
    useLocation,
} = tanStackRouterProvider;

const LocationDebug = () => {
    const location = useLocation();
    return (
        <div>
            <div data-testid="location-pathname">{location.pathname}</div>
            <div data-testid="location-search">{location.search}</div>
        </div>
    );
};

it('should support object to with pathname and search', async () => {
    window.location.hash = '#/redirect';

    render(
        <RouterWrapper>
            <Routes>
                <Route
                    path="/redirect"
                    element={
                        <Navigate
                            to={{
                                pathname: '/posts',
                                search: '?from=redirect',
                            }}
                        />
                    }
                />
                <Route
                    path="/posts"
                    element={
                        <div>
                            <h2>Posts Page</h2>
                            <LocationDebug />
                        </div>
                    }
                />
            </Routes>
        </RouterWrapper>
    );

    await waitFor(() => {
        expect(screen.getByText('Posts Page')).toBeInTheDocument();
        expect(
            screen.getByTestId('location-search').textContent
        ).toContain('?from=redirect');
    });
});

2 - Link with object to (search-only) fails

Use case: Update only the query string while staying on the same route.
Expected: Same route, updated query string.
Actual: Invalid navigation because object to is treated as a string unless it has pathname.
Test:

Expand to View Code
const { matchPath } = tanStackRouterProvider;
const {
    Routes,
    Route,
    Outlet,
    Link,
    Navigate,
    RouterWrapper,
    useLocation,
} = tanStackRouterProvider;

const LocationDebug = () => {
    const location = useLocation();
    return (
        <div>
            <div data-testid="location-pathname">{location.pathname}</div>
            <div data-testid="location-search">{location.search}</div>
        </div>
    );
};

it('should support object to with search only', async () => {
    window.location.hash = '#/posts';

    render(
        <RouterWrapper>
            <Routes>
                <Route
                    path="/posts"
                    element={
                        <div>
                            <h2>Posts Page</h2>
                            <Link to={{ search: '?filter=active' }}>
                                Apply Filter
                            </Link>
                            <LocationDebug />
                        </div>
                    }
                />
            </Routes>
        </RouterWrapper>
    );

    await waitFor(() => {
        expect(screen.getByText('Posts Page')).toBeInTheDocument();
    });

    const user = userEvent.setup();
    await user.click(screen.getByText('Apply Filter'));

    await waitFor(() => {
        expect(screen.getByText('Posts Page')).toBeInTheDocument();
        expect(
            screen.getByTestId('location-search').textContent
        ).toContain('?filter=active');
    });
});

3 - Pathless layout routes don’t match

Use case: Layout wrapper without path containing child routes.
Expected: Layout + child route renders.
Actual: Nothing renders; the parent route never matches.
Test:

Expand to View Code
const { matchPath } = tanStackRouterProvider;
const {
    Routes,
    Route,
    Outlet,
    Link,
    Navigate,
    RouterWrapper,
    useLocation,
} = tanStackRouterProvider;

it('should match pathless layout routes', async () => {
    window.location.hash = '#/posts';

    render(
        <RouterWrapper>
            <Routes>
                <Route
                    element={
                        <div>
                            <h2>Layout</h2>
                            <Outlet />
                        </div>
                    }
                >
                    <Route
                        path="/posts"
                        element={<div>Posts Page</div>}
                    />
                </Route>
            </Routes>
        </RouterWrapper>
    );

    await waitFor(() => {
        expect(screen.getByText('Layout')).toBeInTheDocument();
        expect(screen.getByText('Posts Page')).toBeInTheDocument();
    });
});

@fzaninotto
Copy link
Member Author

Thanks for your reviews! I fixed all the remaining issues and rebased the PR.

@fzaninotto
Copy link
Member Author

Thanks for your review, I took all your remarks into account.

Copy link
Contributor

@slax57 slax57 left a comment

Choose a reason for hiding this comment

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

Incredible work!!! Can't wait to see that one released!! 💪 💪 💪


// Pathless layout route: path is undefined (not empty string) and has children
// Match if any child route would match the pathname
if (route.path === undefined && route.children) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Pathless layout routes short‑circuit the match loop on the first layout with matching children. This bypasses the later “more specific” logic and can make matching order‑dependent, potentially selecting a layout route even if a more specific sibling should win.

Copy link
Contributor

Choose a reason for hiding this comment

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

Tracked in internal issue tracker so we can check this out later.

@slax57 slax57 added this to the 5.14.0 milestone Jan 23, 2026
@slax57 slax57 merged commit 5be07b0 into next Jan 23, 2026
15 checks passed
@slax57 slax57 deleted the routerprovider branch January 23, 2026 09:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

RFR Ready For Review

Development

Successfully merging this pull request may close these issues.

5 participants