Lesson 5 — typed routing between your mobile screens
Your capstone tree has screens. Now make them link to each other. TanStack Router's navigation API is the payoff for all that file-based structure: because routes are generated from files, every to and every params value is type-checked at compile time. A typo in a path is a red squiggle, not a 404.
<Link> ComponentPrimary navigation tool. Renders a real <a> tag with a valid href, so cmd/ctrl-click, middle-click, and "open in new tab" all work for free.
import { Link } from '@tanstack/react-router'
// routes/_app/(tabs)/feed/$postId/index.tsx
export default function PostScreen() {
return (
<Link to="/profile/u/$username" params={{ username: 'ana' }}>
View @ana's profile
</Link>
)
}
to="/profile/u/$username" is checked against your generated route tree. Misspell it as /profle/... and TypeScript errors. Pass params={{ user: 'ana' }} (wrong key) and TypeScript errors. The route tree is the source of truth — you can't navigate to a route that doesn't exist.
Because the file is named $username, the generated types know this route needs a username param. The shape of params is derived from the path:
| Route path | params shape required |
|---|---|
/feed/$postId | { postId: string } |
/profile/u/$username | { username: string } |
/posts/$postId/$revisionId | { postId: string, revisionId: string } |
/home | {} (none) |
Forget a param -> compile error. Add a phantom param -> compile error. The link is impossible to write wrong.
useNavigate — imperative navigationFor side-effect navigations: after a form submit, after an async action resolves, after a timeout. Don't reach for it for anything the user clicks — <Link> is better there (it provides href, accessibility, cmd-click).
import { useNavigate } from '@tanstack/react-router'
function NewPostButton() {
// pass `from` here for max type-safety
const navigate = useNavigate({ from: '/feed' })
const handleSubmit = async (e) => {
e.preventDefault()
const res = await fetch('/api/posts', { method: 'POST', ... })
const { id: postId } = await res.json()
navigate({ to: '/feed/$postId', params: { postId } })
}
return <form onSubmit={handleSubmit}>...</form>
}
from at the hook
useNavigate({ from: '/feed' }) anchors the navigation's type inference to your current route. Do it once at the hook, not on every call. Relative paths and param inference both benefit.
<Navigate to="..."> — renders nothing, immediately navigates. Useful in render branches (e.g. redirect when auth missing).router.navigate(...) — the most powerful; available anywhere you have the router instance, not just inside components.None of these replace server-side redirects. For pre-mount redirects, do them on the server.
Both <Link> and navigate() accept these modifiers (from the NavigateOptions interface):
| Option | Effect |
|---|---|
replace: true | Replace current history entry instead of pushing. User can't hit "back" to return. |
resetScroll: false | Keep scroll position instead of jumping to top (default true). |
viewTransition: true | Wrap navigation in document.startViewTransition() — animated transitions. |
reloadDocument: true | Full page reload instead of SPA navigation. |
ignoreBlocker: true | Override navigation blockers (e.g. "unsaved changes" prompts). |
For mobile apps, replace is common — after login, you don't want "back" returning to the login screen.
"." and ".."Like a filesystem. From /posts/$postId:
<Link to=".">Reload current route (re-run loaders)</Link>
<Link to="..">Up to parent (/posts)</Link>
<Link from="/posts" to=".">Same as above, explicit</Link>
"." = same route. ".." = one route up the tree. Useful for "back to list" buttons and loader re-runs.
For tab bars and nav menus. The link tracks whether its to matches the current URL:
<Link
to="/feed"
activeProps={{ className: 'tab-active' }}
inactiveProps={{ className: 'tab-inactive' }}
>
Feed
</Link>
The rendered <a> also gets data-status="active" when matched — useful if you prefer CSS attribute selectors over prop merging. This is exactly how you'd build the bottom tab bar's highlighted state.
| Next.js | TanStack |
|---|---|
<Link href="/posts/42"> | <Link to="/posts/$postId" params={{ postId: '42' }}> |
router.push('/posts/42') | navigate({ to: '/posts/$postId', params: { postId: '42' } }) |
router.replace('/login') | navigate({ to: '/login', replace: true }) |
router.back() | router.history.back() |
| (no equivalent) | <Link to=".."> relative parent |
The big shift: Next.js uses interpolated strings (href="/posts/42"); TanStack uses typed route templates + params object. No more string-concatenating your way into 404s.
You're in routes/_app/(tabs)/feed/$postId/index.tsx (the post detail screen). Build:
/profile/u/$username (param value: "ana")/feed/$postId with a new postId after a fake delete+create flowSketch the three pieces mentally, then reveal.
import { Link, useNavigate } from '@tanstack/react-router'
export default function PostScreen() {
const navigate = useNavigate({ from: '/feed/$postId' })
const handleRecreate = async () => {
const { id: newId } = await fetch('/api/posts', { method: 'POST' }).then(r => r.json())
navigate({ to: '/feed/$postId', params: { postId: newId } })
}
return (
<>
{/* 1. Typed link to profile */}
<Link to="/profile/u/$username" params={{ username: 'ana' }}>
View @ana
</Link>
{/* 2. Relative back to feed list */}
<Link to="..">Back to feed</Link>
{/* 3. Imperative nav after async action */}
<button onClick={handleRecreate}>Recreate post</button>
</>
)
}
Why this works:
to strings are validated against the route tree — typos are impossible.params is checked against the path's $ segments — missing keys are type errors.from: '/feed/$postId' makes to=".." resolve to /feed with type inference.<a> with a valid href?<Link> renders a real <a>, so cmd/ctrl-click and "open in new tab" work. useNavigate is for side-effects, <Navigate> renders nothing.to="/profile/u/$username" requires which params?$ is the param name. Only $username here, so { username: string }.useNavigate over <Link>?useNavigate is for navigations triggered by side-effects (form submit, async result). Anything a user clicks should use <Link> for accessibility and href.navigate({ to, replace: true }) do?replace: true overwrites the current history entry instead of pushing a new one — user can't hit "back" to return. Common after login./posts/$postId, what does <Link to=".."> resolve to?".." navigates up one route in the tree — from /posts/$postId to its parent /posts.activeProps do on a <Link>?activeProps is merged into the rendered element when the link's to matches the current URL — perfect for highlighting the active tab.to="/profle/u/$username" yields?to value is checked against the generated route tree at compile time. A typo is a TypeScript error, not a runtime 404.from to useNavigate at the hook?from anchors navigation's type inference to your current route. Relative paths and param checking both rely on it. Pass once, not per call.router.push('/posts/42') maps to?navigate({ to: '/posts/$postId', params: { postId: '42' } }).<Link> for CSS?data-status="active". Use it with [data-status="active"] selectors, or use activeProps for inline prop merging.replace vs default push, how to wire tab bar active states, or how to handle "back" inside a modal flow.
Primary source:
TanStack Router — Navigation (the <Link> component, useNavigate, and NavigateOptions sections). Read after the quiz to deepen.