Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/router/framework/react/routing/virtual-file-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,43 @@ export const routes = rootRoute('root.tsx', [
])
```

### Merging Physical Routes at Current Level

You can also use `physical` with an empty path prefix (or a single argument) to merge routes from a physical directory directly at the current level, without adding a path prefix. This is useful when you want to organize your routes into separate directories but have them appear at the same URL level.

Consider the following file structure:

```
/routes
├── __root.tsx
├── about.tsx
└── features
├── index.tsx
└── contact.tsx
```

Comment on lines +233 to +241
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Specify language identifier for fenced code block.

The fenced code block showing the file structure should have a language identifier for better rendering and accessibility.

🔎 Proposed fix
-```
+```text
 /routes
 ├── __root.tsx
 ├── about.tsx
 └── features
     ├── index.tsx
     └── contact.tsx

</details>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.18.1)</summary>

233-233: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

In docs/router/framework/react/routing/virtual-file-routes.md around lines 233
to 241, the fenced code block showing the file structure lacks a language
identifier; update the opening fence to include a language (e.g., use ```text)
so the block is rendered correctly and improves accessibility/semantics in docs.


</details>

<!-- fingerprinting:phantom:poseidon:puma -->

<!-- This is an auto-generated comment by CodeRabbit -->

You can merge the `features` directory routes at the root level:

```tsx
// routes.ts
import { physical, rootRoute, route } from '@tanstack/virtual-file-routes'

export const routes = rootRoute('__root.tsx', [
route('/about', 'about.tsx'),
// Merge features/ routes at root level (no path prefix)
physical('features'),
// Or equivalently: physical('', 'features')
])
```

This will produce the following routes:

- `/about` - from `about.tsx`
- `/` - from `features/index.tsx`
- `/contact` - from `features/contact.tsx`

> **Note:** When merging at the same level, ensure there are no conflicting route paths between your virtual routes and the physical directory routes. If a conflict occurs (e.g., both have an `/about` route), the generator will throw an error.

## Virtual Routes inside of TanStack Router File Based routing

The previous section showed you how you can use TanStack Router's File Based routing convention inside of a virtual route configuration.
Expand Down
19 changes: 16 additions & 3 deletions packages/router-generator/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1090,9 +1090,22 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
})

