tanstack-router-remote

Attach remote route trees to a TanStack Router host at runtime — React, Solid and Vue.
526 4
Install
npm install tanstack-router-remote
Latest Version:1.1.1
Vue:3
License:MIT
Last Updated:Oct 23, 2026
Links: GitHub  ·  npm
Maintainer: darkhipov

TanStack Router Remote

npm CI types license

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 with resolveRemotePath.
  • SSR needs prepare() before the first match, not attach(). 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

@mfe-orchestrator-hub/client-vue

Vue bindings for @mfe-orchestrator-hub/client: plugin and composables over the M...

1,023 0
@esmx/core

A high-performance microfrontend framework supporting Vue, React, Preact, Solid,...

8,645 680
@gez/core

A high-performance microfrontend framework supporting Vue, React, Preact, Solid,...

5,868 680
@tanstack/virtual-core

Headless UI for virtualizing scrollable elements in TS/JS + Frameworks

800,301,233 7,154
table-core-with-skip-accessor-key-dot-notation

Fork of @tanstack/table-core v.8.21.2

4,255 28,517

Version History

Version Released Vue License
1.1.1 >=3.3 MIT
1.1.0 >=3.3 MIT
1.0.0 >=3.3 MIT