build(web)!: migrate from react-router to @tanstack/react-router

Quentin Gliech and Claude Opus 4.6 (1M context) created

replace react-router v7 with @tanstack/react-router for fully
type-safe routing with typed Link params and search params

- define code-based route tree with createRootRoute/createRoute
- register router type for global type inference
- add typed validateSearch for code page search params (ref, path, type)
- replace NavLink with Link + useMatchRoute for active state detection
- replace useSearchParams with useSearch/useNavigate
- replace useRouteError/isRouteErrorResponse with errorComponent props
- use typed Link params instead of string interpolation for all routes
- replace navigate(-1) with window.history.back()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

webui2/package.json                       |   2 
webui2/pnpm-lock.yaml                     | 106 +++++++++++++++-----
webui2/src/App.tsx                        | 124 +++++++++++++++++++-----
webui2/src/components/bugs/BugRow.tsx     |   2 
webui2/src/components/bugs/Timeline.tsx   |  14 +-
webui2/src/components/code/CommitList.tsx |   2 
webui2/src/components/code/FileTree.tsx   |   7 
webui2/src/components/layout/Header.tsx   |  66 +++++++------
webui2/src/components/layout/Shell.tsx    |   2 
webui2/src/lib/repo.tsx                   |  14 +-
webui2/src/pages/BugDetailPage.tsx        |   4 
webui2/src/pages/CodePage.tsx             |  49 +++------
webui2/src/pages/CommitPage.tsx           |  10 +-
webui2/src/pages/ErrorPage.tsx            |  40 +++----
webui2/src/pages/NewBugPage.tsx           |   6 
webui2/src/pages/RepoPickerPage.tsx       |  13 ++
webui2/src/pages/UserProfilePage.tsx      |  12 +-
17 files changed, 295 insertions(+), 178 deletions(-)

Detailed changes

webui2/package.json 🔗

@@ -16,6 +16,7 @@
   },
   "dependencies": {
     "@apollo/client": "^4.1.6",
+    "@tanstack/react-router": "^1.168.8",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "date-fns": "^4.1.0",
@@ -26,7 +27,6 @@
     "react": "^19.1.0",
     "react-dom": "^19.1.0",
     "react-markdown": "^10.1.0",
-    "react-router": "^7.13.2",
     "rehype-autolink-headings": "^7.1.0",
     "rehype-external-links": "^3.0.0",
     "rehype-raw": "^7.0.0",

webui2/pnpm-lock.yaml 🔗

@@ -11,6 +11,9 @@ importers:
       '@apollo/client':
         specifier: ^4.1.6
         version: 4.1.6(graphql-ws@6.0.8(graphql@16.13.2)(ws@8.20.0))(graphql@16.13.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
+      '@tanstack/react-router':
+        specifier: ^1.168.8
+        version: 1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
       class-variance-authority:
         specifier: ^0.7.1
         version: 0.7.1
@@ -41,9 +44,6 @@ importers:
       react-markdown:
         specifier: ^10.1.0
         version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
-      react-router:
-        specifier: ^7.13.2
-        version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
       rehype-autolink-headings:
         specifier: ^7.1.0
         version: 7.1.0
@@ -1847,6 +1847,31 @@ packages:
     peerDependencies:
       vite: ^5.2.0 || ^6 || ^7 || ^8
 
+  '@tanstack/history@1.161.6':
+    resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==}
+    engines: {node: '>=20.19'}
+
+  '@tanstack/react-router@1.168.8':
+    resolution: {integrity: sha512-t0S0QueXubBKmI9eLPcN/A1sLQgTu8/yHerjrvvsGeD12zMdw0uJPKwEKpStQF2OThQtw64cs34uUSYXBUTSNw==}
+    engines: {node: '>=20.19'}
+    peerDependencies:
+      react: '>=18.0.0 || >=19.0.0'
+      react-dom: '>=18.0.0 || >=19.0.0'
+
+  '@tanstack/react-store@0.9.3':
+    resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+      react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+  '@tanstack/router-core@1.168.7':
+    resolution: {integrity: sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ==}
+    engines: {node: '>=20.19'}
+    hasBin: true
+
+  '@tanstack/store@0.9.3':
+    resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
+
   '@tybys/wasm-util@0.10.1':
     resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
 
@@ -2087,9 +2112,8 @@ packages:
   convert-source-map@2.0.0:
     resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
 
-  cookie@1.1.1:
-    resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
-    engines: {node: '>=18'}
+  cookie-es@2.0.0:
+    resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==}
 
   cosmiconfig@8.3.6:
     resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==}
