NativeScript Blog

TanStack Router - Using file routing in NativeScript

Nathan Walker March 12, 2026

The first post covered the runtime contract: why TanStack Router drives a real native page stack so naturally once you accept that Frame and Page are the runtime truth. This post is about the authoring side: how TanStack Router's file-based routing fits into that same architecture. It gives you a route layer that scales and refactors better as an app grows, stays fully type-safe, and the part that matters most - leaves the native runtime contract untouched.


Why File Routing Helps on Native

Native apps accumulate routes fast: list and detail screens, modals, settings areas, nested flows, deep links, auth boundaries. Declared in code, those relationships stay precise, but the route definition layer keeps growing denser over time. File routing moves that structure into the filesystem, where teams already reason about screens. The path shape becomes visible from the directory layout, params become visible from the file names, and each route's loader logic sits next to the screen it serves. Because this is still TanStack Router, you give up none of the type inference; Link destinations, params, search state, and loader data all stay strongly typed.

The Core Idea: Generate, Then Run Natively

The pipeline is short. TanStack Router reads your route files, generates routeTree.gen.ts, and the app builds a router from that tree which NativeScriptRouterProvider turns into native navigation. All of the file-routing work happens at authoring and generation time, so the runtime code stays exactly as lean as it was with hand-written routes:

import {
  NativeScriptRouterProvider,
  createNativeScriptRouter,
} from '@nativescript/tanstack-router/solid'
import { routeTree } from './routeTree.gen'

const router = createNativeScriptRouter({
  routeTree,
  initialPath: '/',
})

declare module '@nativescript/tanstack-router/solid' {
  interface Register {
    router: typeof router
  }
}

export function App() {
  return (
    <gridlayout>
      <NativeScriptRouterProvider router={router} debug={true} />
    </gridlayout>
  )
}

What Makes the Vite Integration Work

@nativescript/vite lets installed packages contribute their own Vite configuration through a nativescript.vite.mjs file, so it can pick up config from dependencies without every app wiring plugin code by hand. For file routing, that chain is: you install @nativescript/tanstack-router, @nativescript/vite discovers its nativescript.vite.mjs, that config enables the TanStack Router Vite plugin when it sees file-route signals like tsr.config.json or src/routes, and TanStack generates routeTree.gen.ts automatically. The app's own Vite config stays tiny:

import { defineConfig, UserConfig } from 'vite'
import { solidConfig } from '@nativescript/vite/solid'

export default defineConfig(({ mode }): UserConfig => {
  return solidConfig({ mode })
})

The App Structure and Config

The main pieces from the sample app:

src/
  app.tsx
  routeTree.gen.ts
  routes/
    __root.tsx
    index.tsx
    about.tsx
    posts.tsx
    posts.$postId.tsx
    users.$userId.tsx
    -virtual/
      layout.tsx
      index.tsx
      inspector.tsx
tsr.config.json

The generated routeTree.gen.ts does what you'd expect; it imports each route module, assigns parent relationships, builds the route tree, and augments TanStack's route types. That last part is what keeps typed links like to="/posts/$postId" with params={{ postId: '2' }} working.

A standard tsr.config.json points the generator at the route files and the output:

{
  "routesDirectory": "./src/routes",
  "generatedRouteTree": "./src/routeTree.gen.ts",
  "routeFileIgnorePrefix": "-",
  "quoteStyle": "double",
  "semicolons": true
}

The routeFileIgnorePrefix of - tells TanStack not to discover --prefixed files as ordinary physical routes, which is exactly what makes the virtual route pattern further down possible.

Physical File Routes in Practice

Root route:

import { createRootRoute } from '@tanstack/solid-router'

export const Route = createRootRoute()

Index route:

import Home from '../components/home'
import { getHomeLoaderData } from '../demo-data'

export const Route = createFileRoute({
  component: Home,
  loader: async () => getHomeLoaderData(),
})

Dynamic param route:

import PostDetail from '../components/post-detail'
import { getPostDetailLoaderData } from '../demo-data'

export const Route = createFileRoute({
  component: PostDetail,
  loader: async ({ params }) => getPostDetailLoaderData(params.postId),
})

Loaders stay route-local, params stay typed, and each module stays small and focused. The runtime never cares whether the route tree came from files or from code.

Why This Fits NativeScript

The fit is strong because NativeScript already thinks in screens. A route file here isn't merely a URL-segment definition; it maps naturally onto a screen boundary. The file defines route behavior, its component defines the page UI, and its loader defines the data contract, while the generated tree wires them together type-safely and the provider handles the native navigation.

Modals Still Work the Native Way

Modals are a good example of the split holding up. A route file defines the route, typed search state signals modal intent, and NativeScriptRouterProvider presents it as a real native modal. File authoring and native presentation stay independent.

The Generated Types

The generated routeTree.gen.ts is what turns route files into a strongly typed control plane. In the sample it holds entries like /, /about, /posts, /posts/$postId, /users/$userId, /virtual, and /virtual/inspector, plus augmentations such as FileRoutesByFullPath, FileRoutesByTo, and FileRoutesById. That's what makes links self-checking:

