Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8bb9c50
revert router-core changes
nlynzaad Oct 6, 2025
813e902
add experimental nonnested path changes to generator
nlynzaad Oct 6, 2025
bf9e788
update router-generator unit tests
nlynzaad Oct 6, 2025
ee640de
update basic-file-based-e2e
nlynzaad Oct 6, 2025
329360d
cleanup
nlynzaad Oct 7, 2025
1040bda
Merge branch 'main' into non-nested-paths
nlynzaad Oct 7, 2025
2374d63
cleanup
nlynzaad Oct 7, 2025
1ce638a
add tests for isValidNonNestedPaths
nlynzaad Oct 7, 2025
f0a12a6
resolve eslint issues
nlynzaad Oct 7, 2025
4fddb0c
align naming to documentation
nlynzaad Oct 7, 2025
a1a2ba6
cleanup
nlynzaad Oct 7, 2025
ebb74d5
update solidjs basic-file-based tests
nlynzaad Oct 7, 2025
7d8d8d5
update documentation
nlynzaad Oct 7, 2025
1365c21
ci: apply automated fixes
autofix-ci[bot] Oct 7, 2025
126c320
resolve test issues
nlynzaad Oct 7, 2025
cb9bee5
code rabbit suggestions
nlynzaad Oct 7, 2025
638c0db
ci: apply automated fixes
autofix-ci[bot] Oct 7, 2025
7f6e4b5
fix excess period in routeTree path
nlynzaad Oct 7, 2025
d561757
restore snapshot again
nlynzaad Oct 7, 2025
4a51ede
ci: apply automated fixes
autofix-ci[bot] Oct 7, 2025
258ddf2
resolve nitpick
nlynzaad Oct 7, 2025
f4a5975
Merge remote-tracking branch 'origin/non-nested-paths' into non-neste…
nlynzaad Oct 7, 2025
a70e617
resolve removeTrailingUnderscore issue
nlynzaad Oct 8, 2025
16db1a5
fix routeTree path
nlynzaad Oct 8, 2025
bac68cd
final nitpicks
nlynzaad Oct 8, 2025
d692497
better test description
nlynzaad Oct 8, 2025
43220cf
exclude snapshots from prettier
nlynzaad Oct 8, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ File-based routing requires that you follow a few simple file naming conventions

> **💡 Remember:** The file-naming conventions for your project could be affected by what [options](../../../../api/file-based-routing.md) are configured.

> [!NOTE] To escape a trailing underscore, for example `/posts[_].tsx`, usage of the upgraded [Non-Nested Routes](../routing-concepts#non-nested-routes) is required.

## Dynamic Path Params

Dynamic path params can be used in both flat and directory routes to create routes that can match a dynamic segment of the URL path. Dynamic path params are denoted by the `$` character in the filename:
Expand Down
34 changes: 34 additions & 0 deletions docs/router/framework/react/routing/routing-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,40 @@ The following table shows which component will be rendered based on the URL:
- The `posts.$postId.tsx` route is nested as normal under the `posts.tsx` route and will render `<Posts><Post>`.
- The `posts_.$postId.edit.tsx` route **does not share** the same `posts` prefix as the other routes and therefore will be treated as if it is a top-level route and will render `<PostEditor>`.

> [!NOTE]
> While using non-nested routes with file-based routing already works brilliantly, it might misbehave in certain conditions.
> Many of these limitations have already been addressed and will be released in the next major version of TanStack Router.
>
> To start enjoying these benefits early, you can enable the experimental `nonNestedRoutes` flag in the router plugin configuration:
>
> ```ts
> export default defineConfig({
> plugins: [
> tanstackRouter({
> // some config,
> experimental: {
> nonNestedRoutes: true,
> },
> }),
> ],
> })
> ```
>
> _It is important to note that this does bring a slight change in how non-nested routes are referenced in useParams, useNavigate, etc. For this reason this has been released as a feature flag.
> The trailing underscore is no longer expected in the path:_
>
> Previously:
>
> ```ts
> useParams({ from: '/posts_/$postId/edit' })
> ```
>
> Now:
>
> ```ts
> useParams({ from: '/posts/$postId/edit' })
> ```

## Excluding Files and Folders from Routes

Files and folders can be excluded from route generation with a `-` prefix attached to the file name. This gives you the ability to colocate logic in the route directories.
Expand Down
8 changes: 7 additions & 1 deletion e2e/react-router/basic-file-based/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"dev:nonnested": "MODE=nonnested VITE_MODE=nonnested vite --port 3000",
"dev:e2e": "vite",
"build": "vite build && tsc --noEmit",
"build:nonnested": "MODE=nonnested VITE_MODE=nonnested vite build && tsc --noEmit",
"serve": "vite preview",
"serve:nonnested": "MODE=nonnested VITE_MODE=nonnested vite preview",
"start": "vite",
"test:e2e": "rm -rf port*.txt; playwright test --project=chromium"
"start:nonnested": "MODE=nonnested VITE_MODE=nonnested vite",
"test:e2e": "pnpm run test:e2e:nonnested && pnpm run test:e2e:default",
"test:e2e:default": "rm -rf port*.txt; playwright test --project=chromium",
"test:e2e:nonnested": "rm -rf port*.txt; MODE=nonnested playwright test --project=chromium"
},
"dependencies": {
"@tanstack/react-router": "workspace:^",
Expand Down
19 changes: 18 additions & 1 deletion e2e/react-router/basic-file-based/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@ import {
getTestServerPort,
} from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }
import { useExperimentalNonNestedRoutes } from './tests/utils/useExperimentalNonNestedRoutes'