@@ -2470,6 +2494,10 @@ packages:
     resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
     engines: {node: '>=0.10.0'}
 
+  isbot@5.1.36:
+    resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==}
+    engines: {node: '>=18'}
+
   isomorphic-ws@5.0.0:
     resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
     peerDependencies:
@@ -2961,16 +2989,6 @@ packages:
       '@types/react':
         optional: true
 
-  react-router@7.13.2:
-    resolution: {integrity: sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==}
-    engines: {node: '>=20.0.0'}
-    peerDependencies:
-      react: '>=18'
-      react-dom: '>=18'
-    peerDependenciesMeta:
-      react-dom:
-        optional: true
-
   react-style-singleton@2.2.3:
     resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
     engines: {node: '>=10'}
@@ -3072,8 +3090,15 @@ packages:
   sentence-case@3.0.4:
     resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==}
 
-  set-cookie-parser@2.7.2:
-    resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+  seroval-plugins@1.5.1:
+    resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      seroval: ^1.0
+
+  seroval@1.5.1:
+    resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
+    engines: {node: '>=10'}
 
   shell-quote@1.8.3:
     resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
@@ -5163,6 +5188,33 @@ snapshots:
       tailwindcss: 4.2.2
       vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3)
 
+  '@tanstack/history@1.161.6': {}
+
+  '@tanstack/react-router@1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+    dependencies:
+      '@tanstack/history': 1.161.6
+      '@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      '@tanstack/router-core': 1.168.7
+      isbot: 5.1.36
+      react: 19.2.4
+      react-dom: 19.2.4(react@19.2.4)
+
+  '@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+    dependencies:
+      '@tanstack/store': 0.9.3
+      react: 19.2.4
+      react-dom: 19.2.4(react@19.2.4)
+      use-sync-external-store: 1.6.0(react@19.2.4)
+
+  '@tanstack/router-core@1.168.7':
+    dependencies:
+      '@tanstack/history': 1.161.6
+      cookie-es: 2.0.0
+      seroval: 1.5.1
+      seroval-plugins: 1.5.1(seroval@1.5.1)
+
+  '@tanstack/store@0.9.3': {}
+
   '@tybys/wasm-util@0.10.1':
     dependencies:
       tslib: 2.8.1
@@ -5403,7 +5455,7 @@ snapshots:
 
   convert-source-map@2.0.0: {}
 
-  cookie@1.1.1: {}
+  cookie-es@2.0.0: {}
 
   cosmiconfig@8.3.6(typescript@6.0.2):
     dependencies:
@@ -5778,6 +5830,8 @@ snapshots:
 
   is-windows@1.0.2: {}
 
+  isbot@5.1.36: {}
+
   isomorphic-ws@5.0.0(ws@8.20.0):
     dependencies:
       ws: 8.20.0
@@ -6556,14 +6610,6 @@ snapshots:
     optionalDependencies:
       '@types/react': 19.2.14
 
-  react-router@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
-    dependencies:
-      cookie: 1.1.1
-      react: 19.2.4
-      set-cookie-parser: 2.7.2
-    optionalDependencies:
-      react-dom: 19.2.4(react@19.2.4)
-
   react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
     dependencies:
       get-nonce: 1.0.1
@@ -6718,7 +6764,11 @@ snapshots:
       tslib: 2.8.1
       upper-case-first: 2.0.2
 
-  set-cookie-parser@2.7.2: {}
+  seroval-plugins@1.5.1(seroval@1.5.1):
+    dependencies:
+      seroval: 1.5.1
+
+  seroval@1.5.1: {}
 
   shell-quote@1.8.3: {}
 

webui2/src/App.tsx 🔗

@@ -1,4 +1,4 @@
-import { createBrowserRouter, RouterProvider } from "react-router";
+import { createRootRoute, createRoute, createRouter, RouterProvider } from "@tanstack/react-router";
 
 import { Shell } from "@/components/layout/Shell";
 import { RepoShell } from "@/lib/repo";
@@ -12,35 +12,103 @@ import { NewBugPage } from "@/pages/NewBugPage";
 import { RepoPickerPage } from "@/pages/RepoPickerPage";
 import { UserProfilePage } from "@/pages/UserProfilePage";
 