if (transformResult.result === 'no-route-export') {
this.logger.warn(
`Route file "${node.fullPath}" does not contain any route piece. This is likely a mistake.`,
)
const fileName = path.basename(node.fullPath)
const dirName = path.dirname(node.fullPath)
const ignorePrefix = this.config.routeFileIgnorePrefix
const ignorePattern = this.config.routeFileIgnorePattern
const suggestedFileName = `${ignorePrefix}${fileName}`
const suggestedFullPath = path.join(dirName, suggestedFileName)

let message = `Warning: Route file "${node.fullPath}" does not export a Route. This file will not be included in the route tree.`
message += `\n\nIf this file is not intended to be a route, you can exclude it using one of these options:`
message += `\n 1. Rename the file to "${suggestedFullPath}" (prefix with "${ignorePrefix}")`
message += `\n 2. Use 'routeFileIgnorePattern' in your config to match this file`
message += `\n\nCurrent configuration:`
message += `\n routeFileIgnorePrefix: "${ignorePrefix}"`
message += `\n routeFileIgnorePattern: ${ignorePattern ? `"${ignorePattern}"` : 'undefined'}`

this.logger.warn(message)
return null
}
if (transformResult.result === 'error') {
Expand Down
18 changes: 18 additions & 0 deletions packages/router-generator/tests/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ function rewriteConfigByFolderName(folderName: string, config: Config) {
case 'virtual-config-file-default-export':
config.virtualRouteConfig = './routes.ts'
break
case 'virtual-physical-empty-path-merge':
config.virtualRouteConfig = './routes.ts'
break
case 'virtual-physical-empty-path-conflict-root':
config.virtualRouteConfig = './routes.ts'
break
case 'virtual-physical-empty-path-conflict-virtual':
config.virtualRouteConfig = './routes.ts'
break
case 'virtual-physical-no-prefix':
config.virtualRouteConfig = './routes.ts'
break
case 'virtual-with-escaped-underscore':
{
// Test case for escaped underscores in physical routes mounted via virtual config
Expand Down Expand Up @@ -243,6 +255,12 @@ function shouldThrow(folderName: string) {
if (folderName === 'duplicate-fullPath') {
return `Conflicting configuration paths were found for the following routes: "/", "/".`
}
if (folderName === 'virtual-physical-empty-path-conflict-root') {
return `Conflicting configuration paths were found for the following routes: "/__root", "/__root".`
}
if (folderName === 'virtual-physical-empty-path-conflict-virtual') {
return `Conflicting configuration paths were found for the following routes: "/about", "/about".`
}
return undefined
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { physical, rootRoute } from '@tanstack/virtual-file-routes'

// This test verifies that a __root.tsx in a physical directory mounted at root
// produces a proper conflict error with the virtual root
export const routes = rootRoute('__root.tsx', [physical('', 'merged')])
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'

export const Route = createRootRoute({
component: () => <Outlet />,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
component: () => <div>Index</div>,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

// This route.tsx in a physical directory mounted at root level
// conflicts with the virtual root __root.tsx - can't have two root routes
export const Route = createFileRoute('')({})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"routesDirectory": "./routes",
"generatedRouteTree": "./routeTree.gen.ts",
"virtualRouteConfig": "./routes.ts"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { physical, rootRoute, route } from '@tanstack/virtual-file-routes'

// This test verifies that a virtual route path conflicts with
// a physical route path when using empty path prefix
export const routes = rootRoute('__root.tsx', [
route('/about', 'about.tsx'), // virtual /about
physical('', 'merged'), // physical also has about.tsx -> /about
])
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'

export const Route = createRootRoute({
component: () => <Outlet />,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'

// Virtual about route - conflicts with merged/about.tsx
export const Route = createFileRoute('/about')({
component: () => <div>About (virtual)</div>,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'

// Physical about route - conflicts with virtual about.tsx -> /about
export const Route = createFileRoute('/about')({
component: () => <div>About (physical)</div>,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
component: () => <div>Index</div>,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"routesDirectory": "./routes",
"generatedRouteTree": "./routeTree.gen.ts",
"virtualRouteConfig": "./routes.ts"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/* eslint-disable */

// @ts-nocheck

// noinspection JSUnusedGlobalSymbols

// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as ContactRouteImport } from './routes/merged/contact'
import { Route as aboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/merged/index'

const ContactRoute = ContactRouteImport.update({
id: '/contact',
path: '/contact',
getParentRoute: () => rootRouteImport,
} as any)
const aboutRoute = aboutRouteImport.update({
id: '/about',
path: '/about',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof aboutRoute
'/contact': typeof ContactRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof aboutRoute
'/contact': typeof ContactRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof aboutRoute
'/contact': typeof ContactRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about' | '/contact'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/about' | '/contact'
id: '__root__' | '/' | '/about' | '/contact'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
aboutRoute: typeof aboutRoute
ContactRoute: typeof ContactRoute
}

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/contact': {
id: '/contact'
path: '/contact'
fullPath: '/contact'
preLoaderRoute: typeof ContactRouteImport
parentRoute: typeof rootRouteImport
}
'/about': {
id: '/about'
path: '/about'
fullPath: '/about'
preLoaderRoute: typeof aboutRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
aboutRoute: aboutRoute,
ContactRoute: ContactRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { physical, rootRoute, route } from '@tanstack/virtual-file-routes'

export const routes = rootRoute('__root.tsx', [
// Virtual route defined here
route('/about', 'about.tsx'),
// Physical mount with empty path - should merge into root level
physical('', 'merged'),
])
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createRootRoute } from '@tanstack/react-router'

export const Route = createRootRoute()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/contact')()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')()
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"routesDirectory": "./routes",
"generatedRouteTree": "./routeTree.gen.ts",
"virtualRouteConfig": "./routes.ts"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/* eslint-disable */

// @ts-nocheck

// noinspection JSUnusedGlobalSymbols

// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as ContactRouteImport } from './routes/merged/contact'
import { Route as aboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/merged/index'

const ContactRoute = ContactRouteImport.update({
id: '/contact',
path: '/contact',
getParentRoute: () => rootRouteImport,
} as any)
const aboutRoute = aboutRouteImport.update({
id: '/about',
path: '/about',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof aboutRoute
'/contact': typeof ContactRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof aboutRoute
'/contact': typeof ContactRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof aboutRoute
'/contact': typeof ContactRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about' | '/contact'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/about' | '/contact'
id: '__root__' | '/' | '/about' | '/contact'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
aboutRoute: typeof aboutRoute
ContactRoute: typeof ContactRoute
}

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/contact': {
id: '/contact'
path: '/contact'
fullPath: '/contact'
preLoaderRoute: typeof ContactRouteImport
parentRoute: typeof rootRouteImport
}
'/about': {
id: '/about'
path: '/about'
fullPath: '/about'
preLoaderRoute: typeof aboutRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
aboutRoute: aboutRoute,
ContactRoute: ContactRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
Loading