測驗:TanStack Router 型別安全的路由系統
共 5 題,點選答案後會立即顯示結果
1. 在 TanStack Router 的 file-based routing 中,檔名 $postId.tsx 的 $ 代表什麼意思?
2. 在巢狀路由中,<Outlet /> 的作用是什麼?
3. 以下是一段路由定義,validateSearch 搭配 Zod schema 的主要好處是什麼?
4. 在 TanStack Router 中,為什麼建議優先使用 <Link> 而不是 useNavigate() 進行頁面導航?
5. 檔案結構中 _auth/ 資料夾用底線 _ 開頭,對路由有什麼影響?
一句話說明
管理「使用者在哪一頁」和「網址上的參數」,而且打錯字 TypeScript 直接幫你抓出來。
這篇在講什麼
上一篇我們選好了技術棧:React + TanStack Router + TanStack Query。這篇要深入 TanStack Router,讓你看懂 AI 生成的路由程式碼在做什麼。
路由的工作很單純:網址變了,畫面跟著變。TanStack Router 做的就是這件事,但它多了一個殺手級功能——完全的 TypeScript 型別安全。也就是說,你的路由參數、搜尋參數、頁面連結,通通有型別檢查,打錯字在編輯器裡就會看到紅色波浪線。
前置知識
- 讀完第 1 篇(了解整體架構和為什麼選這套)
- 基本的 React 和 TypeScript 知識
TanStack Router vs React Router:核心差異
如果你之前用過 React Router(或 AI 幫你用過),先看看 TanStack Router 有什麼不同:
| 比較項目 | React Router | TanStack Router |
|---|---|---|
| 路由定義 | JSX 寫在程式碼裡 | 檔案結構自動產生路由 |
| 型別安全 | 路由參數是 string,要自己轉型 |
路由參數自動推導型別,打錯字編輯器會報錯 |
| Search Params | useSearchParams() 回傳字串,要自己 parse |
內建 schema 驗證,自動型別推導 |
| 路由樹產生 | 手動維護 | CLI 工具自動掃描檔案、自動產生 |
白話翻譯:React Router 像是你自己手寫一份「網址對照表」;TanStack Router 像是你把檔案放對位置,它就自動幫你產出對照表,而且還會幫你檢查有沒有打錯字。
File-Based Routing:檔案結構就是路由結構
TanStack Router 最大的特色是 file-based routing——你不用手動寫路由設定,只要在 routes/ 資料夾裡建立檔案,路由就會自動產生。
檔案 → 網址 對照表
src/routes/
├── __root.tsx → 整個應用的最外層(導覽列、Footer 在這裡)
├── index.tsx → /
├── about.tsx → /about
├── posts/
│ ├── index.tsx → /posts
│ └── $postId.tsx → /posts/123(動態路由)
└── _auth/
├── login.tsx → /login(沒有 _auth 在網址裡)
└── register.tsx → /register
Code language: PHP (php)命名規則速查
| 檔名 | 意思 | 範例 |
|---|---|---|
__root.tsx |
根路由,整個應用的最外殼 | 放導覽列、Outlet |
index.tsx |
該路徑的首頁 | routes/index.tsx 對應 / |
about.tsx |
靜態路徑 | 對應 /about |
$postId.tsx |
動態參數,$ 開頭 |
對應 /posts/123 |
_auth/ |
Layout 群組,_ 開頭 |
不會出現在網址裡 |
(admin)/ |
路由群組,括號包住 | 只做檔案整理,不影響網址 |
-helpers.tsx |
被忽略的檔案,- 開頭 |
不會變成路由 |
記住三個特殊前綴:
$是動態參數、_是隱藏路徑的 Layout、-是忽略不管。
路由樹自動產生
當你執行 tsr generate 或在開發模式下用 tsr watch,工具會掃描 routes/ 資料夾,自動產生一個 routeTree.gen.ts 檔案。你不需要手動編輯這個檔案——它是自動產生的。
// routeTree.gen.ts(自動產生,不要手動改)
import { Route as rootRoute } from './routes/__root'
import { Route as indexRoute } from './routes/index'
import { Route as aboutRoute } from './routes/about'
import { Route as postsIndexRoute } from './routes/posts/index'
import { Route as postsPostIdRoute } from './routes/posts/$postId'
// ... 自動串接所有路由
Code language: JavaScript (javascript)白話翻譯:你只管建檔案、寫元件,工具幫你把所有路由串起來。
看懂路由定義檔
每個路由檔案的基本結構都長這樣。我們分兩種來看。
createRootRoute:根路由
// src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: RootLayout,
})
function RootLayout() {
return (
<div>
<nav>導覽列</nav>
<Outlet /> {/* 子路由的內容會渲染在這裡 */}
<footer>頁尾</footer>
</div>
)
}
Code language: JavaScript (javascript)逐行翻譯:
| 程式碼 | 翻譯 |
|---|---|
createRootRoute({...}) |
建立應用程式最頂層的路由 |
component: RootLayout |
這個路由要用 RootLayout 元件來顯示 |
export const Route = ... |
匯出路由(檔名和匯出名稱都是固定的) |
<Outlet /> |
「子頁面的內容插在這裡」——這是巢狀路由的關鍵 |
createFileRoute:一般路由
// src/routes/about.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: AboutPage,
})
function AboutPage() {
return <h1>關於我們</h1>
}
Code language: JavaScript (javascript)逐行翻譯:
| 程式碼 | 翻譯 |
|---|---|
createFileRoute('/about') |
建立對應 /about 網址的路由 |
({component: AboutPage}) |
這個路由顯示 AboutPage 元件 |
export const Route = ... |
匯出路由定義(每個路由檔案都要這樣寫) |
注意 createFileRoute('/about') 裡面的路徑字串是工具自動填入的——你不需要自己寫,tsr watch 會根據檔案位置自動幫你填上正確的路徑。
Path Params:動態路由參數
當網址有動態的部分(像是 /posts/123 裡的 123),就需要用動態路由。
定義動態路由
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
component: PostDetail,
})
function PostDetail() {
const { postId } = Route.useParams() // 從網址取出 postId
return <h1>文章 #{postId}</h1>
}
Code language: JavaScript (javascript)逐行翻譯:
| 程式碼 | 翻譯 |
|---|---|
$postId.tsx |
檔名用 $ 開頭,表示這是動態參數 |
'/posts/$postId' |
路徑中 $postId 是佔位符,會匹配任何值 |
Route.useParams() |
從目前網址取出參數 |
{ postId } |
解構出 postId,型別自動推導為 string |
型別安全的好處
function PostDetail() {
const { postId } = Route.useParams()
// TypeScript 知道 postId 一定存在,型別是 string
console.log(postId) // OK
// 如果你打錯字...
const { postID } = Route.useParams()
// ^^^^^^ TypeScript 報錯:沒有 postID 這個屬性
}
Code language: JavaScript (javascript)白話翻譯:你不用擔心「這個參數叫什麼名字」,打錯字編輯器馬上告訴你。React Router 的 useParams() 回傳的是 Record<string, string | undefined>,你得自己記住參數名稱——TanStack Router 則是根據路由定義自動推導。
Search Params:型別安全的查詢參數
Search params 就是網址裡 ? 後面的東西,像 ?page=1&sort=name。這是 TanStack Router 比 React Router 強大很多的地方。
為什麼 Search Params 需要型別安全?
傳統做法的問題:
// React Router 的做法(型別不安全)
function PostList() {
const [searchParams] = useSearchParams()
const page = searchParams.get('page') // 型別是 string | null
const sort = searchParams.get('sort') // 型別是 string | null
// 你得自己轉型、自己處理 null、自己驗證格式
const pageNum = page ? parseInt(page) : 1
// 如果有人在網址輸入 ?page=abc 呢?parseInt 會回傳 NaN...
}
Code language: JavaScript (javascript)TanStack Router 的做法:validateSearch
// src/routes/posts/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
// 第一步:定義 search params 的 schema
const postsSearchSchema = z.object({
page: z.number().default(1), // 數字,預設值 1
sort: z.enum(['name', 'date']).default('date'), // 只能是 'name' 或 'date'
})
// 第二步:在路由定義中使用 validateSearch
export const Route = createFileRoute('/posts/')({
validateSearch: postsSearchSchema, // 用 schema 驗證 search params
component: PostList,
})
// 第三步:在元件中取出(完全型別安全)
function PostList() {
const { page, sort } = Route.useSearch()
// ^^^^ number ^^^^^ 'name' | 'date'
// TypeScript 自動知道型別,不需要自己轉型
return (
<div>
<p>第 {page} 頁,排序方式:{sort}</p>
</div>
)
}
Code language: JavaScript (javascript)逐行翻譯:
| 程式碼 | 翻譯 |
|---|---|
z.object({...}) |
用 Zod 定義「search params 應該長什麼樣」 |
z.number().default(1) |
page 必須是數字,沒給的話預設 1 |
z.enum(['name','date']) |
sort 只能是這兩個值之一 |
validateSearch: postsSearchSchema |
告訴路由:「用這個 schema 驗證網址的 search params」 |
Route.useSearch() |
取出驗證過的 search params,型別自動推導 |
和 React Router 的比較
React Router:
?page=abc → page = "abc"(字串,你得自己處理)
?page= → page = ""(空字串)
沒有 page → page = null
TanStack Router + validateSearch:
?page=abc → page = 1(不合格式,用預設值)
?page= → page = 1(不合格式,用預設值)
沒有 page → page = 1(沒給,用預設值)
?page=3 → page = 3(數字 3,不是字串 "3")
Code language: JavaScript (javascript)Search params 被 TanStack Router 當作「一等公民」處理——有型別、有驗證、有預設值。你不用再寫一堆防禦性程式碼。
巢狀路由和 Layout:用 Outlet 組合頁面
巢狀路由讓你把多個頁面共用的結構(像側邊欄、頂部標籤)抽出來,避免重複。
基本概念
瀏覽器畫面:
┌──────────────────────────┐
│ 導覽列(__root.tsx) │
├──────────────────────────┤
│ ┌────────────────────┐ │
│ │ 側邊欄(posts 的 layout)│ │
│ │ ┌──────────────┐ │ │
│ │ │ 文章內容 │ │ │
│ │ │($postId.tsx) │ │ │
│ │ └──────────────┘ │ │
│ └────────────────────┘ │
└──────────────────────────┘
對應的路由結構:
__root.tsx → <nav> + <Outlet />
└── posts/
├── route.tsx → 側邊欄 + <Outlet /> (posts 的 layout)
└── $postId.tsx → 文章內容
Code language: HTML, XML (xml)實際程式碼
根路由放導覽列:
// src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => (
<div>
<nav>全站導覽列</nav>
<Outlet /> {/* 子路由渲染在這裡 */}
</div>
),
})
Code language: JavaScript (javascript)Posts 區域有自己的 layout:
// src/routes/posts/route.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/posts')({
component: PostsLayout,
})
function PostsLayout() {
return (
<div style={{ display: 'flex' }}>
<aside>文章側邊欄</aside>
<main>
<Outlet /> {/* posts 底下的子頁面渲染在這裡 */}
</main>
</div>
)
}
Code language: JavaScript (javascript)文章列表和文章詳情:
// src/routes/posts/index.tsx → /posts
export const Route = createFileRoute('/posts/')({
component: () => <div>文章列表</div>,
})
// src/routes/posts/$postId.tsx → /posts/123
export const Route = createFileRoute('/posts/$postId')({
component: () => {
const { postId } = Route.useParams()
return <div>文章 #{postId}</div>
},
})
Code language: JavaScript (javascript)Outlet 的角色:<Outlet /> 就像一個「插槽」。父路由放了 <Outlet />,子路由的內容就會渲染在那個位置。一層一層嵌套下去,就形成了完整的頁面結構。
Pathless Layout:用 _ 前綴
有時候你想讓幾個頁面共用一個 layout,但 layout 本身不佔網址路徑:
src/routes/
├── _auth/
│ ├── login.tsx → /login(不是 /_auth/login)
│ └── register.tsx → /register
└── _auth.tsx → _auth 的 layout 元件
// src/routes/_auth.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth')({
component: () => (
<div className="auth-layout">
<div className="auth-card">
<Outlet /> {/* login 或 register 的內容 */}
</div>
</div>
),
})
Code language: JavaScript (javascript)白話翻譯:_auth 的 _ 表示「我只是個 layout,不要出現在網址裡」。所以 /login 頁面會套上 _auth 的外殼,但網址不會變成 /_auth/login。
導航方式:Link 和 useNavigate
Link 元件:宣告式導航
import { Link } from '@tanstack/react-router'
function Navigation() {
return (
<nav>
{/* 基本連結 */}
<Link to="/about">關於我們</Link>
{/* 帶 path params */}
<Link to="/posts/$postId" params={{ postId: '42' }}>
查看文章 #42
</Link>
{/* 帶 search params(型別安全!) */}
<Link to="/posts" search={{ page: 2, sort: 'name' }}>
文章列表第 2 頁
</Link>
{/* 目前頁面會自動加上 active class */}
<Link to="/posts" activeProps={{ className: 'active' }}>
文章
</Link>
</nav>
)
}
Code language: JavaScript (javascript)型別安全的威力:
// 打錯路徑 → TypeScript 報錯
<Link to="/abuot">關於</Link>
// ^^^^^^ 錯誤:'/abuot' 不是有效的路由路徑
// 漏了必要參數 → TypeScript 報錯
<Link to="/posts/$postId">查看文章</Link>
// 錯誤:缺少 params.postId
// search params 型別錯誤 → TypeScript 報錯
<Link to="/posts" search={{ page: "abc" }}>列表</Link>
// ^^^^^ 錯誤:page 應該是 number
Code language: HTML, XML (xml)useNavigate:命令式導航
有時候你不是點連結,而是在某個操作完成後跳轉頁面(例如表單送出後),這時用 useNavigate:
import { useNavigate } from '@tanstack/react-router'
function CreatePostForm() {
const navigate = useNavigate()
async function handleSubmit(data: PostData) {
const newPost = await createPost(data)
// 建立成功後,跳轉到新文章的頁面
navigate({
to: '/posts/$postId',
params: { postId: newPost.id },
})
}
return <form onSubmit={handleSubmit}>...</form>
}
Code language: JavaScript (javascript)Link vs useNavigate 怎麼選?
| 場景 | 用什麼 |
|---|---|
| 導覽列、選單、文字連結 | <Link> |
| 表單送出後跳轉 | useNavigate() |
| 按鈕點擊後做完某些事再跳 | useNavigate() |
有 href、能右鍵新分頁開啟 |
<Link>(它會渲染成 <a>) |
經驗法則:**能用
<Link>就用<Link>**,因為它會渲染成真正的<a>標籤,支援右鍵開新分頁、瀏覽器的上一頁/下一頁等原生行為。
Route Guards:用 beforeLoad 做路由守衛
有些頁面只有登入的使用者才能看(例如 Dashboard)。TanStack Router 用 beforeLoad 來處理這個需求。
基本認證檢查
// src/routes/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/dashboard')({
beforeLoad: ({ context }) => {
// 進入這個頁面之前,先檢查有沒有登入
if (!context.auth.isAuthenticated) {
// 沒登入?丟到登入頁面
throw redirect({
to: '/login',
search: { redirect: '/dashboard' }, // 登入後跳回來
})
}
},
component: DashboardPage,
})
Code language: JavaScript (javascript)逐行翻譯:
| 程式碼 | 翻譯 |
|---|---|
beforeLoad |
進入頁面「之前」執行的函式 |
context.auth.isAuthenticated |
從路由的 context 取出認證狀態 |
throw redirect({...}) |
沒登入就強制跳轉到登入頁 |
search: { redirect: '/dashboard' } |
在網址帶上原本要去的頁面,方便登入後跳回 |
保護多個頁面:用 Layout Route
如果有很多頁面都需要認證,不用每個都寫 beforeLoad。用 layout route 一次搞定:
// src/routes/_authenticated.tsx
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
component: () => <Outlet />, // 通過認證就正常顯示子頁面
})
Code language: JavaScript (javascript)src/routes/
├── _authenticated/
│ ├── dashboard.tsx → /dashboard(需要登入)
│ ├── settings.tsx → /settings(需要登入)
│ └── profile.tsx → /profile(需要登入)
├── _authenticated.tsx → 認證守衛(beforeLoad 在這裡)
├── login.tsx → /login(不需要登入)
└── __root.tsx
白話翻譯:_authenticated 是一個隱形的保護層。底下的所有頁面都會在載入前先跑認證檢查,通過了才能看到內容。這比在每個頁面都寫一次認證邏輯乾淨很多。
核心概念翻譯
| 你會看到 | 意思 |
|---|---|
createRootRoute |
建立最頂層的根路由(放導覽列、全站 layout) |
createFileRoute('/path') |
建立檔案路由,路徑字串由工具自動填入 |
export const Route = ... |
每個路由檔案都要匯出 Route |
<Outlet /> |
子路由的內容插在這裡 |
Route.useParams() |
取得網址中的動態參數(/posts/$postId 的 postId) |
Route.useSearch() |
取得驗證過的 search params(?page=1 的 page) |
validateSearch |
用 schema 定義和驗證 search params |
beforeLoad |
進入頁面前執行的中介函式(常用於認證) |
throw redirect({...}) |
在 beforeLoad 中強制跳轉 |
<Link to="/path"> |
型別安全的連結元件 |
useNavigate() |
命令式導航(操作完成後跳轉用) |
routeTree.gen.ts |
自動產生的路由樹檔案,不要手動修改 |
AI 最常這樣用
用法 1:基本的 CRUD 路由結構
src/routes/
├── __root.tsx
├── index.tsx → 首頁
├── posts/
│ ├── index.tsx → /posts(列表頁)
│ ├── $postId.tsx → /posts/123(詳情頁)
│ └── new.tsx → /posts/new(新增頁)
└── _authenticated/
├── dashboard.tsx → /dashboard
└── settings.tsx → /settings
Code language: PHP (php)用法 2:帶分頁和篩選的列表頁
const searchSchema = z.object({
page: z.number().default(1),
q: z.string().optional(),
status: z.enum(['all', 'active', 'archived']).default('all'),
})
export const Route = createFileRoute('/posts/')({
validateSearch: searchSchema,
component: PostList,
})
function PostList() {
const { page, q, status } = Route.useSearch()
// 根據 search params 抓資料、顯示列表
}
Code language: PHP (php)用法 3:帶 context 的路由(傳遞全域狀態)
// src/routes/__root.tsx
import { createRootRouteWithContext } from '@tanstack/react-router'
interface RouterContext {
auth: { isAuthenticated: boolean; user: User | null }
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
})
Code language: JavaScript (javascript)翻譯:createRootRouteWithContext 讓你在根路由定義一個 context(例如認證狀態),所有子路由的 beforeLoad 都能透過 context 存取到。
Vibe Coder 檢查點
看到 AI 幫你寫了 TanStack Router 的程式碼時,確認這些事:
- [ ] 路由檔案結構對嗎?
routes/資料夾的檔案命名要遵守規則($動態參數、_pathless layout、__root.tsx根路由) - [ ] 每個路由檔案都有
export const Route? 這是必須的匯出,少了路由不會生效 - [ ] 有用到 search params 的頁面有
validateSearch嗎? 如果網址有?page=1之類的參數,路由定義應該要有 schema 驗證 - [ ] 巢狀路由的 parent 有放
<Outlet />嗎? 沒放 Outlet,子路由的內容不會顯示 - [ ] 需要認證的頁面有
beforeLoad嗎? 敏感頁面應該在載入前檢查登入狀態 - [ ] 連結用的是
<Link>而不是<a>? 用原生<a>會造成整頁重新載入,失去 SPA 的優勢 - [ ]
routeTree.gen.ts有被加進.gitignore嗎? 這個檔案是自動產生的,不應該手動編輯
必看懂 vs 知道就好
必看懂(本系列會一直出現)
| 概念 | 為什麼重要 |
|---|---|
createFileRoute / createRootRoute |
每個路由檔案的基本結構 |
Route.useParams() |
取得動態路由參數,每個詳情頁都會用 |
Route.useSearch() + validateSearch |
Search params 的型別安全存取 |
<Outlet /> |
巢狀路由的組合方式 |
<Link> |
頁面間的導航 |
beforeLoad |
路由守衛,認證必備 |
知道就好(遇到再查)
- Route Masking:讓顯示的網址和實際路由不同(例如 modal 路由)
- Lazy Loading:用
.lazy.tsx拆分路由的程式碼,減少初始載入量 - Pending Component:路由載入中時顯示的過渡畫面
- Error Component:路由載入失敗時顯示的錯誤畫面
- Route Context:在路由之間傳遞共享資料
小結
這篇幫你看懂了 TanStack Router 的核心概念:
- File-based routing — 檔案結構就是路由結構,工具自動幫你產生路由樹
- 路由定義 —
createRootRoute和createFileRoute是兩個基本 API,每個路由檔案都要export const Route - Path Params — 用
$開頭的檔名定義動態參數,用Route.useParams()取得 - Search Params — 用
validateSearch搭配 Zod schema 做型別安全的查詢參數 - 巢狀路由 — 用
<Outlet />組合父子路由的畫面結構 - 導航 —
<Link>做宣告式導航、useNavigate()做命令式導航 - Route Guards — 用
beforeLoad+throw redirect()做認證檢查
下一篇,我們會進入 TanStack Query,看懂 useQuery 和 useMutation 怎麼管理 API 資料和快取。
進階測驗:TanStack Router 型別安全的路由系統
共 5 題,包含情境題與錯誤診斷題。