-// Route structure:
-//   /                          → repo picker (or redirect if single repo)
-//   /:repo                     → code browser (repo home)
-//   /:repo/issues              → issue list
-//   /auth/select-identity      → OAuth identity adoption (first-time login)
-const router = createBrowserRouter([
-  {
-    path: "/",
-    element: <Shell />,
-    errorElement: <ErrorPage />,
-    children: [
-      { index: true, element: <RepoPickerPage /> },
-      { path: "auth/select-identity", element: <IdentitySelectPage /> },
-      {
-        path: ":repo",
-        element: <RepoShell />,
-        children: [
-          { index: true, element: <CodePage /> },
-          { path: "issues", element: <BugListPage /> },
-          { path: "issues/new", element: <NewBugPage /> },
-          { path: "issues/:id", element: <BugDetailPage /> },
-          { path: "user/:id", element: <UserProfilePage /> },
-          { path: "commit/:hash", element: <CommitPage /> },
-        ],
-      },
-    ],
-  },
+// ── Route tree ───────────────────────────────────────────────────────────────
+
+const rootRoute = createRootRoute({
+  component: Shell,
+  errorComponent: ErrorPage,
+});
+
+const indexRoute = createRoute({
+  getParentRoute: () => rootRoute,
+  path: "/",
+  component: RepoPickerPage,
+});
+
+const authSelectIdentityRoute = createRoute({
+  getParentRoute: () => rootRoute,
+  path: "/auth/select-identity",
+  component: IdentitySelectPage,
+});
+
+const repoRoute = createRoute({
+  getParentRoute: () => rootRoute,
+  path: "/$repo",
+  component: RepoShell,
+});
+
+export type CodePageSearch = {
+  ref: string;
+  path: string;
+  type: "tree" | "blob" | "commits";
+};
+
+const repoIndexRoute = createRoute({
+  getParentRoute: () => repoRoute,
+  path: "/",
+  component: CodePage,
+  validateSearch: (search: Record<string, unknown>): CodePageSearch => ({
+    ref: (search.ref as string) ?? "",
+    path: (search.path as string) ?? "",
+    type: ["tree", "blob", "commits"].includes(search.type as string)
+      ? (search.type as CodePageSearch["type"])
+      : "tree",
+  }),
+});
+
+const bugListRoute = createRoute({
+  getParentRoute: () => repoRoute,
+  path: "/issues",
+  component: BugListPage,
+});
+
+const newBugRoute = createRoute({
+  getParentRoute: () => repoRoute,
+  path: "/issues/new",
+  component: NewBugPage,
+});
+
+const bugDetailRoute = createRoute({
+  getParentRoute: () => repoRoute,
+  path: "/issues/$id",
+  component: BugDetailPage,
+});
+
+const userProfileRoute = createRoute({
+  getParentRoute: () => repoRoute,
+  path: "/user/$id",
+  component: UserProfilePage,
+});
+
+const commitRoute = createRoute({
+  getParentRoute: () => repoRoute,
+  path: "/commit/$hash",
+  component: CommitPage,
+});
+
+const routeTree = rootRoute.addChildren([
+  indexRoute,
+  authSelectIdentityRoute,
+  repoRoute.addChildren([
+    repoIndexRoute,
+    bugListRoute,
+    newBugRoute,
+    bugDetailRoute,
+    userProfileRoute,
+    commitRoute,
+  ]),
 ]);
 
+// ── Router instance ──────────────────────────────────────────────────────────
+
+const router = createRouter({ routeTree });
+
+declare module "@tanstack/react-router" {
+  interface Register {
+    router: typeof router;
+  }
+}
+
 export function App() {
   return <RouterProvider router={router} />;
 }

webui2/src/components/bugs/BugRow.tsx 🔗

@@ -1,6 +1,6 @@
+import { Link } from "@tanstack/react-router";
 import { formatDistanceToNow } from "date-fns";
 import { MessageSquare, CircleDot, CircleCheck } from "lucide-react";
-import { Link } from "react-router";
 
 import { Status } from "@/__generated__/graphql";
 

webui2/src/components/bugs/Timeline.tsx 🔗

@@ -1,7 +1,7 @@
+import { Link } from "@tanstack/react-router";
 import { formatDistanceToNow } from "date-fns";
 import { Tag, GitPullRequestClosed, Pencil, CircleDot } from "lucide-react";
 import { useState } from "react";
-import { Link } from "react-router";
 
 import {
   Status,
@@ -98,7 +98,8 @@ function CommentItem({ item, bugPrefix }: { item: CommentItem; bugPrefix: string
       <div className="border-border min-w-0 flex-1 rounded-md border">
         <div className="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm">
           <Link
-            to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
+            to="/$repo/user/$id"
+            params={{ repo: repo!, id: item.author.humanId }}
             className="text-foreground font-medium hover:underline"
           >
             {item.author.displayName}
@@ -177,7 +178,8 @@ function LabelChangeItem({ item }: { item: LabelChangeItem }) {
     <EventRow icon={<Tag className="size-4" />}>
       <span>
         <Link
-          to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
+          to="/$repo/user/$id"
+          params={{ repo: repo!, id: item.author.humanId }}
           className="text-foreground font-medium hover:underline"
         >
           {item.author.displayName}
@@ -219,7 +221,8 @@ function StatusChangeItem({ item }: { item: StatusChangeItem }) {
     >
       <span>
         <Link
-          to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
+          to="/$repo/user/$id"
+          params={{ repo: repo!, id: item.author.humanId }}
           className="text-foreground font-medium hover:underline"
         >
           {item.author.displayName}
@@ -237,7 +240,8 @@ function TitleChangeItem({ item }: { item: TitleChangeItem }) {
     <EventRow icon={<Pencil className="size-4" />}>
       <span>
         <Link
-          to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
+          to="/$repo/user/$id"
+          params={{ repo: repo!, id: item.author.humanId }}
           className="text-foreground font-medium hover:underline"
         >
           {item.author.displayName}

webui2/src/components/code/CommitList.tsx 🔗

@@ -3,10 +3,10 @@
 
 import { gql } from "@apollo/client";
 import { useQuery } from "@apollo/client/react";
+import { Link } from "@tanstack/react-router";
 import { formatDistanceToNow } from "date-fns";
 import { GitCommit } from "lucide-react";
 import { useEffect, useState } from "react";
-import { Link } from "react-router";
 
 import { Button } from "@/components/ui/button";
 import { Skeleton } from "@/components/ui/skeleton";

webui2/src/components/code/FileTree.tsx 🔗

@@ -1,6 +1,6 @@
+import { Link } from "@tanstack/react-router";
 import { formatDistanceToNow } from "date-fns";
 import { Folder, File } from "lucide-react";
-import { Link } from "react-router";
 
 import type { GitTreeEntry } from "@/__generated__/graphql";
 import { Skeleton } from "@/components/ui/skeleton";
@@ -84,9 +84,8 @@ function FileTreeRow({
       <td className="text-muted-foreground hidden max-w-xs truncate px-3 py-2 md:table-cell">
         {entry.lastCommit && (
           <Link
-            to={
-              repo ? `/${repo}/commit/${entry.lastCommit.hash}` : `/commit/${entry.lastCommit.hash}`
-            }
+            to="/$repo/commit/$hash"
+            params={{ repo: repo!, hash: entry.lastCommit.hash }}
             className="hover:text-foreground hover:underline"
             onClick={(e) => e.stopPropagation()}
           >

webui2/src/components/layout/Header.tsx 🔗

@@ -6,8 +6,8 @@
 // In external mode, shows a "Sign in" button when logged out and a sign-out
 // action when logged in.
 
+import { Link, useMatchRoute, useParams } from "@tanstack/react-router";
 import { Bug, Plus, Sun, Moon, LogIn, LogOut } from "lucide-react";
-import { Link, useMatch, NavLink } from "react-router";
 
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { Button } from "@/components/ui/button";
@@ -34,15 +34,21 @@ export function Header() {
   const { user, mode, loginProviders } = useAuth();
   const { theme, toggle } = useTheme();
 
-  // Detect if we're inside a /:repo route and grab the slug.
-  // useMatch works from any component in the tree, unlike useParams which is
-  // scoped to the nearest Route element.
-  const repoMatch = useMatch({ path: "/:repo/*", end: false });
-  const repo = repoMatch?.params.repo ?? null;
+  // Detect if we're inside a /$repo route and grab the slug.
+  const params = useParams({ strict: false });
+  const repo = params.repo ?? null;
 
   // Don't show repo nav on the /auth/* pages.
   const effectiveRepo = repo === "auth" ? null : repo;
 
+  const matchRoute = useMatchRoute();
+  const isCodeActive = effectiveRepo
+    ? !!matchRoute({ to: "/$repo", params: { repo: effectiveRepo }, fuzzy: false })
+    : false;
+  const isIssuesActive = effectiveRepo
+    ? !!matchRoute({ to: "/$repo/issues", params: { repo: effectiveRepo }, fuzzy: true })
+    : false;
+
   return (
     <header className="border-border bg-background/95 sticky top-0 z-50 border-b backdrop-blur">
       <div className="mx-auto flex h-14 max-w-screen-xl items-center gap-6 px-4">
@@ -55,33 +61,31 @@ export function Header() {
         {/* Repo-scoped nav links — only shown when inside a repo */}
         {effectiveRepo && (
           <nav className="flex items-center gap-1">
-            <NavLink
-              to={`/${effectiveRepo}`}
-              end
-              className={({ isActive }) =>
-                cn(
-                  "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
-                  isActive
-                    ? "bg-accent text-accent-foreground"
-                    : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
-                )
-              }
+            <Link
+              to="/$repo"
+              params={{ repo: effectiveRepo }}
+              search={{ ref: "", path: "", type: "tree" as const }}
+              className={cn(
+                "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+                isCodeActive
+                  ? "bg-accent text-accent-foreground"
+                  : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
+              )}
             >
               Code
-            </NavLink>
-            <NavLink
-              to={`/${effectiveRepo}/issues`}
-              className={({ isActive }) =>
-                cn(
-                  "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
-                  isActive
-                    ? "bg-accent text-accent-foreground"
-                    : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
-                )
-              }
+            </Link>
+            <Link
+              to="/$repo/issues"
+              params={{ repo: effectiveRepo }}
+              className={cn(
+                "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+                isIssuesActive
+                  ? "bg-accent text-accent-foreground"
+                  : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
+              )}
             >
               Issues
-            </NavLink>
+            </Link>
           </nav>
         )}
 
@@ -107,12 +111,12 @@ export function Header() {
           {user && effectiveRepo && (
             <>
               <Button asChild size="sm">
-                <Link to={`/${effectiveRepo}/issues/new`}>
+                <Link to="/$repo/issues/new" params={{ repo: effectiveRepo }}>
                   <Plus className="size-4" />
                   New issue
                 </Link>
               </Button>
-              <Link to={`/${effectiveRepo}/user/${user.humanId}`}>
+              <Link to="/$repo/user/$id" params={{ repo: effectiveRepo, id: user.humanId }}>
                 <Avatar className="size-7">
                   <AvatarImage src={user.avatarUrl ?? undefined} alt={user.displayName} />
                   <AvatarFallback className="text-xs">

webui2/src/lib/repo.tsx 🔗

@@ -1,20 +1,20 @@
-// Provides the current repository slug (the :repo URL segment) to all
-// components rendered inside a /:repo/* route.
+// Provides the current repository slug (the $repo URL segment) to all
+// components rendered inside a /$repo/* route.
 //
 // Usage:
-//   - Wrap the /:repo route subtree with <RepoShell /> as the route element.
+//   - Wrap the /$repo route subtree with <RepoShell /> as the route element.
 //   - Read the current slug in any child component with useRepo().
 //   - Pass the slug as `ref` to all GraphQL repository queries.
 
+import { Outlet, useParams } from "@tanstack/react-router";
 import { createContext, useContext } from "react";
-import { useParams, Outlet } from "react-router";
 
 const RepoContext = createContext<string | null>(null);
 
-// Route element for /:repo routes. Reads the :repo param and provides it
+// Route element for /$repo routes. Reads the $repo param and provides it
 // via context so any descendant can call useRepo() without prop drilling.
 export function RepoShell() {
-  const { repo } = useParams<{ repo: string }>();
+  const { repo } = useParams({ strict: false });
   return (
     <RepoContext.Provider value={repo ?? null}>
       <Outlet />
@@ -23,7 +23,7 @@ export function RepoShell() {
 }
 
 // Returns the current repo slug from the nearest RepoShell ancestor.
-// Returns null when rendered outside of a /:repo route (e.g. the picker page).
+// Returns null when rendered outside of a /$repo route (e.g. the picker page).
 export function useRepo(): string | null {
   return useContext(RepoContext);
 }

webui2/src/pages/BugDetailPage.tsx 🔗

@@ -1,6 +1,6 @@
+import { useParams, Link } from "@tanstack/react-router";
 import { formatDistanceToNow } from "date-fns";
 import { ArrowLeft } from "lucide-react";
-import { useParams, Link } from "react-router";
 
 import { useBugDetailQuery } from "@/__generated__/graphql";
 import { CommentBox } from "@/components/bugs/CommentBox";
@@ -16,7 +16,7 @@ import { useRepo } from "@/lib/repo";
 // Issue detail page (/:repo/issues/:id). Shows title, status, timeline of
 // comments and events, and a sidebar with labels and participants.
 export function BugDetailPage() {
-  const { id } = useParams<{ id: string }>();
+  const { id } = useParams({ strict: false });
   const repo = useRepo();
   const { data, loading, error } = useBugDetailQuery({
     variables: { ref: repo, prefix: id! },

webui2/src/pages/CodePage.tsx 🔗

@@ -3,9 +3,9 @@
 
 import { gql } from "@apollo/client";
 import { useQuery } from "@apollo/client/react";
+import { useNavigate, useSearch } from "@tanstack/react-router";
 import { AlertCircle, GitCommit } from "lucide-react";
 import { useEffect } from "react";
-import { useSearchParams } from "react-router";
 
 import type { GitRef, GitTreeEntry, GitBlob, GitLastCommit } from "@/__generated__/graphql";
 import { CodeBreadcrumb } from "@/components/code/CodeBreadcrumb";
@@ -104,15 +104,14 @@ interface BlobQueryData {
   } | null;
 }
 
-type ViewMode = "tree" | "blob" | "commits";
+import type { CodePageSearch } from "@/App";
+
+type ViewMode = CodePageSearch["type"];
 
 export function CodePage() {
   const repo = useRepo();
-  const [searchParams, setSearchParams] = useSearchParams();
-
-  const currentRef = searchParams.get("ref") ?? "";
-  const currentPath = searchParams.get("path") ?? "";
-  const viewMode: ViewMode = (searchParams.get("type") as ViewMode) ?? "tree";
+  const navigate = useNavigate({ from: "/$repo/" });
+  const { ref: currentRef, path: currentPath, type: viewMode } = useSearch({ from: "/$repo/" });
 
   const {
     data: refsData,
@@ -125,16 +124,13 @@ export function CodePage() {
 
   // Set default ref from query result once loaded
   useEffect(() => {
-    if (refsLoading || refs.length === 0 || searchParams.get("ref")) return;
+    if (refsLoading || refs.length === 0 || currentRef) return;
     const defaultRef = refs.find((r: GitRef) => r.isDefault) ?? refs[0];
     if (defaultRef) {
-      setSearchParams(
-        (prev) => {
-          prev.set("ref", defaultRef.shortName);
-          return prev;
-        },
-        { replace: true },
-      );
+      void navigate({
+        search: (prev) => ({ ...prev, ref: defaultRef.shortName }),
+        replace: true,
+      });
     }
   }, [refsLoading, refs.length]); // eslint-disable-line react-hooks/exhaustive-deps
 
@@ -182,32 +178,23 @@ export function CodePage() {
 
   const repoName = refsData?.repository?.name ?? repo ?? "default-repo";
 
-  function navigate(path: string, type: ViewMode = "tree") {
-    setSearchParams((prev) => {
-      prev.set("path", path);
-      prev.set("type", type);
-      return prev;
-    });
+  function navigateTo(path: string, type: ViewMode = "tree") {
+    void navigate({ search: (prev) => ({ ...prev, path, type }) });
   }
 
   function handleEntryClick(entry: TreeEntryWithCommit) {
     const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
-    navigate(newPath, entry.type === "BLOB" ? "blob" : "tree");
+    navigateTo(newPath, entry.type === "BLOB" ? "blob" : "tree");
   }
 
   function handleNavigateUp() {
     const parts = currentPath.split("/").filter(Boolean);
     parts.pop();
-    navigate(parts.join("/"), "tree");
+    navigateTo(parts.join("/"), "tree");
   }
 
   function handleRefSelect(ref: GitRef) {
-    setSearchParams((prev) => {
-      prev.set("ref", ref.shortName);
-      prev.set("path", "");
-      prev.set("type", "tree");
-      return prev;
-    });
+    void navigate({ search: { ref: ref.shortName, path: "", type: "tree" } });
   }
 
   if (refsError) {
@@ -230,7 +217,7 @@ export function CodePage() {
             repoName={repoName}
             ref={currentRef}
             path={currentPath}
-            onNavigate={(p) => navigate(p, "tree")}
+            onNavigate={(p) => navigateTo(p, "tree")}
           />
         )}
         <div className="flex items-center gap-2">
@@ -238,7 +225,7 @@ export function CodePage() {
             <Button
               variant={viewMode === "commits" ? "secondary" : "outline"}
               size="sm"
-              onClick={() => navigate(currentPath, viewMode === "commits" ? "tree" : "commits")}
+              onClick={() => navigateTo(currentPath, viewMode === "commits" ? "tree" : "commits")}
             >
               <GitCommit className="size-3.5" />
               History

webui2/src/pages/CommitPage.tsx 🔗

@@ -3,9 +3,9 @@
 
 import { gql } from "@apollo/client";
 import { useQuery } from "@apollo/client/react";
+import { Link, useParams } from "@tanstack/react-router";
 import { format } from "date-fns";
 import { ArrowLeft, GitCommit } from "lucide-react";
-import { Link, useParams, useNavigate } from "react-router";
 
 import { FileDiffView } from "@/components/code/FileDiffView";
 import { Skeleton } from "@/components/ui/skeleton";
@@ -54,8 +54,7 @@ interface CommitQueryData {
 }
 
 export function CommitPage() {
-  const { hash } = useParams<{ hash: string }>();
-  const navigate = useNavigate();
+  const { hash } = useParams({ strict: false });
   const repo = useRepo();
 
   const { data, loading, error } = useQuery<CommitQueryData>(COMMIT_QUERY, {
@@ -83,7 +82,7 @@ export function CommitPage() {
     <div>
       <button
         onClick={() => {
-          void navigate(-1);
+          window.history.back();
         }}
         className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
       >
@@ -119,7 +118,8 @@ export function CommitPage() {
             <span key={p} className="text-muted-foreground">
               parent{" "}
               <Link
-                to={repo ? `/${repo}/commit/${p}` : `/commit/${p}`}
+                to="/$repo/commit/$hash"
+                params={{ repo: repo!, hash: p }}
                 className="text-foreground font-mono hover:underline"
               >
                 {p.slice(0, 7)}

webui2/src/pages/ErrorPage.tsx 🔗

@@ -1,35 +1,33 @@
-// Global error boundary page. Rendered by React Router when a route throws
-// or when navigation results in a 404. Replaces the default "Unexpected
-// Application Error!" screen.
+// Global error boundary page. Rendered by TanStack Router when a route throws.
 
+import { Link, useRouter } from "@tanstack/react-router";
 import { AlertTriangle } from "lucide-react";
-import { useRouteError, isRouteErrorResponse, Link } from "react-router";
 
 import { Button } from "@/components/ui/button";
 
-export function ErrorPage() {
-  const error = useRouteError();
+export function ErrorPage({ error }: { error?: Error }) {
+  const router = useRouter();
 
-  let status: number | undefined;
-  let message: string;
-
-  if (isRouteErrorResponse(error)) {
-    status = error.status;
-    message = error.statusText || error.data;
-  } else if (error instanceof Error) {
-    message = error.message;
-  } else {
-    message = "An unexpected error occurred.";
-  }
+  const message = error?.message ?? "An unexpected error occurred.";
 
   return (
     <div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
       <AlertTriangle className="text-muted-foreground size-10" />
-      {status && <p className="text-5xl font-bold tracking-tight">{status}</p>}
       <p className="text-muted-foreground text-sm">{message}</p>
-      <Button variant="outline" size="sm" asChild>
-        <Link to="/">Go home</Link>
-      </Button>
+      <div className="flex gap-2">
+        <Button
+          variant="outline"
+          size="sm"
+          onClick={() => {
+            void router.invalidate();
+          }}
+        >
+          Try again
+        </Button>
+        <Button variant="outline" size="sm" asChild>
+          <Link to="/">Go home</Link>
+        </Button>
+      </div>
     </div>
   );
 }

webui2/src/pages/NewBugPage.tsx 🔗

@@ -1,6 +1,6 @@
+import { useNavigate, Link } from "@tanstack/react-router";
 import { ArrowLeft } from "lucide-react";
 import { useState } from "react";
-import { useNavigate, Link } from "react-router";
 
 import { useBugCreateMutation } from "@/__generated__/graphql";
 import { Markdown } from "@/components/content/Markdown";
@@ -27,7 +27,7 @@ export function NewBugPage() {
     });
     const humanId = result.data?.bugCreate.bug.humanId;
     if (humanId) {
-      void navigate(repo ? `/${repo}/issues/${humanId}` : `/issues/${humanId}`);
+      void navigate({ to: repo ? `/${repo}/issues/${humanId}` : `/issues/${humanId}` });
     }
   }
 
@@ -115,7 +115,7 @@ export function NewBugPage() {
             type="button"
             variant="ghost"
             onClick={() => {
-              void navigate(issuesHref);
+              void navigate({ to: issuesHref });
             }}
             disabled={loading}
           >

webui2/src/pages/RepoPickerPage.tsx 🔗

@@ -1,9 +1,9 @@
 // Repository picker page (/). Auto-redirects when there is exactly one repo.
 // Shows a list when multiple repos are registered.
 
+import { Link, useNavigate } from "@tanstack/react-router";
 import { GitFork, FolderOpen, AlertCircle } from "lucide-react";
 import { useEffect } from "react";
-import { Link, useNavigate } from "react-router";
 
 import { useRepositoriesQuery } from "@/__generated__/graphql";
 import { Skeleton } from "@/components/ui/skeleton";
@@ -23,7 +23,12 @@ export function RepoPickerPage() {
   // Auto-redirect when there is exactly one repo — no need to pick.
   useEffect(() => {
     if (data?.repositories.nodes.length === 1) {
-      void navigate("/" + repoSlug(data.repositories.nodes[0].name), { replace: true });
+      void navigate({
+        to: "/$repo",
+        params: { repo: repoSlug(data.repositories.nodes[0].name) },
+        search: { ref: "", path: "", type: "tree" as const },
+        replace: true,
+      });
     }
   }, [data, navigate]);
 
@@ -53,7 +58,9 @@ export function RepoPickerPage() {
         {data?.repositories.nodes.map((repo) => (
           <Link
             key={repoSlug(repo.name)}
-            to={`/${repoSlug(repo.name)}`}
+            to="/$repo"
+            params={{ repo: repoSlug(repo.name) }}
+            search={{ ref: "", path: "", type: "tree" as const }}
             className="hover:bg-muted/50 flex items-center gap-3 px-4 py-4 transition-colors"
           >
             <FolderOpen className="text-muted-foreground size-5 shrink-0" />

webui2/src/pages/UserProfilePage.tsx 🔗

@@ -6,6 +6,7 @@
 // The :id param is treated as a humanId prefix and passed directly to the
 // identity(prefix) and allBugs(query:"author:...") GraphQL arguments.
 
+import { useParams, Link } from "@tanstack/react-router";
 import { formatDistanceToNow } from "date-fns";
 import {
   ArrowLeft,
@@ -17,7 +18,6 @@ import {
   ChevronRight,
 } from "lucide-react";
 import { useState } from "react";
-import { useParams, Link } from "react-router";
 
 import { Status, useUserProfileQuery } from "@/__generated__/graphql";
 import { LabelBadge } from "@/components/bugs/LabelBadge";
@@ -30,7 +30,7 @@ import { cn } from "@/lib/utils";
 const PAGE_SIZE = 25;
 
 export function UserProfilePage() {
-  const { id } = useParams<{ id: string }>();
+  const { id } = useParams({ strict: false });
   const repo = useRepo();
   const [statusFilter, setStatusFilter] = useState<"open" | "closed">("open");
 
@@ -91,12 +91,11 @@ export function UserProfilePage() {
     setCursors((prev) => prev.slice(0, -1));
   }
 
-  const issuesHref = repo ? `/${repo}/issues` : "/issues";
-
   return (
     <div>
       <Link
-        to={issuesHref}
+        to="/$repo/issues"
+        params={{ repo: repo! }}
         className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
       >
         <ArrowLeft className="size-3.5" />
@@ -214,7 +213,8 @@ export function UserProfilePage() {
               <div className="min-w-0 flex-1">
                 <div className="flex flex-wrap items-baseline gap-2">
                   <Link
-                    to={repo ? `/${repo}/issues/${bug.humanId}` : `/issues/${bug.humanId}`}
+                    to="/$repo/issues/$id"
+                    params={{ repo: repo!, id: bug.humanId }}
                     className="text-foreground hover:text-primary font-medium hover:underline"
                   >
                     {bug.title}