tanstack-router-remote
TanStack Router Remote
Attach a microfrontend's routes to a TanStack Router host at runtime — one router, one history, one route cache. React, Solid and Vue.
The problem
TanStack Router builds its route tree when you call createRouter(). A
microfrontend host does not have that tree: each remote is deployed separately
and owns routes the host has never seen. The two usual workarounds both cost
something:
- Load every remote at startup so the tree is complete before
createRouter(). You pay for remotes nobody visits, and one slow remote delays the whole application. - Give each remote its own
<RouterProvider>. Now two routers listen to one history, with separate caches and separate ideas of the current URL. Deep links and back/forward stop behaving like one application.
This package takes a third path: the remote exports its route tree, not a router, and the host grafts that tree into the tree it already has — the first time someone opens a URL under the mount.
Concretely: the host builds its router knowing only that something lives under
/orders. It declares a mount — a real route with no children:
const mount = createRemoteRoute({
getParentRoute: () => root,
path: '/orders',
component: OrdersMount,
})
const router = createRouter({ routeTree: root.addChildren([mount]) })
Someone opens /orders/42 — a bookmark, a shared link, a cold load. There is no
/orders/42 in that tree, so TanStack's fuzzy matching falls back to the closest
route that does match: the /orders mount. Its component runs, and that is the
hook the whole design hangs on:
function OrdersMount() {
return (
<RemoteRouteMount
mountRoute={mount}
loadRouteTree={async () => (await import('./remote')).routeTree}
loading={<p>Loading orders…</p>}
>
<Outlet />
</RemoteRouteMount>
)
}
RemoteRouteMount calls loadRouteTree(), receives the remote's exported
routeTree, and adds it as children of the mount. The tree now has an
/orders/$orderId, so it asks the router to match the current URL again — and
/orders/42 resolves to the remote's detail route, which renders through the
<Outlet /> above. One createRouter(), one history, one cache, start to
finish; /orders/42 was never handled by a second router.
Had the user opened plain /orders instead, the identical path runs — the
rematch just lands on the remote's index route. The deep link is the interesting
case only because it is the one that usually breaks.
Loaders, validateSearch, beforeLoad, boundaries, the route cache and
Link/useNavigate need no integration — inside a mounted remote they behave
as they do anywhere else.
Reach for this when remotes are deployed independently and own routes the host cannot know at build time. Do not if every route is known in one build — TanStack's own code splitting is simpler and does the job. Mixing renderers (a Vue remote in a React host) additionally needs a rendering bridge you write yourself; picking another package entry does not create one.
Install and compatibility
npm install tanstack-router-remote
That is all you add: in an app that already renders TanStack Router, every other
peer is present. @tanstack/router-core arrives as a dependency of your
router package, and the renderer peers are the ones your app already has.
ESM only. Import from /react, /solid or /vue to match your renderer — the
other renderers' peers are optional and never installed. Use one TanStack
version across host and remotes; do not upgrade them independently. Tested
combinations are in compatibility.
Quick start
Four short steps, in an existing React/TypeScript app with an id="root"
element. Host and remote must use the same dependency versions. This uses a
native import() so there is nothing to configure; swap it for a federation
call once it works.
1. The remote exports a route tree — no router, no provider. Whatever your remote already builds, export the tree instead of a router:
export const routeTree = root.addChildren([index, detail])
2. The host declares a mount. This is the only part that differs between routing styles, and it is a few lines either way.
Code routes — the mount is a route like any other, passed to createRouter():
import { createRemoteRoute } from 'tanstack-router-remote/react'
const mount = createRemoteRoute({
getParentRoute: () => root,
path: '/orders',
component: OrdersMount,
})
const router = createRouter({ routeTree: root.addChildren([mount]) })
File routes — the generator decides the parent, so wrap the generated
declaration and let the wrapper be the exported Route:
// src/routes/orders.remote.tsx
import { createFileRoute } from '@tanstack/react-router'
import { createRemoteRoute } from 'tanstack-router-remote/react'
export const Route = createRemoteRoute(
createFileRoute('/orders')({ component: OrdersMount }),
)
The generator reads the inner createFileRoute call, so no build-time transform
is involved — and a mount that forgot the wrapper cannot exist, because the
wrapper is the export.
3. The mount's component loads the tree — identical in both styles, except
that mountRoute is mount or Route to match:
import { Outlet } from '@tanstack/react-router'
import { RemoteRouteMount } from 'tanstack-router-remote/react'
function OrdersMount() {
return (
<RemoteRouteMount
mountRoute={Route}
loadRouteTree={async () => (await import('./remote')).routeTree}
loading={<p>Loading orders…</p>}
error={(error) => <p>Could not load orders: {error.message}</p>}
>
<Outlet />
</RemoteRouteMount>
)
}
4. One adapter above RouterProvider — the same in both styles:
import {
RemoteRouterAdapter,
RemoteRouterProvider,
} from 'tanstack-router-remote/react'
const adapter = new RemoteRouterAdapter(() => router)
createRoot(element).render(
<RemoteRouterProvider adapter={adapter}>
<RouterProvider router={router} />
</RemoteRouterProvider>,
)
Now open /orders/42 directly — not /orders first. The deep link is the case
that matters: the mount catches it, loads the tree and rematches, all before
anything renders. Your dev server must serve the host HTML for application URLs.
To deploy the remote separately, replace import('./remote') with a transport —
loadRemote('orders/routeTree') for Module Federation, or anything else that
returns a route tree. The adapter does not care which.
The steps above are fragments, shown one idea at a time. Here are the same two files whole — this pair is compiled on every CI run, so it is known to build against the packed package:
The complete host and remote
import { createRootRoute, createRoute, Outlet } from '@tanstack/react-router'
const root = createRootRoute({
component: () => (
<>
<h2>Orders</h2>
<Outlet />
</>
),
})
const index = createRoute({
getParentRoute: () => root,
path: '/',
component: () => <p>Orders index</p>,
})
const detail = createRoute({
getParentRoute: () => root,
path: '/$orderId',
component: () => <p>Order {detail.useParams().orderId}</p>,
})
export const routeTree = root.addChildren([index, detail])
import { createRoot } from 'react-dom/client'
import {
createRootRoute,
createRouter,
Outlet,
RouterProvider,
} from '@tanstack/react-router'
import {
createRemoteRoute,
RemoteRouteMount,
RemoteRouterAdapter,
RemoteRouterProvider,
} from 'tanstack-router-remote/react'
const root = createRootRoute({
component: () => (
<>
<h1>Host</h1>
<Outlet />
</>
),
notFoundComponent: () => <p>Host page not found</p>,
})
const mount = createRemoteRoute({
getParentRoute: () => root,
path: '/orders',
component: OrdersMount,
})
function OrdersMount() {
return (
<RemoteRouteMount
mountRoute={mount}
loadRouteTree={async () => (await import('./remote')).routeTree}
loading={<p>Loading orders…</p>}
error={(error) => <p>Could not load orders: {error.message}</p>}
>
<Outlet />
</RemoteRouteMount>
)
}
const router = createRouter({ routeTree: root.addChildren([mount]) })
const adapter = new RemoteRouterAdapter(() => router)
const element = document.getElementById('root')
if (!element) throw new Error('Missing #root element')
createRoot(element).render(
<RemoteRouterProvider adapter={adapter}>
<RouterProvider router={router} />
</RemoteRouterProvider>,
)
For a host and remote you can actually run, start with the native ESM example — no bundler configuration — or the file routing examples for both generator modes. The full index is below.
Two file-routing details worth knowing before you copy: the .remote in
orders.remote.tsx is not a path segment only because that host sets
routeToken: /(?:route|remote)/, and in virtual mode the path you pass to
createFileRoute must match the generated route id — a mount under a pathless
shell.tsx layout is declared createFileRoute('/_shell/catalog') even though
its URL stays /catalog. Both are spelled out in the file-routing example.
Know before you adopt
- Keep the default fuzzy not-found matching. That is what lets a childless
mount catch a deep link below itself and start loading.
notFoundMode: 'root'breaks the mechanism. - A tree attaches once, and grafting mutates it. To mount the same remote twice, or to serve SSR requests, export a factory that returns a fresh tree. Detaching or replacing an attached tree is not supported.
- The host owns basepath, history and global router options. A remote cannot set its own.
- Types do not cross the runtime boundary. Remote routes are not part of the
host's compile-time route union, so a host
<Link to="/orders/42">is not typed against them. - Navigation inside a remote is scoped, redirects are not. A
redirect()thrown from remote lifecycle code targets the host router. Where a remote path collides with a host one, resolve it explicitly withresolveRemotePath. - SSR needs
prepare()before the first match, notattach(). TanStack Start and streaming/deferred SSR are untested, not claimed.
One more thing worth knowing up front: this builds on route.update() and
addChildren(), which are not an official TanStack composition API. The public
API here follows semver, but an upstream release can force the peer range to
narrow in a minor. A scheduled canary job runs the full suite against the newest
published router so that surfaces here rather than in your build.
See navigation and types, API and retry and full limitations.
Choose a guide or runnable example
| Integration | Start here |
|---|---|
| Module Federation | transport and singleton configuration |
| React / Solid / Vue | framework setup |
| Physical / virtual file routing | file routing examples |
| Nested remote trees | Orders → Invoices example |
| Native ESM | complete workspace example |
| SSR / hydration | runnable server and client |
| Custom rendering bridge | cross-framework example |
The framework entries export the adapter, provider, mount, route helper, and explicit path resolver. The package root is the framework extension point.
Run the examples
corepack pnpm install --frozen-lockfile
corepack pnpm run dev:example:module-federation
Then open http://localhost:3100/platform/orders for a host with one federated
remote, or /platform/orders/invoices/INV-42 for a remote loaded by that remote
— two levels deep, still one router. Host home also has a switch that kills the
remote entry, so you can watch a transport failure stay inside its mount.
Other scripts: dev:example:native-import, dev:example:file-routing,
dev:example:solid, dev:example:vue, dev:example:cross-framework.
pnpm run test:e2e drives these production builds in a real browser.
Contributing
Repository tooling is pinned to pnpm 11.9.0, also checks pnpm 10.26.0, and requires Node
^22.18.0 || >=24.11.0. The supported tooling floor is pnpm 10.26.0;
package consumers are not constrained to pnpm.
corepack pnpm run check
corepack pnpm run check:consumers
CONTRIBUTING · compatibility policy
Not affiliated with or endorsed by TanStack.
Related Packages
Vue bindings for @mfe-orchestrator-hub/client: plugin and composables over the M...
A high-performance microfrontend framework supporting Vue, React, Preact, Solid,...
A high-performance microfrontend framework supporting Vue, React, Preact, Solid,...
Headless UI for virtualizing scrollable elements in TS/JS + Frameworks
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 1.1.1 | >=3.3 | MIT | |
| 1.1.0 | >=3.3 | MIT | |
| 1.0.0 | >=3.3 | MIT |