const PORT = await getTestServerPort(packageJson.name)
const EXTERNAL_PORT = await getDummyServerPort(packageJson.name)
const baseURL = `http://localhost:${PORT}`
const experimentalNonNestedPathsModeCommand = `pnpm build:nonnested && pnpm serve:nonnested --port ${PORT}`
const defaultCommand = `pnpm build && pnpm serve --port ${PORT}`
const command = useExperimentalNonNestedRoutes
? experimentalNonNestedPathsModeCommand
: defaultCommand

console.info('Running with mode: ', process.env.MODE || 'default')

/**
* See https://playwright.dev/docs/test-configuration.
*/
Expand All @@ -26,10 +35,18 @@ export default defineConfig({
},

webServer: {
command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && VITE_SERVER_PORT=${PORT} pnpm serve --port ${PORT}`,
command,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
env: {
MODE: process.env.MODE || '',
VITE_MODE: process.env.MODE || '',
VITE_NODE_ENV: 'test',
VITE_EXTERNAL_PORT: String(EXTERNAL_PORT),
VITE_SERVER_PORT: String(PORT),
PORT: String(PORT),
},
},

projects: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { createFileRoute, useParams } from '@tanstack/react-router'
import { useExperimentalNonNestedRoutes } from '../../../../../tests/utils/useExperimentalNonNestedRoutes'

export const Route = createFileRoute('/params-ps/non-nested/$foo_/$bar')({
component: RouteComponent,
})

function RouteComponent() {
const fooParams = useParams({ from: '/params-ps/non-nested/$foo_' })
const fooParams = useParams({
// @ts-expect-error path is updated with new Experimental Non Nested Paths to not include the trailing underscore
from: `/params-ps/non-nested/${useExperimentalNonNestedRoutes ? '$foo' : '$foo_'}`,
})
const routeParams = Route.useParams()

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { createFileRoute } from '@tanstack/react-router'
import { getRouteApi, useParams } from '@tanstack/react-router'
import { createFileRoute, getRouteApi, useParams } from '@tanstack/react-router'
import { useExperimentalNonNestedRoutes } from '../../tests/utils/useExperimentalNonNestedRoutes'

export const Route = createFileRoute('/posts_/$postId/edit')({
component: PostEditPage,
})

const api = getRouteApi('/posts_/$postId/edit')
const api = getRouteApi(
// @ts-expect-error path is updated with new Experimental Non Nested Paths to not include the trailing underscore
`/${useExperimentalNonNestedRoutes ? 'posts' : 'posts_'}/$postId/edit`,
)

function PostEditPage() {
const paramsViaApi = api.useParams()
const paramsViaHook = useParams({ from: '/posts_/$postId/edit' })
const paramsViaHook = useParams({
// @ts-expect-error path is updated with new Experimental Non Nested Paths to not include the trailing underscore
from: `/${useExperimentalNonNestedRoutes ? 'posts' : 'posts_'}/$postId/edit`,
})

const paramsViaRouteHook = Route.useParams()

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test'
import { useExperimentalNonNestedRoutes } from './utils/useExperimentalNonNestedRoutes'

const testCases: Array<{
name: string
Expand Down Expand Up @@ -184,7 +185,13 @@ test.describe('Non-nested paths', () => {
await expect(pathRouteHeading).not.toBeVisible()
await expect(barHeading).toBeVisible()
const bar2ParamValue = await barParams.innerText()
expect(JSON.parse(bar2ParamValue)).toEqual(paramValue2)
if (useExperimentalNonNestedRoutes || testPathDesc !== 'named') {
expect(JSON.parse(bar2ParamValue)).toEqual(paramValue2)
} else {
// this is a bug with named path params and non-nested paths
// that is resolved in the new experimental flag
expect(JSON.parse(bar2ParamValue)).toEqual(paramValue)
}
})
})
},
Expand Down
19 changes: 15 additions & 4 deletions e2e/react-router/basic-file-based/tests/params.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test'
import { useExperimentalNonNestedRoutes } from './utils/useExperimentalNonNestedRoutes'
import type { Page } from '@playwright/test'

test.beforeEach(async ({ page }) => {
Expand Down Expand Up @@ -65,6 +66,8 @@ test.describe('params operations + non-nested routes', () => {

const fooBarLink = page.getByTestId('l-to-non-nested-foo-bar')

const foo2Bar2Link = page.getByTestId('l-to-non-nested-foo2-bar2')

await expect(fooBarLink).toHaveAttribute(
'href',
'/params-ps/non-nested/foo/bar',
Expand All @@ -85,8 +88,6 @@ test.describe('params operations + non-nested routes', () => {
const paramsObj = JSON.parse(paramsText)
expect(paramsObj).toEqual({ foo: 'foo', bar: 'bar' })

const foo2Bar2Link = page.getByTestId('l-to-non-nested-foo2-bar2')

await expect(foo2Bar2Link).toHaveAttribute(
'href',
'/params-ps/non-nested/foo2/bar2',
Expand All @@ -99,12 +100,22 @@ test.describe('params operations + non-nested routes', () => {
const foo2ParamsValue = page.getByTestId('foo-params-value')
const foo2ParamsText = await foo2ParamsValue.innerText()
const foo2ParamsObj = JSON.parse(foo2ParamsText)
expect(foo2ParamsObj).toEqual({ foo: 'foo2' })
if (useExperimentalNonNestedRoutes) {
expect(foo2ParamsObj).toEqual({ foo: 'foo2' })
} else {
// this is a bug that is resolved in the new experimental flag
expect(foo2ParamsObj).toEqual({ foo: 'foo' })
}

const params2Value = page.getByTestId('foo-bar-params-value')
const params2Text = await params2Value.innerText()
const params2Obj = JSON.parse(params2Text)
expect(params2Obj).toEqual({ foo: 'foo2', bar: 'bar2' })
if (useExperimentalNonNestedRoutes) {
expect(params2Obj).toEqual({ foo: 'foo2', bar: 'bar2' })
} else {
// this is a bug that is resolved in the new experimental flag
expect(params2Obj).toEqual({ foo: 'foo', bar: 'bar2' })
}
})
})

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const useExperimentalNonNestedRoutes =
typeof process !== 'undefined'
? typeof process.env.MODE !== 'undefined'
? process.env.MODE === 'nonnested'
: process.env.VITE_MODE === 'nonnested'
: import.meta.env.VITE_MODE === 'nonnested'
10 changes: 9 additions & 1 deletion e2e/react-router/basic-file-based/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,13 @@ import { tanstackRouter } from '@tanstack/router-plugin/vite'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [tanstackRouter({ target: 'react' }), react()],
plugins: [
tanstackRouter({
target: 'react',
experimental: {
nonNestedRoutes: process.env.MODE === 'nonnested',
},
}),
react(),
],
})
8 changes: 7 additions & 1 deletion e2e/solid-router/basic-file-based/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"dev:nonnested": "MODE=nonnested VITE_MODE=nonnested vite --port 3000",
"dev:e2e": "vite",
"build": "vite build && tsc --noEmit",
"build:nonnested": "MODE=nonnested VITE_MODE=nonnested vite build && tsc --noEmit",
"serve": "vite preview",
"serve:nonnested": "MODE=nonnested VITE_MODE=nonnested vite preview",
"start": "vite",
"test:e2e": "rm -rf port*.txt; playwright test --project=chromium"
"start:nonnested": "MODE=nonnested VITE_MODE=nonnested vite",
"test:e2e": "pnpm run test:e2e:nonnested && pnpm run test:e2e:default",
"test:e2e:default": "rm -rf port*.txt; playwright test --project=chromium",
"test:e2e:nonnested": "rm -rf port*.txt; MODE=nonnested playwright test --project=chromium"
},
"dependencies": {
"@tanstack/solid-router": "workspace:^",
Expand Down
19 changes: 18 additions & 1 deletion e2e/solid-router/basic-file-based/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@ import {
getTestServerPort,
} from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }
import { useExperimentalNonNestedRoutes } from './tests/utils/useExperimentalNonNestedRoutes'

const PORT = await getTestServerPort(packageJson.name)
const EXTERNAL_PORT = await getDummyServerPort(packageJson.name)
const baseURL = `http://localhost:${PORT}`
const experimentalNonNestedPathsModeCommand = `pnpm build:nonnested && pnpm serve:nonnested --port ${PORT}`
const defaultCommand = `pnpm build && pnpm serve --port ${PORT}`
const command = useExperimentalNonNestedRoutes
? experimentalNonNestedPathsModeCommand
: defaultCommand

console.info('Running with mode: ', process.env.MODE || 'default')

/**
* See https://playwright.dev/docs/test-configuration.
*/
Expand All @@ -26,10 +35,18 @@ export default defineConfig({
},

webServer: {
command: `VITE_NODE_ENV="test" VITE_EXTERNAL_PORT=${EXTERNAL_PORT} VITE_SERVER_PORT=${PORT} pnpm build && VITE_NODE_ENV="test" VITE_EXTERNAL_PORT=${EXTERNAL_PORT} VITE_SERVER_PORT=${PORT} pnpm serve --port ${PORT}`,
command,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
env: {
MODE: process.env.MODE || '',
VITE_MODE: process.env.MODE || '',
VITE_NODE_ENV: 'test',
VITE_EXTERNAL_PORT: String(EXTERNAL_PORT),
VITE_SERVER_PORT: String(PORT),
PORT: String(PORT),
},
},

projects: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { createFileRoute, useParams } from '@tanstack/solid-router'
import { useExperimentalNonNestedRoutes } from '../../../../../tests/utils/useExperimentalNonNestedRoutes'

export const Route = createFileRoute('/params-ps/non-nested/$foo_/$bar')({
component: RouteComponent,
})

function RouteComponent() {
const fooParams = useParams({ from: '/params-ps/non-nested/$foo_' })
const fooParams = useParams({
// @ts-expect-error path is updated with new Experimental Non Nested Paths to not include the trailing underscore
from: `/params-ps/non-nested/${useExperimentalNonNestedRoutes ? '$foo' : '$foo_'}`,
})
const routeParams = Route.useParams()

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { createFileRoute } from '@tanstack/solid-router'
import { getRouteApi, useParams } from '@tanstack/solid-router'
import { createFileRoute, getRouteApi, useParams } from '@tanstack/solid-router'
import { useExperimentalNonNestedRoutes } from '../../tests/utils/useExperimentalNonNestedRoutes'

export const Route = createFileRoute('/posts_/$postId/edit')({
component: PostEditPage,
})

const api = getRouteApi('/posts_/$postId/edit')
const api = getRouteApi(
// @ts-expect-error path is updated with new Experimental Non Nested Paths to not include the trailing underscore
`/${useExperimentalNonNestedRoutes ? 'posts' : 'posts_'}/$postId/edit`,
)

function PostEditPage() {
const paramsViaApi = api.useParams()
const paramsViaHook = useParams({ from: '/posts_/$postId/edit' })
const paramsViaHook = useParams({
// @ts-expect-error path is updated with new Experimental Non Nested Paths to not include the trailing underscore
from: `/${useExperimentalNonNestedRoutes ? 'posts' : 'posts_'}/$postId/edit`,
})
const paramsViaRouteHook = Route.useParams()

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test'
import { useExperimentalNonNestedRoutes } from './utils/useExperimentalNonNestedRoutes'

const testCases: Array<{
name: string
Expand Down Expand Up @@ -184,7 +185,13 @@ test.describe('Non-nested paths', () => {
await expect(pathRouteHeading).not.toBeVisible()
await expect(barHeading).toBeVisible()
const bar2ParamValue = await barParams.innerText()
expect(JSON.parse(bar2ParamValue)).toEqual(paramValue2)
if (useExperimentalNonNestedRoutes || testPathDesc !== 'named') {
expect(JSON.parse(bar2ParamValue)).toEqual(paramValue2)
} else {
// this is a bug with named path params and non-nested paths
// that is resolved in the new experimental flag
expect(JSON.parse(bar2ParamValue)).toEqual(paramValue)
}
})
})
},
Expand Down
Loading
Loading