TanStack Router on Sparkling (native MPA navigation)
This guide describes how TanStack Router is ported onto Sparkling so that it drives native multi-page navigation — each page is a separate LynxView with its own JS context, and navigating between pages is a native container open, not an in-memory route transition.
It covers the design, the reusable shim layer, the exact feature support matrix (what works, what cannot, and why), the shims ReactLynx needs, and a comparison with TanStack Router's own React Native effort.
Status: experimental. In-page routing is verified live in the go-web web preview; the reusable history layer (
sparkling-history) and the demo (tanstack-router-demo) are the deliverables.
The problem: MPA, not SPA
ReactLynx already supports TanStack Router / React Router inside a single LynxView using a memory history — a classic SPA: one router, one JS heap, routes swap components in place.
Sparkling navigation is different. Each sparkling-navigation.open opens a new
native container (a new Activity/Fragment on Android, a SPKViewController on
iOS), and each container runs its own LynxView with its own JS context.
There is no shared window, history, location, or JS memory across pages.
This is an MPA (multi-page app) model, like a mini-app platform.
So a router that drives Sparkling navigation cannot keep one in-memory history stack. Two consequences shape the whole design:
- Pages are connected out-of-band. The only inbound channel to a
freshly-opened page is the scheme's query string, surfaced as
lynx.__globalProps.queryItems(strings only). Routes are therefore connected via pre-generated file-based metadata (a route→page manifest), not shared objects. - Each page boots its own router at an initial location derived from its launch params. The native back stack is owned by the container, not JS.
Layered architecture
The design deliberately separates three concerns so the bottom layer can back other routers and other host platforms in the future.
All three live in the sparkling-history package.
1. NavigationHost — the API contract
The contract any container platform implements:
This is the layering boundary that matters: anything that can report the
URL/stack it launched with, open a page for an href, and close itself can host
a URL-driven router. Sparkling is one implementation; a plain browser window,
a test double (createMemoryHost), or a future platform are others.
2. createMpaHistory — the web-history shim
Implements the RouterHistory shape from @tanstack/history (so its result is
a drop-in for createRouter({ history })), over a NavigationHost:
- In-page navigation (destination resolves to the current page) behaves
exactly like
@tanstack/history's memory history — push/replace/back/forward on a local entry stack, with subscriber notifications. Ported memory-history tests pass verbatim. - Cross-page navigation (destination resolves to another page, decided by
a
PageResolver) is forwarded tohost.open(...); the local stack is left untouched (the current page keeps rendering until the container covers or replaces it — document-navigation semantics). back()at the local root becomeshost.close()— a native pop.__TSR_indexis seeded withhost.getStackDepth(), socanGoBack()anduseCanGoBack()stay correct on a pushed page even at its local root.- Navigation blockers run with no
document.@tanstack/historygates blocker execution ontypeof document !== 'undefined'; the shim does not, souseBlockerworks in a native Lynx JS context.
3. createSparklingHost — the sparkling binding
Turns a cross-page open into a Sparkling scheme:
and reconstructs the destination page's initial location from its launch
queryItems. options.replace maps to Sparkling's open + replace
semantics; close() maps to router.close. Spaces are encoded as %20
(native URL parsers reject +).
File-based route manifest
Because pages cannot share memory, the route→page mapping is a build-time
artifact — the same idea as TanStack's generated routeTree.gen.ts, extended
with a page dimension. createManifestPageResolver(manifest) builds the
PageResolver from it:
The longest matching path prefix wins; if the destination page equals the current page, navigation stays in-page.
File-based routing: reusing TanStack's compile-time toolchain
TanStack Router's file-based routing has a compile-time half that is fully reusable in a Rspeedy/Rspack/ReactLynx project — verified end-to-end in the demo. It splits into three official pieces plus one MPA-specific piece we add.
What we reuse as-is (official):
@tanstack/router-generatorturns thesrc/routes/*file convention (createFileRoute('/path')) intosrc/routeTree.gen.ts— the fully-wired, fully-typed route tree. It is pure Node (no bundler, no DOM) and runs standalone (new Generator({ config, root }).run()).@tanstack/router-plugin/rspackcomposes into the Rspeedy build viatools.rspackalongsidepluginReactLynx.TanStackRouterGeneratorRspack(generator-only) regenerates the tree on build and watch — verified by deletingrouteTree.gen.tsand rebuilding.- Code-splitting (
TanStackRouterRspackwithautoCodeSplitting: true) also composes: the build emits a separate async chunk per route component and the lazy chunks load and render at runtime in the Lynx web worker (verified in the go-web web preview). The demo keeps it off by default: on an MPA each page is already its own bundle, so per-page bundling gives most of the benefit, and native async-chunk loading (vs the web worker) is not yet verified.
What TanStack does not provide — the MPA dimension (scripts/gen-mpa.mjs):
The official generator assumes one router = one bundle. An MPA needs two more artifacts derived from the same route files:
src/routes.manifest.ts— the route→page (bundle) mapping.- one bundle entry per native page (
src/pages.gen/<id>/index.tsx), wired intosource.entry.
Page boundaries are declared inline in a route file — our extension to the convention:
Routes without a page export belong to the root page (the one whose page
has root: true). gen-mpa.mjs reads these markers and emits the manifest and
entries; createManifestPageResolver consumes the manifest at runtime. The
whole pipeline (pnpm codegen) is wired into build/dev/pretest, so
routeTree.gen.ts + routes.manifest.ts + entries regenerate from the route
files alone.
What a navigation actually does
From the demo (packages/tanstack-router-demo) — in-page rows run live in the
go-web web preview; cross-page rows are native router.open/close:
Path params (id) and validated search params (ref) cross the JS-context
boundary via the scheme query and are read back from queryItems in the
destination page.
Feature support matrix
Legend: ✅ Supported (works, with a test/example) · ⚠️ Supported in-page only (works within a page's JS context, not across the native page boundary) · ❌ Not supported (blocked by the MPA model or by DOM-only dependencies).
Every ✅/⚠️ below is backed by a passing test in
packages/tanstack-router-demo/tests/router-features.test.ts (headless, real
router, no DOM) or packages/sparkling-history/tests/* unless noted.
Routing core — fully supported
MPA-specific — supported through the shim
Preloading & transitions — partial
DOM-bound / SSR — not supported (by platform)
Cross-page limitations inherent to MPA
These are not shim gaps — they follow from pages being separate JS contexts, and are the fundamental difference from the SPA model:
- No shared in-memory router state across pages. A loader's data on one
page cannot flow into another page's component; the only channel is the
URL/
queryItems(or a native KV bridge). TanStack's typed search params are the recommended way to type that channel. - The back stack is native-owned. JS cannot read it, cannot
go(-3)across pages (only one nativecloseperback), and cannot block a hardware/gesture back — only JS-initiated navigation is blockable. - No return value to the opener via navigation.
closedoes not deliver a result to the reopened page through the navigation API; the previous page only learns it reappeared via theviewAppearedlifecycle event.
Running TanStack Router on ReactLynx: the shims
ReactLynx is a Preact-based custom renderer with no react-dom and no DOM
globals. TanStack Router runs unmodified once four bundler-level shims are in
place (see packages/tanstack-router-demo/lynx.config.ts and src/shims/) —
no fork of TanStack Router is required:
react$alias — addsstartTransition/useTransition(from@lynx-js/react/compat) and ausebinding that TanStack's dist links against by name (ESM strict linking needs the binding to exist;useis React-19-only and TanStack falls back when it isundefined).react-dom$alias — providesflushSync(fn => fn()). This is the only react-dom symbol in TanStack Router's client entry (used by<Link>to flip transition state synchronously).use-sync-external-store/shim*→@lynx-js/use-sync-external-store(Lynx's build of the store subscription primitive).env.tsglobals —scrollTo(router-core resets scroll on every navigation even withscrollRestorationoff), plusAbortController/queueMicrotaskfallbacks for runtimes that lack them.
Two router options are also required:
isServer: false— otherwise a document-less runtime is treated as a server and nothing re-renders.origin: '<any-url>'— router-core reads a barewindow.originwhenisServeris false, which throwsReferenceErrorin a native Lynx runtime.
Web preview (go-web) and what it can show
The demo is embedded on the website as a live go-web
example. The go-web web preview renders a Lynx *.web.bundle in the browser but
does not run the Sparkling native bridge (the same limitation as every other
Sparkling example — see the Examples overview). That maps cleanly
onto this integration:
- In-page routing runs live in the preview — the spike's Home↔About and the
MPA
homebundle's Home↔Profile arecreateMpaHistoryin-memory transitions with nopipe.call, so they work in the browser. - Cross-page navigation is a native
router.open— in the preview it is a graceful no-op (the bridge returns "not available"); on device (QR tab) it opens the destination page's bundle. This was verified end-to-end earlier on an experimental web method-bridge harness (a shell that turnsrouter.openinto a<lynx-view>swap); that bridge is orthogonal to go-web and is not part of this branch.
Comparison with TanStack Router's React Native adapter
TanStack has an official experimental React Native adapter
(@tanstack/react-native-router, alpha; draft PR #7622 by Tanner Linsley — not
merged or published beyond a 0.0.1 placeholder as of mid-2026). It is worth
comparing because it solves the same "web router, native screens" problem from
the opposite architectural starting point.
How theirs works
- Single JS context, one router. The whole app is one React tree in one
Metro bundle/VM.
Routerextends the sameRouterCoreas web. The native screen stack (react-native-screens, driven directly — not react-navigation) is a projection of the router's in-memory history. - In-memory history (
createNativeHistory) with native reconciliation: swipe/hardware back is reconciled after the native transition viaonDismissed → history.back(). - Native-first extensions: per-route
nativeoptions (presentation, animation, header) resolved from route + loader data before navigating;stackBehavior: reuse(singleTop-like) computed inrouter-core; a lifecycle policy (active/paused/detached) that even removes deep screens from the native stack (<Activity mode="hidden">) to fight one-heap memory growth.
What is reusable for us
Their design is mostly runtime-coupled to the single heap, but several ideas are pure data/protocol design and transplant directly:
- URL-as-screen-identity with no address bar — they prove the full web routing contract (path + typed params + validated search) works when the URL is virtual. That is exactly our inter-page contract.
resolveNativeNavigateOptionscomputes header/presentation/animation from route options before the destination renders — precisely what a native container needs to configure a page window before its VM boots. Our manifest'scontainerParamsis the same idea; theirs is richer.- Stack-semantics vocabulary (
push/replace/reuse+getId+stackMatch) maps one-to-one onto native launch modes and could live in our manifest. - Detached-screen lifecycle — their "detached" (drop the screen, keep the
history entry, re-materialize on pop) is, in an MPA, literally killing and
respawning a page VM; their design tells us the minimal resurrection state is
href + history-entry state, which is what we transport via
__mpa_*.
Their advantages (single heap)
- Shared router state: a screen's
loaderDatacan drive another screen's native header. Impossible across VMs without a serialization protocol. - In-JS loader pipeline, pending transitions (
pendingMatches),preloadRouteacross screens, and a JS-owned back stack (go(n), masks, blockers, full introspection). - One compilation unit → end-to-end type safety and TanStack Start server functions.
Our advantages (separate VMs / MPA)
- The native stack is the source of truth, not a projection — gestures, predictive back, and transitions are correct by construction (no reconcile-after-the-fact desync).
- Per-page VM isolation: crash and memory containment; reclaiming memory is just killing the VM (their "detached", but real). Pages can be prewarmed / parallel-loaded and can version/deploy independently (the mini-app property).
- The shim is router-agnostic and host-agnostic — the
NavigationHostcontract can back React Router or a custom router, and can target hosts other than Sparkling.
Our limitations (the mirror image)
- No shared in-memory state, no cross-page loader prefetch inside JS, no
cross-page
go(n)/blocking, no cross-page pending/Suspense transition, and type safety across pages needs a shared generated manifest instead of one compilation unit. Every page pays router/framework boot cost in its own VM.
Net
Both converge on the same substrate (a web-grade, URL-centric router core
driving native screens). Theirs optimizes for a shared heap and rich in-JS
navigation; ours optimizes for native-owned, isolated page containers. The
sparkling-history layer is what makes the router core reusable in the MPA
world their adapter does not target.
Packages & files
packages/sparkling-history— the reusable shim (contract + history + sparkling host + manifest resolver). 28 tests.packages/tanstack-router-demo— the spike, the file-based multi-page MPA demo, and the headless tests (16: feature matrix + generated-tree).src/routes/*— file-based routes (official convention +pagemarkers).scripts/codegen.mjs— runs the official generator +gen-mpa.mjs.scripts/gen-mpa.mjs— MPA manifest + per-page entries codegen.src/routeTree.gen.ts(official generator),src/routes.manifest.ts+src/page-entries.gen.ts+src/pages.gen/*(MPA codegen).
- Website embed:
docs/en/guide/examples/tanstack-router.mdx(the live<Go>example), registered inpackages/website/scripts/prepare-examples.mjs.