<Link to="/posts/$postId" params={{ postId: '2' }}>
  <label text="Open" />
</Link>

Change a route path or mismatch its params and TypeScript flags it immediately; the feedback loop that keeps navigation refactors safe.

Virtual Routes

Physical file routes are only half of what makes TanStack Router's file story interesting. The other half is virtual routing, which lets route structure come from configuration instead of raw directory shape. That's useful when you want to group screens differently than the folders are laid out, declare a subtree centrally, mix physical discovery with explicitly mounted files, or keep certain files out of normal discovery. On native, that maps onto things like onboarding flows, settings sections, account areas, and feature-flagged route clusters.

The sample adds a /virtual subtree using an inline virtualRouteConfig inside tsr.config.json:

{
  "routesDirectory": "./src/routes",
  "generatedRouteTree": "./src/routeTree.gen.ts",
  "virtualRouteConfig": {
    "type": "root",
    "file": "__root.tsx",
    "children": [
      {
        "type": "physical",
        "directory": ".",
        "pathPrefix": ""
      },
      {
        "type": "route",
        "path": "virtual",
        "file": "-virtual/layout.tsx",
        "children": [
          {
            "type": "index",
            "file": "-virtual/index.tsx"
          },
          {
            "type": "route",
            "path": "inspector",
            "file": "-virtual/inspector.tsx"
          }
        ]
      }
    ]
  },
  "routeFileIgnorePrefix": "-"
}

That config does two things at once: the physical(".") entry preserves the ordinary file-routed app, and the virtual subtree mounts /virtual from files under src/routes/-virtual and files with the - prefix stay out of normal discovery so the config can place them deliberately.

The mounted screens are still ordinary TanStack file route modules. A layout route, for example:

import { Outlet } from '@tanstack/solid-router'
import { Link } from '@nativescript/tanstack-router/solid'

export const Route = createFileRoute({
  component: VirtualRoutesLayout,
})

function VirtualRoutesLayout() {
  return (
    <>
      <actionbar title="Virtual Routes" />
      <scrollview>
        <stacklayout>
          <Link to="/virtual">
            <label text="Overview" />
          </Link>
          <Link to="/virtual/inspector">
            <label text="Inspector" />
          </Link>
          <Outlet />
        </stacklayout>
      </scrollview>
    </>
  )
}

Virtual routing changes where routes are mounted, not how their components are authored.

Why the Pairing Holds Together

The responsibilities never blur. TanStack Router handles matching, parsing, params, loaders, and typed navigation intent, while NativeScript handles page stack mutations, modal presentation, transitions, back button behavior, and lifecycle boundaries. File routing improves how routes are authored without touching how they execute, the generated tree turns that authoring into a compile-time contract rather than a loose convention, and screen-oriented NativeScript apps map onto route files instead of fighting them.

What About Code-Based Routes?

They still work. File routing is additive, not a replacement: keep explicit code-based route trees if you prefer them, switch to filesystem-driven authoring if you don't, or run a hybrid - physical file routes plus virtual route configuration for selected areas.

A Practical Setup Path

The shortest path to file routing in a NativeScript Solid app today:

1. Create the app

ns create myapp --solid
cd myapp

2. Install the router packages

npm install @nativescript/tanstack-router @tanstack/solid-router @tanstack/history

# devDependency only
npm install -D @nativescript/vite

Init Vite:

npx nativescript-vite init

To author virtual route config directly, also install:

npm install -D @tanstack/virtual-file-routes

3. Add route files

Create:

  • src/routes/__root.tsx
  • src/routes/index.tsx
  • src/routes/about.tsx

4. Add tsr.config.json

{
  "routesDirectory": "./src/routes",
  "generatedRouteTree": "./src/routeTree.gen.ts",
  "routeFileIgnorePrefix": "-",
  "quoteStyle": "double",
  "semicolons": true
}

5. Create the NativeScript router from the generated tree

import {
  NativeScriptRouterProvider,
  createNativeScriptRouter,
} from '@nativescript/tanstack-router/solid'
import { routeTree } from './routeTree.gen'

6. Run the app

ns run ios
# or
ns run android

The route tree is generated, and your navigation stays fully native.

One Implementation Note

The sample shows virtual routing through the inline virtualRouteConfig object inside tsr.config.json on purpose. It matches the generator version currently exercised end to end in the NativeScript sample, so the example stays aligned with what works cleanly right now. As the ecosystem evolves, other authoring styles (external virtual route config files, subtree config helpers) can follow. The core is already proven: physical file routes work, mixed physical-plus-virtual routing works, and the runtime behavior stays native.

Sample App

The working sample app used for this post can be found here:

References

Help Accelerate the Work

This is still early work, with plenty of room to improve the ergonomics, examples, tests, and cross-framework reach. If the direction is useful to your team, support helps turn these ideas into durable tooling.

Join the conversation

Share your feedback or ask follow-up questions below.