Repository: TanStack/router (2025-07-17T16:57:27Z)

Issue:
Required Param wins over static Path behind optional Param
### Which project does this relate to?

Router

### Describe the bug

First of all, great job to anyone who worked on the optional path params! 🚀 

Consider the following `routes` structure:

- `admin-levels/$adminlevelId/index.tsx`
- `admin-levels/{-$adminlevelId}/reports.tsx`

Requesting `/admin-levels/reports` will render `admin-levels/${adminlevelId}/index.tsx`. I would expect static path slugs to be higher ranked then a required param, therefore rendering `admin-levels/{-$adminLevelId}/reports.tsx`.

Especially, as this case would otherwise make no sense, because `admin-levels/{-$adminLevelId}/reports.tsx` will never be rendered without an `adminLevelId`.

### Your Example Website or App

https://stackblitz.com/edit/tanstack-router-yifxmvlx?file=src%2Froutes%2Findex.tsx

### Steps to Reproduce the Bug or Issue

Click the link initially shown and notice which page is rendered.

### Expected behavior

`admin-levels/{-$adminLevelId}/reports.tsx` should be rendered when requesting `/admin-levels/reports`.

### Platform

- Router / Start Version: 1.128.0



### Additional context

I've noticed this while migrating an app from Remix – which behaves the way I would expect TSR to also handle this case.

Thanks to @schiller-manuel for discussing this with me!


Discussion:

Patch:
diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts
index 3f7d43faa95..e942505a033 100644
--- a/packages/router-core/src/path.ts
+++ b/packages/router-core/src/path.ts
@@ -7,6 +7,8 @@ export interface Segment {
   value: string
   prefixSegment?: string
   suffixSegment?: string
+  // Indicates if there is a static segment after this required/optional param
+  hasStaticAfter?: boolean
 }
 
 export function joinPaths(paths: Array<string | undefined>) {
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 4748e20afe6..71e309e76c7 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -29,6 +29,7 @@ import { setupScrollRestoration } from './scroll-restoration'
 import { defaultParseSearch, defaultStringifySearch } from './searchParams'
 import { rootRouteId } from './root'
 import { isRedirect, redirect } from './redirect'
+import type { Segment } from './path'
 import type { SearchParser, SearchSerializer } from './searchParams'
 import type { AnyRedirect, ResolvedRedirect } from './redirect'
 import type {
@@ -3177,6 +3178,27 @@ export type ProcessRouteTreeResult<TRouteLike extends RouteLike> = {
   routesByPath: Record<string, TRouteLike>
   flatRoutes: Array<TRouteLike>
 }
+
+const REQUIRED_PARAM_BASE_SCORE = 0.5
+const OPTIONAL_PARAM_BASE_SCORE = 0.4
+const WILDCARD_PARAM_BASE_SCORE = 0.25
+
+function handleParam(segment: Segment, baseScore: number) {
+  if (segment.prefixSegment && segment.suffixSegment) {
+    return baseScore + 0.05
+  }
+
+  if (segment.prefixSegment) {
+    return baseScore + 0.02
+  }
+
+  if (segment.suffixSegment) {
+    return baseScore + 0.01
+  }
+
+  return baseScore
+}
+
 export function processRouteTree<TRouteLike extends RouteLike>({
   routeTree,
   initRoute,
@@ -3223,9 +3245,11 @@ export function processRouteTree<TRouteLike extends RouteLike>({
   const scoredRoutes: Array<{
     child: TRouteLike
     trimmed: string
-    parsed: ReturnType<typeof parsePathname>
+    parsed: Array<Segment>
     index: number
     scores: Array<number>
+    hasStaticAfter: boolean
+    optionalParamCount: number
   }> = []
 
   const routes: Array<TRouteLike> = Object.values(routesById)
@@ -3243,63 +3267,50 @@ export function processRouteTree<TRouteLike extends RouteLike>({
       parsed.shift()
     }
 
-    const scores = parsed.map((segment) => {
+    let optionalParamCount = 0
+    let hasStaticAfter = false
+    const scores = parsed.map((segment, index) => {
       if (segment.value === '/') {
         return 0.75
       }
 
+      let baseScore: number | undefined = undefined
       if (segment.type === 'param') {
-        if (segment.prefixSegment && segment.suffixSegment) {
-          return 0.55
-        }
-
-        if (segment.prefixSegment) {
-          return 0.52
-        }
-
-        if (segment.suffixSegment) {
-          return 0.51
-        }
-
-        return 0.5
+        baseScore = REQUIRED_PARAM_BASE_SCORE
+      } else if (segment.type === 'optional-param') {
+        baseScore = OPTIONAL_PARAM_BASE_SCORE
+        optionalParamCount++
+      } else if (segment.type === 'wildcard') {
+        baseScore = WILDCARD_PARAM_BASE_SCORE
       }
 
-      if (segment.type === 'optional-param') {
-        if (segment.prefixSegment && segment.suffixSegment) {
-          return 0.45
-        }
-
-        if (segment.prefixSegment) {
-          return 0.42
-        }
-
-        if (segment.suffixSegment) {
-          return 0.41
-        }
-
-        return 0.4
-      }
-
-      if (segment.type === 'wildcard') {
-        if (segment.prefixSegment && segment.suffixSegment) {
-          return 0.3
-        }
-
-        if (segment.prefixSegment) {
-          return 0.27
-        }
-
-        if (segment.suffixSegment) {
-          return 0.26
+      if (baseScore) {
+        // if there is any static segment (that is not an index) after a required / optional param,
+        // we will boost this param so it ranks higher than a required/optional param without a static segment after it
+        // JUST FOR SORTING, NOT FOR MATCHING
+        for (let i = index + 1; i < parsed.length; i++) {
+          const nextSegment = parsed[i]!
+          if (nextSegment.type === 'pathname' && nextSegment.value !== '/') {
+            hasStaticAfter = true
+            return handleParam(segment, baseScore + 0.2)
+          }
         }
 
-        return 0.25
+        return handleParam(segment, baseScore)
       }
 
       return 1
     })
 
-    scoredRoutes.push({ child: d, trimmed, parsed, index: i, scores })
+    scoredRoutes.push({
+      child: d,
+      trimmed,
+      parsed,
+      index: i,
+      scores,
+      optionalParamCount,
+      hasStaticAfter,
+    })
   })
 
   const flatRoutes = scoredRoutes
@@ -3315,17 +3326,16 @@ export function processRouteTree<TRouteLike extends RouteLike>({
 
       // If all common segments have equal scores, then consider length and specificity
       if (a.scores.length !== b.scores.length) {
-        // Count optional parameters in each route
-        const aOptionalCount = a.parsed.filter(
-          (seg) => seg.type === 'optional-param',
-        ).length
-        const bOptionalCount = b.parsed.filter(
-          (seg) => seg.type === 'optional-param',
-        ).length
-
         // If different number of optional parameters, fewer optional parameters wins (more specific)
-        if (aOptionalCount !== bOptionalCount) {
-          return aOptionalCount - bOptionalCount
+        // only if both or none of the routes has static segments after the params
+        if (a.optionalParamCount !== b.optionalParamCount) {
+          if (a.hasStaticAfter === b.hasStaticAfter) {
+            return a.optionalParamCount - b.optionalParamCount
+          } else if (a.hasStaticAfter && !b.hasStaticAfter) {
+            return -1
+          } else if (!a.hasStaticAfter && b.hasStaticAfter) {
+            return 1
+          }
         }
 
         // If same number of optional parameters, longer path wins (for static segments)


Test patch:
diff --git a/packages/router-core/tests/processRouteTree.test.ts b/packages/router-core/tests/processRouteTree.test.ts
index f7c7cd7f506..c9397eab4f4 100644
--- a/packages/router-core/tests/processRouteTree.test.ts
+++ b/packages/router-core/tests/processRouteTree.test.ts
@@ -384,5 +384,46 @@ describe('processRouteTree', () => {
         '/foo/{-$p1}/{-$p1}',
       ])
     })
+
+    it.each([
+      {
+        routes: ['/foo/{-$p1}/bar', '/foo/{-$p1}', '/foo/$p1', '/foo/$p1/'],
+        expected: ['/foo/{-$p1}/bar', '/foo/$p1/', '/foo/$p1', '/foo/{-$p1}'],
+      },
+      {
+        routes: ['/foo/{-$p1}/{-$p2}/bar', '/foo/$p1/$p2/'],
+        expected: ['/foo/{-$p1}/{-$p2}/bar', '/foo/$p1/$p2/'],
+      },
+      {
+        routes: ['/foo/{-$p1}/$p2/bar', '/foo/$p1/{-$p2}/bar'],
+        expected: ['/foo/$p1/{-$p2}/bar', '/foo/{-$p1}/$p2/bar'],
+      },
+      {
+        routes: [
+          '/foo',
+          '/admin-levels/$adminLevelId/',
+          '/admin-levels/$adminLevelId',
+          '/about',
+          '/admin-levels/{-$adminLevelId}/reports',
+          '/admin-levels/{-$adminLevelId}',
+          '/',
+        ],
+        expected: [
+          '/admin-levels/{-$adminLevelId}/reports',
+          '/admin-levels/$adminLevelId/',
+          '/admin-levels/$adminLevelId',
+          '/about',
+          '/foo',
+          '/admin-levels/{-$adminLevelId}',
+          '/',
+        ],
+      },
+    ])(
+      'static segment after param ranks param higher: $routes',
+      ({ routes, expected }) => {
+        const result = processRouteTree({ routeTree: createRouteTree(routes) })
+        expect(result.flatRoutes.map((r) => r.id)).toEqual(expected)
+      },
+    )
   })
 })