diff --git a/.changeset/direction-aware-links-prefer-back.md b/.changeset/direction-aware-links-prefer-back.md
new file mode 100644
index 0000000000..b6819f019d
--- /dev/null
+++ b/.changeset/direction-aware-links-prefer-back.md
@@ -0,0 +1,15 @@
+---
+'@tanstack/router-core': minor
+'@tanstack/react-router': minor
+---
+
+feat: history-aware links via a `preferBack` prop on `Link`
+
+`` now navigates via `history.back()` when `/x` resolves to the previous history entry, instead of always pushing a new one. This preserves forward history and the browser's native per-entry scroll restoration for "Back to X" links. It is best-effort: when the target isn't the previous entry — or the previous entry is unknown (e.g. a fresh page load or deep link) — it falls back to a normal push (or `replace`, if set). The element always renders a real ``, so keyboard nav, "copy link", and middle/modifier-click keep working.
+
+`preferBack` accepts a match mode: `true`/`'pathname'` (default) matches by pathname only (restoring the previous entry's exact search and scroll), while `'exact'` also requires the search to match.
+
+New public APIs:
+
+- `useIsBackNavigation(options, match?)` (react-router) — the primitive behind `preferBack`; returns whether navigating to `options` would resolve to the previous history entry. `match` is `'pathname'` (default) or `'exact'`.
+- `router.getHistoryEntry(index)` (router-core) — read a visited history entry by its `__TSR_index` from the router's in-memory per-index tracking.
diff --git a/docs/router/api/router.md b/docs/router/api/router.md
index 2c5ede186c..5180d7db6f 100644
--- a/docs/router/api/router.md
+++ b/docs/router/api/router.md
@@ -39,6 +39,7 @@ title: Router API
- [`useBlocker`](./router/useBlockerHook.md)
- [`useCanGoBack`](./router/useCanGoBack.md)
- [`useChildMatches`](./router/useChildMatchesHook.md)
+ - [`useIsBackNavigation`](./router/useIsBackNavigation.md)
- [`useLinkProps`](./router/useLinkPropsHook.md)
- [`useLoaderData`](./router/useLoaderDataHook.md)
- [`useLoaderDeps`](./router/useLoaderDepsHook.md)
diff --git a/docs/router/api/router/useIsBackNavigation.md b/docs/router/api/router/useIsBackNavigation.md
new file mode 100644
index 0000000000..f4b186a12b
--- /dev/null
+++ b/docs/router/api/router/useIsBackNavigation.md
@@ -0,0 +1,62 @@
+---
+id: useIsBackNavigation
+title: useIsBackNavigation hook
+---
+
+The `useIsBackNavigation` hook returns a boolean representing whether navigating to the given options would resolve to the **previous** history entry — i.e. whether clicking it should go "back" rather than push a new entry.
+
+It is the primitive behind the [`preferBack`](../../guide/navigation.md#history-aware-links-preferback) prop on [`Link`](./linkComponent.md). Use it directly when building custom links or buttons that want the same history-aware behavior.
+
+> ⚠️ The following `useIsBackNavigation` API is currently _experimental_.
+
+## useIsBackNavigation options
+
+The `useIsBackNavigation` hook accepts the same navigation options as `Link`/`useNavigate` (`to`, `params`, `search`, `hash`, `from`, etc.) as its first argument.
+
+The optional second argument is the **match mode**:
+
+- `'pathname'` (default) — match by pathname only, so going back restores the previous entry's exact search params and scroll position.
+- `'exact'` — match by pathname **and** search.
+
+## useIsBackNavigation returns
+
+- `true` if the resolved target's pathname equals the previous history entry's pathname.
+- `false` otherwise, including:
+ - when the router is at history index `0` (nothing behind it),
+ - when the previous entry is **unknown** to the router (e.g. a fresh page load or deep link, where the router never recorded the entry behind the current one),
+ - on the server.
+
+Because it returns `false` whenever a back navigation can't be determined, it is always safe to branch on: a `false` simply means "navigate normally".
+
+## How it works
+
+The browser only exposes the *current* history entry, so the router maintains an in-memory map of visited entries keyed by their history index (`__TSR_index`). `useIsBackNavigation` compares the resolved target against the entry at `currentIndex - 1`, by pathname (and search, in `'exact'` mode). You can read these entries directly via [`router.getHistoryEntry(index)`](./RouterType.md).
+
+## Examples
+
+```tsx
+import { useRouter, useIsBackNavigation } from '@tanstack/react-router'
+
+function BackToIssues() {
+ const router = useRouter()
+ const isBack = useIsBackNavigation({ to: '/issues' })
+
+ return (
+
+ )
+}
+```
+
+For the common case, prefer the `preferBack` prop on `Link`, which renders a real `` and wires this up for you:
+
+```tsx
+
+ Back to issues
+
+```
diff --git a/docs/router/guide/navigation.md b/docs/router/guide/navigation.md
index 3dd09cba47..952fcba20c 100644
--- a/docs/router/guide/navigation.md
+++ b/docs/router/guide/navigation.md
@@ -133,6 +133,8 @@ export type LinkOptions<
preloadDelay?: number
// If true, will render the link without the href attribute
disabled?: boolean
+ // History-aware back navigation when the resolved target is the previous history entry (see "History-aware links" below). `true`/`'pathname'` match by pathname; `'exact'` also requires search to match.
+ preferBack?: boolean | 'pathname' | 'exact'
}
```
@@ -733,6 +735,22 @@ const link = (
)
```
+### History-aware links (`preferBack`)
+
+> ⚠️ The `preferBack` prop and the `useIsBackNavigation` hook are currently _experimental_.
+
+A regular "Back to X" link always **pushes** a new history entry — even when `/x` is the entry the user just came from — discarding forward history and the browser's native per-entry scroll restoration.
+
+Setting `preferBack` makes the link history-aware: when its resolved target matches the previous history entry, clicking it calls `router.history.back()` instead of pushing. Otherwise it behaves like a normal link, falling back to a push (or `replace`, if also set) — including when the previous entry is unknown to the router (e.g. a fresh load or deep link). It always renders a real ``, so keyboard nav, "copy link", and middle/modifier-click keep working.
+
+```tsx
+
+ Back to issues
+
+```
+
+By default (`preferBack` / `preferBack="pathname"`) the target is matched by **pathname only**, so going back restores the user's exact prior search params and scroll position. Use `preferBack="exact"` to also require the search to match (it then pushes when the search differs). For custom links or buttons, the underlying [`useIsBackNavigation`](../api/router/useIsBackNavigation.md) hook returns whether navigating to the given options would go back.
+
## `useNavigate`
> ⚠️ Because of the `Link` component's built-in affordances around `href`, cmd/ctrl + click-ability, and active/inactive capabilities, it's recommended to use the `Link` component instead of `useNavigate` for anything the user can interact with (e.g. links, buttons). However, there are some cases where `useNavigate` is necessary to handle side-effect navigations (e.g. a successful async action that results in a navigation).
diff --git a/examples/react/basic/src/main.tsx b/examples/react/basic/src/main.tsx
index ca6c41b709..c5ca5f8c8f 100644
--- a/examples/react/basic/src/main.tsx
+++ b/examples/react/basic/src/main.tsx
@@ -123,6 +123,14 @@ function PostComponent() {
{post.title}
{post.body}
+
+
+ ← Back to posts
+
)
}
diff --git a/examples/react/basic/src/posts.ts b/examples/react/basic/src/posts.ts
index 54d62e5788..baa70a7f96 100644
--- a/examples/react/basic/src/posts.ts
+++ b/examples/react/basic/src/posts.ts
@@ -13,7 +13,7 @@ export const fetchPosts = async () => {
await new Promise((r) => setTimeout(r, 500))
return axios
.get>('https://jsonplaceholder.typicode.com/posts')
- .then((r) => r.data.slice(0, 10))
+ .then((r) => r.data.slice(0, 50))
}
export const fetchPost = async (postId: string) => {
diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx
index 6637c7542b..d39c0460d9 100644
--- a/packages/react-router/src/index.tsx
+++ b/packages/react-router/src/index.tsx
@@ -298,6 +298,8 @@ export { useRouter } from './useRouter'
export { useRouterState } from './useRouterState'
export { useLocation } from './useLocation'
export { useCanGoBack } from './useCanGoBack'
+export { useIsBackNavigation } from './useIsBackNavigation'
+export type { BackNavigationMatch } from './useIsBackNavigation'
export { CatchNotFound, DefaultGlobalNotFound } from './not-found'
export { notFound, isNotFound } from '@tanstack/router-core'
diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx
index eddc0ac0b3..828d959f10 100644
--- a/packages/react-router/src/link.tsx
+++ b/packages/react-router/src/link.tsx
@@ -18,6 +18,7 @@ import { useRouter } from './useRouter'
import { useForwardedRef, useIntersectionObserver } from './utils'
import { useHydrated } from './ClientOnly'
+import { resolveIsBackNavigation } from './useIsBackNavigation'
import type {
AnyRouter,
Constrain,
@@ -73,6 +74,7 @@ export function useLinkProps<
startTransition,
resetScroll,
viewTransition,
+ preferBack,
// element props
children,
target,
@@ -403,7 +405,9 @@ export function useLinkProps<
const currentLocation = useStore(
router.stores.location,
(l) => l,
- (prev, next) => prev.href === next.href,
+ (prev, next) =>
+ prev.href === next.href &&
+ prev.state.__TSR_index === next.state.__TSR_index,
)
// eslint-disable-next-line react-hooks/rules-of-hooks
@@ -412,6 +416,18 @@ export function useLinkProps<
return router.buildLocation(opts as any)
}, [router, currentLocation, _options])
+ // History-aware: go back instead of pushing when the target is the previous
+ // entry. `'exact'` also matches search. Reuses `next`, so no extra buildLocation.
+ const isBackNavigation =
+ !!preferBack &&
+ !_reloadDocument &&
+ resolveIsBackNavigation(
+ router,
+ currentLocation,
+ next,
+ preferBack === 'exact' ? 'exact' : 'pathname',
+ )
+
// Use publicHref - it contains the correct href for display
// When a rewrite changes the origin, publicHref is the full URL
// Otherwise it's the origin-stripped path
@@ -626,6 +642,16 @@ export function useLinkProps<
setIsTransitioning(false)
})
+ // Target is the previous entry — pop instead of pushing to preserve forward
+ // history and native scroll restoration (handled via the router's popstate).
+ if (isBackNavigation) {
+ if (viewTransition !== undefined) {
+ router.shouldViewTransition = viewTransition
+ }
+ router.history.back({ ignoreBlocker })
+ return
+ }
+
// All is well? Navigate!
// N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing
router.navigate({
diff --git a/packages/react-router/src/useIsBackNavigation.ts b/packages/react-router/src/useIsBackNavigation.ts
new file mode 100644
index 0000000000..6a163b0b1d
--- /dev/null
+++ b/packages/react-router/src/useIsBackNavigation.ts
@@ -0,0 +1,105 @@
+import * as React from 'react'
+import { useStore } from '@tanstack/react-store'
+import { isServer } from '@tanstack/router-core/isServer'
+import { useRouter } from './useRouter'
+import type {
+ AnyRouter,
+ LinkOptions,
+ ParsedLocation,
+ RegisteredRouter,
+} from '@tanstack/router-core'
+
+/**
+ * How a target is matched against the previous history entry:
+ * - `'pathname'` — match by pathname only (so going back restores the previous
+ * entry's exact search params and scroll position).
+ * - `'exact'` — match by pathname **and** search.
+ */
+export type BackNavigationMatch = 'pathname' | 'exact'
+
+/**
+ * Whether navigating to `target` lands on the previous history entry (so a
+ * `history.back()` is preferable to a push). Returns `false` at index 0 or when
+ * the previous entry is unknown, so callers degrade gracefully to a push.
+ */
+export function resolveIsBackNavigation(
+ router: AnyRouter,
+ currentLocation: ParsedLocation,
+ target: Pick,
+ match: BackNavigationMatch = 'pathname',
+): boolean {
+ const currentIndex = currentLocation.state.__TSR_index ?? 0
+ if (currentIndex === 0) return false
+ const previous = router.getHistoryEntry(currentIndex - 1)
+ if (!previous || previous.pathname !== target.pathname) return false
+ if (match === 'exact') return previous.searchStr === target.searchStr
+ return true
+}
+
+/**
+ * Returns `true` when navigating to the given options would resolve to the
+ * previous history entry — i.e. it should go "back" rather than push. The
+ * primitive behind `preferBack`, for use in custom links or buttons.
+ *
+ * Returns `false` on the server and whenever the previous entry is unknown, so
+ * it's always safe to branch on. Pass `match: 'exact'` to also require search to
+ * match (defaults to `'pathname'`).
+ *
+ * @example
+ * const isBack = useIsBackNavigation({ to: '/issues' })
+ * // isBack ? router.history.back() : router.navigate({ to: '/issues' })
+ */
+export function useIsBackNavigation<
+ TRouter extends AnyRouter = RegisteredRouter,
+ const TFrom extends string = string,
+ const TTo extends string | undefined = undefined,
+ const TMaskFrom extends string = TFrom,
+ const TMaskTo extends string = '',
+>(
+ options: LinkOptions,
+ match: BackNavigationMatch = 'pathname',
+): boolean {
+ const router = useRouter()
+
+ // On the server there is no client-side history to pop, and the previous
+ // entry is never known, so back-navigation is always false.
+ if (isServer ?? router.isServer) {
+ return false
+ }
+
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static (compile-time `isServer`)
+ const currentLocation = useStore(
+ router.stores.location,
+ (l) => l,
+ (prev, next) =>
+ prev.href === next.href &&
+ prev.state.__TSR_index === next.state.__TSR_index,
+ )
+
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const _options = React.useMemo(
+ () => options,
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [
+ router,
+ options.from,
+ options._fromLocation,
+ options.hash,
+ options.to,
+ options.search,
+ options.params,
+ options.state,
+ options.mask,
+ options.unsafeRelative,
+ ],
+ )
+
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ return React.useMemo(() => {
+ const next = router.buildLocation({
+ _fromLocation: currentLocation,
+ ..._options,
+ } as any)
+ return resolveIsBackNavigation(router, currentLocation, next, match)
+ }, [router, currentLocation, _options, match])
+}
diff --git a/packages/react-router/tests/useIsBackNavigation.test.tsx b/packages/react-router/tests/useIsBackNavigation.test.tsx
new file mode 100644
index 0000000000..70d1fe129c
--- /dev/null
+++ b/packages/react-router/tests/useIsBackNavigation.test.tsx
@@ -0,0 +1,402 @@
+import React from 'react'
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import {
+ Link,
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ useIsBackNavigation,
+} from '../src'
+
+beforeEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+})
+afterEach(() => {
+ cleanup()
+})
+
+function HookProbe() {
+ const backToHome = useIsBackNavigation({ to: '/' })
+ const backToContact = useIsBackNavigation({ to: '/contact' })
+ return (
+ <>
+ {String(backToHome)}
+ {String(backToContact)}
+ >
+ )
+}
+
+function RootComponent() {
+ return (
+ <>
+ Home
+ About
+ Contact
+
+ Back home
+
+
+ Prefer contact
+
+
+ Back home (view transition)
+
+
+ Back home (reload)
+
+
+
+ >
+ )
+}
+
+function setup(initialEntries: Array = ['/']) {
+ const rootRoute = createRootRoute({ component: RootComponent })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => IndexTitle
,
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ component: () => AboutTitle
,
+ })
+ const contactRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/contact',
+ component: () => ContactTitle
,
+ })
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, aboutRoute, contactRoute]),
+ history: createMemoryHistory({ initialEntries }),
+ })
+
+ const utils = render()
+ return { router, ...utils }
+}
+
+describe('useIsBackNavigation', () => {
+ test('is false at the start of history (no previous entry)', async () => {
+ setup(['/'])
+ await screen.findByText('IndexTitle')
+
+ // index 0 — nothing behind, so neither target is a back navigation.
+ expect(screen.getByTestId('hook-home')).toHaveTextContent('false')
+ expect(screen.getByTestId('hook-contact')).toHaveTextContent('false')
+ })
+
+ test('is true only when the target equals the previous entry', async () => {
+ const { router } = setup(['/'])
+ await screen.findByText('IndexTitle')
+
+ await act(() => fireEvent.click(screen.getByText('About')))
+ await screen.findByText('AboutTitle')
+
+ // Previous entry (index 0) is "/", so a link to "/" is a back navigation,
+ // but a link to "/contact" is not.
+ expect(router.history.location.state.__TSR_index).toBe(1)
+ expect(screen.getByTestId('hook-home')).toHaveTextContent('true')
+ expect(screen.getByTestId('hook-contact')).toHaveTextContent('false')
+ })
+
+ test('falls back to false when the previous entry is unknown (deep link)', async () => {
+ // Router starts at index 1; entry 0 ("/") was never recorded by the router.
+ const { router } = setup(['/', '/about'])
+ await screen.findByText('AboutTitle')
+
+ expect(router.history.location.state.__TSR_index).toBe(1)
+ expect(router.getHistoryEntry(0)).toBeUndefined()
+ expect(screen.getByTestId('hook-home')).toHaveTextContent('false')
+ })
+})
+
+describe('Link preferBack', () => {
+ test('keeps a real regardless of decision', async () => {
+ setup(['/'])
+ await screen.findByText('IndexTitle')
+
+ const link = screen.getByTestId('preferback-home')
+ expect(link.tagName).toBe('A')
+ expect(link.getAttribute('href')).toBe('/')
+ })
+
+ test('goes back when the target is the previous entry', async () => {
+ const { router } = setup(['/'])
+ await screen.findByText('IndexTitle')
+
+ await act(() => fireEvent.click(screen.getByText('About')))
+ await screen.findByText('AboutTitle')
+ expect(router.history.location.state.__TSR_index).toBe(1)
+
+ const backSpy = vi.spyOn(router.history, 'back')
+
+ await act(() => fireEvent.click(screen.getByTestId('preferback-home')))
+ await screen.findByText('IndexTitle')
+
+ expect(backSpy).toHaveBeenCalledTimes(1)
+ // Popped rather than pushed: index decreased to 0, forward history kept.
+ expect(router.history.location.state.__TSR_index).toBe(0)
+ })
+
+ test('pushes normally when the target is not the previous entry', async () => {
+ const { router } = setup(['/'])
+ await screen.findByText('IndexTitle')
+
+ await act(() => fireEvent.click(screen.getByText('About')))
+ await screen.findByText('AboutTitle')
+
+ const backSpy = vi.spyOn(router.history, 'back')
+
+ // Previous entry is "/", target is "/contact" → no match → push.
+ await act(() => fireEvent.click(screen.getByTestId('preferback-contact')))
+ await screen.findByText('ContactTitle')
+
+ expect(backSpy).not.toHaveBeenCalled()
+ expect(router.history.location.state.__TSR_index).toBe(2)
+ })
+
+ test('pushes normally when the previous entry is unknown (deep link)', async () => {
+ const { router } = setup(['/', '/about'])
+ await screen.findByText('AboutTitle')
+
+ const backSpy = vi.spyOn(router.history, 'back')
+
+ await act(() => fireEvent.click(screen.getByTestId('preferback-home')))
+ await screen.findByText('IndexTitle')
+
+ expect(backSpy).not.toHaveBeenCalled()
+ // Fell back to a push: a new entry at index 2, not a pop to index 0.
+ expect(router.history.location.state.__TSR_index).toBe(2)
+ })
+
+ test('carries the link\'s viewTransition intent onto the back navigation', async () => {
+ const { router } = setup(['/'])
+ await screen.findByText('IndexTitle')
+ await act(() => fireEvent.click(screen.getByText('About')))
+ await screen.findByText('AboutTitle')
+
+ const origBack = router.history.back.bind(router.history)
+ let vtAtBack: unknown = 'unset'
+ vi.spyOn(router.history, 'back').mockImplementation((opts) => {
+ vtAtBack = router.shouldViewTransition
+ return origBack(opts)
+ })
+
+ await act(() => fireEvent.click(screen.getByTestId('preferback-home-vt')))
+ await screen.findByText('IndexTitle')
+
+ expect(vtAtBack).toBe(true)
+ })
+
+ test('reloadDocument takes precedence: no back shortcut, full navigate', async () => {
+ const { router } = setup(['/'])
+ await screen.findByText('IndexTitle')
+ await act(() => fireEvent.click(screen.getByText('About')))
+ await screen.findByText('AboutTitle')
+
+ const backSpy = vi.spyOn(router.history, 'back')
+ // Stub navigate so the full-document reload path doesn't touch window.location.
+ const navigateSpy = vi
+ .spyOn(router, 'navigate')
+ .mockResolvedValue(undefined)
+
+ await act(() =>
+ fireEvent.click(screen.getByTestId('preferback-home-reload')),
+ )
+
+ // The back shortcut is skipped; reloadDocument flows through to navigate.
+ expect(backSpy).not.toHaveBeenCalled()
+ expect(navigateSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ reloadDocument: true }),
+ )
+ })
+
+ describe('match modes (pathname vs exact)', () => {
+ function ModeProbe() {
+ const exactMatch = useIsBackNavigation({ to: '/about', search: { page: 3 } }, 'exact')
+ const exactNoMatch = useIsBackNavigation({ to: '/about', search: { page: 9 } }, 'exact')
+ const pathnameDiffSearch = useIsBackNavigation({ to: '/about', search: { page: 9 } }, 'pathname')
+ return (
+ <>
+ {String(exactMatch)}
+ {String(exactNoMatch)}
+ {String(pathnameDiffSearch)}
+ >
+ )
+ }
+
+ function setupModes() {
+ const rootRoute = createRootRoute({
+ component: () => (
+ <>
+
+ About 3
+
+ Contact
+
+ Exact back (match)
+
+
+ Exact back (no match)
+
+
+
+ >
+ ),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => IndexTitle
,
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ validateSearch: (search: Record) => ({
+ page: search.page as number | undefined,
+ }),
+ component: () => AboutTitle
,
+ })
+ const contactRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/contact',
+ component: () => ContactTitle
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, aboutRoute, contactRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ const utils = render()
+ return { router, ...utils }
+ }
+
+ // Sets up history so the previous entry is /about?page=3.
+ async function arrangePreviousAboutPage3() {
+ const utils = setupModes()
+ await screen.findByText('IndexTitle')
+ await act(() => fireEvent.click(screen.getByText('About 3')))
+ await screen.findByText('AboutTitle')
+ await act(() => fireEvent.click(screen.getByText('Contact')))
+ await screen.findByText('ContactTitle')
+ expect(utils.router.history.location.state.__TSR_index).toBe(2)
+ return utils
+ }
+
+ test('exact: true only when pathname AND search match the previous entry', async () => {
+ await arrangePreviousAboutPage3()
+
+ expect(screen.getByTestId('exact-match')).toHaveTextContent('true')
+ expect(screen.getByTestId('exact-nomatch')).toHaveTextContent('false')
+ // pathname mode ignores search, so a differing search still matches.
+ expect(screen.getByTestId('pathname-diffsearch')).toHaveTextContent('true')
+ })
+
+ test('preferBack="exact" goes back when search matches', async () => {
+ const { router } = await arrangePreviousAboutPage3()
+ const backSpy = vi.spyOn(router.history, 'back')
+
+ await act(() => fireEvent.click(screen.getByTestId('exact-back')))
+ await screen.findByText('AboutTitle')
+
+ expect(backSpy).toHaveBeenCalledTimes(1)
+ expect(router.history.location.state.__TSR_index).toBe(1)
+ })
+
+ test('preferBack="exact" pushes when search differs', async () => {
+ const { router } = await arrangePreviousAboutPage3()
+ const backSpy = vi.spyOn(router.history, 'back')
+
+ await act(() => fireEvent.click(screen.getByTestId('exact-push')))
+ await screen.findByText('AboutTitle')
+
+ expect(backSpy).not.toHaveBeenCalled()
+ expect(router.history.location.state.__TSR_index).toBe(3)
+ })
+ })
+
+ describe('click guards do not trigger back navigation', () => {
+ async function setupAtAbout() {
+ const utils = setup(['/'])
+ await screen.findByText('IndexTitle')
+ await act(() => fireEvent.click(screen.getByText('About')))
+ await screen.findByText('AboutTitle')
+ const backSpy = vi.spyOn(utils.router.history, 'back')
+ return { ...utils, backSpy }
+ }
+
+ test('middle-click (button !== 0) is ignored', async () => {
+ const { backSpy } = await setupAtAbout()
+ await act(() =>
+ fireEvent.click(screen.getByTestId('preferback-home'), { button: 1 }),
+ )
+ expect(backSpy).not.toHaveBeenCalled()
+ })
+
+ test('modifier-click (metaKey) is ignored', async () => {
+ const { backSpy } = await setupAtAbout()
+ await act(() =>
+ fireEvent.click(screen.getByTestId('preferback-home'), {
+ metaKey: true,
+ }),
+ )
+ expect(backSpy).not.toHaveBeenCalled()
+ })
+
+ test('a user onClick calling preventDefault suppresses back navigation', async () => {
+ const onClick = vi.fn((e: React.MouseEvent) => e.preventDefault())
+
+ const rootRoute = createRootRoute({
+ component: () => (
+ <>
+ About
+
+ Back home
+
+
+ >
+ ),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => IndexTitle
,
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ component: () => AboutTitle
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, aboutRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ render()
+ await screen.findByText('IndexTitle')
+ await act(() => fireEvent.click(screen.getByText('About')))
+ await screen.findByText('AboutTitle')
+
+ const backSpy = vi.spyOn(router.history, 'back')
+ await act(() => fireEvent.click(screen.getByTestId('preferback-home')))
+
+ expect(onClick).toHaveBeenCalledTimes(1)
+ expect(backSpy).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts
index fd673ca410..9b8b748595 100644
--- a/packages/router-core/src/index.ts
+++ b/packages/router-core/src/index.ts
@@ -285,6 +285,7 @@ export type {
ClearCacheFn,
CreateRouterFn,
SSROption,
+ RouterHistoryEntry,
} from './router'
export * from './config'
diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts
index 55d5a79ce8..3e9a6034c5 100644
--- a/packages/router-core/src/link.ts
+++ b/packages/router-core/src/link.ts
@@ -691,6 +691,13 @@ export interface LinkOptionsProps {
* If the user exits this proximity before this delay, the preload will be cancelled.
*/
preloadIntentProximity?: number
+ /**
+ * Makes the link history-aware: when its target is the previous history entry,
+ * clicking goes back instead of pushing (preserving forward history + scroll).
+ * `'exact'` also requires search to match; otherwise falls back to a normal navigation
+ * @default false
+ */
+ preferBack?: boolean | 'pathname' | 'exact'
}
export type LinkOptions<
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 2197dab737..ccad08539b 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -956,6 +956,21 @@ declare global {
| undefined
}
+/**
+ * A visited history entry keyed by its `__TSR_index`, used to resolve navigation
+ * direction (the browser only exposes the current entry).
+ */
+export interface RouterHistoryEntry {
+ /** The entry's `__TSR_index` in the history stack. */
+ index: number
+ /** Parsed (basepath-stripped) pathname, comparable to `buildLocation().pathname`. */
+ pathname: string
+ /** Search string incl. leading `?`, comparable to `buildLocation().searchStr`. */
+ searchStr: string
+ /** Full href (pathname + search + hash), without origin. */
+ href: string
+}
+
/**
* Core, framework-agnostic router engine that powers TanStack Router.
*
@@ -1007,6 +1022,12 @@ export class RouterCore<
origin?: string
latestLocation!: ParsedLocation>
pendingBuiltLocation?: ParsedLocation>
+ /**
+ * Visited history entries keyed by `__TSR_index` (recorded in
+ * {@link updateLatestLocation}), so the router can resolve the previous entry —
+ * which the browser doesn't expose. Read via {@link getHistoryEntry}.
+ */
+ historyEntries = new Map()
basepath!: string
routeTree!: TRouteTree
routesById!: RoutesById
@@ -1238,8 +1259,27 @@ export class RouterCore<
this.history.location,
this.latestLocation,
)
+
+ // Record the committed entry by index. `replace` overwrites the same index;
+ // truncated forward entries are harmless since the back-decision only reads
+ // `index - 1`. Stored pathname/search compare directly with buildLocation().
+ const index = this.latestLocation.state.__TSR_index ?? 0
+ this.historyEntries.set(index, {
+ index,
+ pathname: this.latestLocation.pathname,
+ searchStr: this.latestLocation.searchStr,
+ href: this.latestLocation.href,
+ })
}
+ /**
+ * Look up a visited history entry by its `__TSR_index`. Returns `undefined` for
+ * entries the router never recorded (e.g. a fresh page load or deep link), so
+ * consumers should degrade gracefully.
+ */
+ getHistoryEntry = (index: number): RouterHistoryEntry | undefined =>
+ this.historyEntries.get(index)
+
buildRouteTree = () => {
const result = processRouteTree(
this.routeTree,
diff --git a/packages/router-core/tests/historyEntries.test.ts b/packages/router-core/tests/historyEntries.test.ts
new file mode 100644
index 0000000000..78ec7552ae
--- /dev/null
+++ b/packages/router-core/tests/historyEntries.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, test } from 'vitest'
+import { createMemoryHistory } from '@tanstack/history'
+import { BaseRootRoute, BaseRoute } from '../src'
+import { createTestRouter } from './routerTestUtils'
+
+function setup(initialEntries: Array = ['/']) {
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/' })
+ const aboutRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ validateSearch: (search: Record) => ({
+ page: search.page as number | undefined,
+ }),
+ })
+ const contactRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/contact',
+ })
+ const replacedRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/replaced',
+ })
+
+ const routeTree = rootRoute.addChildren([
+ indexRoute,
+ aboutRoute,
+ contactRoute,
+ replacedRoute,
+ ])
+
+ const router = createTestRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries }),
+ })
+
+ return router
+}
+
+describe('router.getHistoryEntry - per-index entry tracking', () => {
+ test('records each visited entry keyed by its __TSR_index', async () => {
+ const router = setup()
+ await router.load()
+
+ expect(router.getHistoryEntry(0)).toMatchObject({ index: 0, pathname: '/' })
+
+ await router.navigate({ to: '/about' })
+ await router.navigate({ to: '/contact' })
+
+ expect(router.getHistoryEntry(0)).toMatchObject({ pathname: '/' })
+ expect(router.getHistoryEntry(1)).toMatchObject({ index: 1, pathname: '/about' })
+ expect(router.getHistoryEntry(2)).toMatchObject({ index: 2, pathname: '/contact' })
+ })
+
+ test('replace reuses the same index and overwrites the entry', async () => {
+ const router = setup()
+ await router.load()
+
+ await router.navigate({ to: '/about' })
+ expect(router.getHistoryEntry(1)).toMatchObject({ pathname: '/about' })
+
+ await router.navigate({ to: '/replaced', replace: true })
+
+ // Same index, updated pathname — replace does not create a new slot.
+ expect(router.latestLocation.state.__TSR_index).toBe(1)
+ expect(router.getHistoryEntry(1)).toMatchObject({ index: 1, pathname: '/replaced' })
+ expect(router.getHistoryEntry(2)).toBeUndefined()
+ })
+
+ test('start of history has no previous entry', async () => {
+ const router = setup()
+ await router.load()
+
+ expect(router.latestLocation.state.__TSR_index).toBe(0)
+ expect(router.getHistoryEntry(-1)).toBeUndefined()
+ })
+
+ test('unknown / unvisited indices return undefined', async () => {
+ const router = setup()
+ await router.load()
+
+ expect(router.getHistoryEntry(99)).toBeUndefined()
+ expect(router.getHistoryEntry(1)).toBeUndefined()
+ })
+
+ test('records the search string alongside the pathname', async () => {
+ const router = setup()
+ await router.load()
+
+ await router.navigate({ to: '/about', search: { page: 3 } })
+
+ const entry = router.getHistoryEntry(1)
+ expect(entry).toMatchObject({ pathname: '/about' })
+ expect(entry?.searchStr).toBe('?page=3')
+ })
+
+ test('the previous entry reflects the entry the user came from', async () => {
+ const router = setup()
+ await router.load()
+ await router.navigate({ to: '/about' })
+ await router.navigate({ to: '/contact' })
+
+ const currentIndex = router.latestLocation.state.__TSR_index
+ expect(currentIndex).toBe(2)
+ // Going back from /contact should land on /about (index 1).
+ expect(router.getHistoryEntry(currentIndex - 1)).toMatchObject({
+ pathname: '/about',
+ })
+ })
+})