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. The scheme is assembled with
plain string building (encodeURIComponent, spaces as %20 — native URL
parsers reject +): the native Lynx runtime has no URL/URLSearchParams
globals, so the host must not depend on them.
Deep links: when a bundle is opened externally (a scheme without
__mpa_href), the host falls back to the page's own default route
(defaultHref, derived from the manifest via defaultHrefForPage) — never
to /, which would render the root page's UI inside the wrong container.
Per-page tree pruning: each bundle boots a pruned route tree
(src/pages.gen/<id>/routeTree.ts): its own routes carry full options
(components, loaders, validateSearch), while every foreign route is a
path-only stub kept solely so navigate({ to, params }) can build an href —
which the manifest resolver then dispatches to the host; a stub never
renders. The full routeTree.gen.ts remains the type-level source of
truth, so cross-page links stay type-checked against the whole app. The
runtime (mount/createMpaRouter) deliberately imports no default tree —
a static default would drag every page's components back into every bundle.
What pruning does not shrink is the per-bundle framework baseline
(ReactLynx + router core + shims, ~330 kB in this demo); deduplicating that
across bundles is shared-chunk work at the Lynx level, orthogonal to routing.
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.
Schema v1 notes: the manifest is pure serializable data — it is the
contract shared by JS, codegen, and (eventually) the native side, so it is
versioned; consumers must reject versions they don't understand rather than
guess. presentation: 'push' | 'modal' declares how the destination
container is presented; the sparkling host encodes it into the scheme
(presentation=modal) — native support is part of the stack-protocol work,
and containers that don't understand it fall back to push.
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 two ways — our extension to the convention:
The boundary file is prefixed - deliberately: the official generator
excludes --prefixed files from routing, while a _ prefix would create a
pathless layout route and corrupt the generated tree. An in-file page
export overrides an inherited directory marker (an explicit carve-out, e.g. a
modal page inside another page's path space).
Marker extraction is TypeScript-AST based (no regex, no eval): markers
must be statically evaluable literals (satisfies-wrapped is fine), and any
unreadable form — re-export, referenced constant, computed value — fails the
build. Codegen also enforces boundary containment: a route without a
marker that sits under another page's declared path prefix would render in a
surprising container, so it is a build error (move the file, or mark it).
Routes without any marker belong to the root page (the one whose marker has
root: true). gen-mpa.mjs reads the markers and emits the manifest,
pruned per-page trees, and entries; createManifestPageResolver consumes the
manifest at runtime. The whole pipeline (pnpm codegen) is wired into
build/dev/pretest, so everything regenerates 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. - Return values and stack observation need a native event face. The
NavigationHostcontract now specifies one —history.closePage({ result })hands a result to the page below,host.subscribeStackbroadcastsStackChangedEvents (push/pop/replace/container-back, with depth and result), andcreateStackMirror(host)exposes a read-only, subscribable snapshot of the native stack. The memory host implements the full protocol (it is the executable spec, covered bystack-events.test.ts); the sparkling binding is command-only today because the native SDK neither broadcasts stack changes nor carries a close payload. Consumers must feature-detect viamirror.live/ the absence ofsubscribeStackand degrade. Growing this event face in the native SDK (a container registry + a global stack-changed event) is the key next step for the platform.
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 —url-search-params-polyfill(required: TanStack Router parses/serializes search params withURLSearchParams, which the native Lynx runtime does not provide — Node tests and browser previews have it natively, so its absence only surfaces on device),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.
Second authoring frontend: the Next-style app directory
The runtime consumes exactly three inputs — a route tree, a page manifest, and the booting page's id. Nothing in it knows which file convention produced the first two. That makes authoring a frontend concern: a translator from a file convention to the artifact pair. The demo ships two:
gen-next.mjs walks the app directory, emits thin bridge route files
(TanStack flat convention, re-exporting the app components and translating
container → page), runs the official TanStack generator over them, and
compiles the manifest with the same shared compiler (lib/page-manifest.mjs)
the TanStack frontend uses. routeOptions (e.g. validateSearch) spreads
through as code, since functions cannot be compiled into a data manifest.
tests/next-parity.test.ts pins the equivalence: identical route-id sets,
deep-equal manifests, and the same cross-page/in-page navigation behavior over
the same runtime — mount({ pageId, routeTree, manifest }) takes the artifact
pair explicitly (no defaults: a static default import would pull the full tree
into every bundle); no runtime logic changed.
Status: experimental. The Next-style frontend exists to prove the
authoring dimension is pluggable; it is not part of the v1 API surface, and
shipping two conventions would split the ecosystem. It also skips per-page
tree pruning (its entries boot the full next tree). Both frontends build side
by side here (next-* bundles) purely for the demo; a real app would pick
one convention and ship it unprefixed. The surface is convention-compatible,
not Next-compatible: server components, data fetching, and the rest of Next's
runtime semantics are out of scope.
Packages & files
packages/sparkling-history— the reusable shim (contract + history + sparkling host + manifest resolver + stack mirror). 40 tests.packages/tanstack-router-demo— the spike, the file-based multi-page MPA demo (two authoring frontends), and the headless tests (35: feature matrix + generated-tree + next-parity).src/routes/*— file-based routes (official convention +pagemarkers).src/app/**— the same app authored Next-style (containermarkers).scripts/codegen.mjs— runs the official generator +gen-mpa.mjs+gen-next.mjs.scripts/gen-mpa.mjs/scripts/gen-next.mjs— the two frontend translators;scripts/lib/page-manifest.mjs— shared manifest compiler.src/routeTree.gen.ts(official generator),src/routes.manifest.ts+src/page-entries.gen.ts+src/pages.gen/*(MPA codegen), and their*.next.*counterparts from the Next-style translator.
- Website embed:
docs/en/guide/examples/tanstack-router.mdx(the live<Go>example), registered inpackages/website/scripts/prepare-examples.mjs.

