+ Search parameters are automatically persisted when you navigate away and
+ back
+
+
+
+
+ Testing Middleware Chain:
+
+
+ 1. Apply filters below (name, status)
+ 2. Click "View Details" on any user
+ 3. Switch between tabs (Profile, Activity, Settings)
+ 4. Click "← Back" and then "View Details" again
+ 5. See both retained filters AND persisted tab selections!
+
+ How it works: Search parameters are automatically
+ saved to a server database and restored when you navigate back to the
+ same route. This works across page refreshes and new tabs because the
+ data is stored server-side.
+
Come back - your Users filters are isolated & restored!
+
Notice: limit resets to 10 (not in persisted params)
+
+
+
+
+
🔧 SSR Safety
+
+ Each SSR request gets its own SearchPersistenceStore instance,
+ preventing cross-request state contamination. Try refreshing the page
+ with search params - they'll be restored correctly!
+
+
+
+ )
+}
diff --git a/examples/react/ssr-search-persistence/tsconfig.json b/examples/react/ssr-search-persistence/tsconfig.json
new file mode 100644
index 0000000000..4aaf48e263
--- /dev/null
+++ b/examples/react/ssr-search-persistence/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "esnext",
+ "types": ["vite/client"],
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "skipLibCheck": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/examples/react/ssr-search-persistence/vite.config.ts b/examples/react/ssr-search-persistence/vite.config.ts
new file mode 100644
index 0000000000..09cc95baa8
--- /dev/null
+++ b/examples/react/ssr-search-persistence/vite.config.ts
@@ -0,0 +1,53 @@
+import path from 'node:path'
+import url from 'node:url'
+import { tanstackRouter } from '@tanstack/router-plugin/vite'
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import type { BuildEnvironmentOptions } from 'vite'
+
+const __filename = url.fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+// SSR configuration
+const ssrBuildConfig: BuildEnvironmentOptions = {
+ ssr: true,
+ outDir: 'dist/server',
+ ssrEmitAssets: true,
+ copyPublicDir: false,
+ emptyOutDir: true,
+ rollupOptions: {
+ input: path.resolve(__dirname, 'src/entry-server.tsx'),
+ output: {
+ entryFileNames: '[name].js',
+ chunkFileNames: 'assets/[name]-[hash].js',
+ assetFileNames: 'assets/[name]-[hash][extname]',
+ },
+ },
+}
+
+// Client-specific configuration
+const clientBuildConfig: BuildEnvironmentOptions = {
+ outDir: 'dist/client',
+ emitAssets: true,
+ copyPublicDir: true,
+ emptyOutDir: true,
+ rollupOptions: {
+ input: path.resolve(__dirname, 'src/entry-client.tsx'),
+ output: {
+ entryFileNames: 'static/[name].js',
+ chunkFileNames: 'static/assets/[name]-[hash].js',
+ assetFileNames: 'static/assets/[name]-[hash][extname]',
+ },
+ },
+}
+
+// https://vitejs.dev/config/
+export default defineConfig((configEnv) => {
+ return {
+ plugins: [
+ tanstackRouter({ target: 'react', autoCodeSplitting: true }),
+ react(),
+ ],
+ build: configEnv.isSsrBuild ? ssrBuildConfig : clientBuildConfig,
+ }
+})
diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx
index ed78317315..3f000f74a2 100644
--- a/packages/react-router/src/index.tsx
+++ b/packages/react-router/src/index.tsx
@@ -28,10 +28,13 @@ export {
isPlainObject,
isPlainArray,
deepEqual,
+ SearchPersistenceStore,
shallow,
createControlledPromise,
retainSearchParams,
stripSearchParams,
+ persistSearchParams,
+ getSearchPersistenceStore,
} from '@tanstack/router-core'
export type {
diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts
index ca34da1722..b7a2a33ce4 100644
--- a/packages/router-core/src/index.ts
+++ b/packages/router-core/src/index.ts
@@ -254,7 +254,14 @@ export type {
BuildLocationFn,
} from './RouterProvider'
-export { retainSearchParams, stripSearchParams } from './searchMiddleware'
+export {
+ SearchPersistenceStore,
+ createSearchPersistenceStore,
+ retainSearchParams,
+ stripSearchParams,
+ persistSearchParams,
+ getSearchPersistenceStore,
+} from './searchMiddleware'
export {
defaultParseSearch,
diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts
index bdda040845..b88e043b31 100644
--- a/packages/router-core/src/route.ts
+++ b/packages/router-core/src/route.ts
@@ -71,15 +71,38 @@ export type RoutePathOptionsIntersection = {
export type SearchFilter = (prev: TInput) => TResult
+export interface SearchMiddlewareRouter {
+ options: {
+ searchPersistenceStore?: any // Avoid circular dependency, will be typed at usage
+ }
+ state?: {
+ location?: {
+ pathname?: string
+ }
+ }
+ destPathname?: string // 🎯 Destination pathname for per-user storage keys
+}
+
export type SearchMiddlewareContext = {
search: TSearchSchema
next: (newSearch: TSearchSchema) => TSearchSchema
+ route: { id: string; fullPath: string }
+ router: SearchMiddlewareRouter
}
-export type SearchMiddleware = (
+export type SearchMiddlewareFunction = (
ctx: SearchMiddlewareContext,
) => TSearchSchema
+export type SearchMiddlewareObject = {
+ middleware: SearchMiddlewareFunction
+ inheritParentMiddlewares?: boolean
+}
+
+export type SearchMiddleware =
+ | SearchMiddlewareFunction
+ | SearchMiddlewareObject
+
export type ResolveId<
TParentRoute,
TCustomId extends string,
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 6a132b5783..65be3b9e21 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -31,6 +31,7 @@ import {
import { isNotFound } from './not-found'
import { setupScrollRestoration } from './scroll-restoration'
import { defaultParseSearch, defaultStringifySearch } from './searchParams'
+import { SearchPersistenceStore } from './searchMiddleware'
import { rootRouteId } from './root'
import { isRedirect, redirect } from './redirect'
import { createLRUCache } from './lru-cache'
@@ -61,6 +62,7 @@ import type {
RouteContextOptions,
RouteMask,
SearchMiddleware,
+ SearchMiddlewareContext,
} from './route'
import type {
FullSearchSchema,
@@ -276,6 +278,15 @@ export interface RouterOptions<
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/router-context)
*/
context?: InferRouterContext
+ /**
+ * A store instance that will be used to persist search parameters across route navigations.
+ *
+ * If not provided, a new SearchPersistenceStore instance will be created automatically for this router.
+ * On the server, search persistence will be disabled unless an explicit store is provided.
+ *
+ * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/search-params-persistence)
+ */
+ searchPersistenceStore?: SearchPersistenceStore
/**
* A function that will be called when the router is dehydrated.
*
@@ -922,6 +933,11 @@ export class RouterCore<
setupScrollRestoration(this)
}
+ // Initialize searchPersistenceStore if not provided and not on server
+ if (!this.options.searchPersistenceStore && typeof window !== 'undefined') {
+ this.options.searchPersistenceStore = new SearchPersistenceStore()
+ }
+
if (
typeof window !== 'undefined' &&
'CSS' in window &&
@@ -1337,20 +1353,20 @@ export class RouterCore<
// Update the match's context
if (route.options.context) {
- const contextFnContext: RouteContextOptions = {
- deps: match.loaderDeps,
- params: match.params,
+ const contextFnContext: RouteContextOptions = {
+ deps: match.loaderDeps,
+ params: match.params,
context: parentContext ?? {},
- location: next,
- navigate: (opts: any) =>
- this.navigate({ ...opts, _fromLocation: next }),
- buildLocation: this.buildLocation,
- cause: match.cause,
- abortController: match.abortController,
- preload: !!match.preload,
- matches,
- }
- // Get the route context
+ location: next,
+ navigate: (opts: any) =>
+ this.navigate({ ...opts, _fromLocation: next }),
+ buildLocation: this.buildLocation,
+ cause: match.cause,
+ abortController: match.abortController,
+ preload: !!match.preload,
+ matches,
+ }
+ // Get the route context
match.__routeContext =
route.options.context(contextFnContext) ?? undefined
}
@@ -1452,7 +1468,7 @@ export class RouterCore<
// for from to be invalid it shouldn't just be unmatched to currentLocation
// but the currentLocation should also be unmatched to from
if (!matchedFrom && !matchedCurrent) {
- console.warn(`Could not find match for from: ${fromPath}`)
+ console.warn(`Could not find match for from: ${fromPath}`)
}
}
}
@@ -1474,7 +1490,7 @@ export class RouterCore<
dest.params === false || dest.params === null
? {}
: (dest.params ?? true) === true
- ? fromParams
+ ? fromParams
: Object.assign(
fromParams,
functionalUpdate(dest.params as any, fromParams),
@@ -1488,14 +1504,14 @@ export class RouterCore<
}).interpolatedPath
const destRoutes = this.matchRoutes(interpolatedNextTo, undefined, {
- _buildLocation: true,
+ _buildLocation: true,
}).map((d) => this.looseRoutesById[d.routeId]!)
// If there are any params, we need to stringify them
if (Object.keys(nextParams).length > 0) {
for (const route of destRoutes) {
const fn =
- route.options.params?.stringify ?? route.options.stringifyParams
+ route.options.params?.stringify ?? route.options.stringifyParams
if (fn) {
Object.assign(nextParams, fn(nextParams))
}
@@ -1515,10 +1531,11 @@ export class RouterCore<
// Resolve the next search
let nextSearch = fromSearch
+
if (opts._includeValidateSearch && this.options.search?.strict) {
const validatedSearch = {}
destRoutes.forEach((route) => {
- if (route.options.validateSearch) {
+ if (route.options.validateSearch) {
try {
Object.assign(
validatedSearch,
@@ -1527,19 +1544,40 @@ export class RouterCore<
...nextSearch,
}),
)
- } catch {
- // ignore errors here because they are already handled in matchRoutes
+ } catch {
+ // ignore errors here because they are already handled in matchRoutes
}
}
})
nextSearch = validatedSearch
}
+ // Filter search params through destination route's validateSearch before middlewares
+ const finalRoute = destRoutes[destRoutes.length - 1]
+ let filteredSearch = nextSearch
+
+ if (finalRoute?.options.validateSearch) {
+ try {
+ // Apply validateSearch to filter out invalid params for this route
+ filteredSearch =
+ validateSearch(finalRoute.options.validateSearch, nextSearch) ?? {}
+ } catch {
+ // If validation fails, start with empty search
+ filteredSearch = {}
+ }
+ } else {
+ // Routes without validateSearch get clean search (prevents contamination)
+ filteredSearch = {}
+ }
+
nextSearch = applySearchMiddleware({
- search: nextSearch,
+ search: filteredSearch,
dest,
destRoutes,
_includeValidateSearch: opts._includeValidateSearch,
+ router: this,
+ currentLocationMatches: allCurrentLocationMatches,
+ destPathname: nextPathname,
})
// Replace the equal deep
@@ -1598,9 +1636,9 @@ export class RouterCore<
this.basepath,
next.pathname,
{
- to: d.from,
- caseSensitive: false,
- fuzzy: false,
+ to: d.from,
+ caseSensitive: false,
+ fuzzy: false,
},
this.parsePathnameCache,
)
@@ -2279,8 +2317,8 @@ export class RouterCore<
this.basepath,
baseLocation.pathname,
{
- ...opts,
- to: next.pathname,
+ ...opts,
+ to: next.pathname,
},
this.parsePathnameCache,
) as any
@@ -2635,10 +2673,10 @@ export function getMatchedRoutes({
basepath,
trimmedPath,
{
- to: route.fullPath,
- caseSensitive: route.options?.caseSensitive ?? caseSensitive,
+ to: route.fullPath,
+ caseSensitive: route.options?.caseSensitive ?? caseSensitive,
// we need fuzzy matching for `notFoundMode: 'fuzzy'`
- fuzzy: true,
+ fuzzy: true,
},
parseCache,
)
@@ -2668,7 +2706,7 @@ export function getMatchedRoutes({
}
} else {
foundRoute = route
- routeParams = matchedParams
+ routeParams = matchedParams
break
}
}
@@ -2698,93 +2736,125 @@ function applySearchMiddleware({
dest,
destRoutes,
_includeValidateSearch,
+ router,
+ currentLocationMatches,
+ destPathname,
}: {
search: any
dest: BuildNextOptions
destRoutes: Array
_includeValidateSearch: boolean | undefined
+ router: { options: { searchPersistenceStore?: any } }
+ currentLocationMatches: Array
+ destPathname: string
}) {
- const allMiddlewares =
- destRoutes.reduce(
- (acc, route) => {
- const middlewares: Array> = []
-
- if ('search' in route.options) {
- if (route.options.search?.middlewares) {
- middlewares.push(...route.options.search.middlewares)
+ const allMiddlewares: Array<{
+ middleware: SearchMiddleware
+ route: { id: string; fullPath: string }
+ }> = destRoutes.reduce(
+ (acc, route, routeIndex) => {
+ const middlewares: Array> = []
+ const isDestinationRoute = routeIndex === destRoutes.length - 1
+
+ if ('search' in route.options) {
+ if (route.options.search?.middlewares) {
+ for (const middleware of route.options.search.middlewares) {
+ const isFunction = typeof middleware === 'function'
+ const inheritFlag = isFunction
+ ? undefined
+ : middleware.inheritParentMiddlewares
+ const shouldInclude = isFunction
+ ? true
+ : middleware.inheritParentMiddlewares !== false
+
+ if (isDestinationRoute || shouldInclude) {
+ middlewares.push(middleware)
+ }
}
}
- // TODO remove preSearchFilters and postSearchFilters in v2
- else if (
- route.options.preSearchFilters ||
- route.options.postSearchFilters
- ) {
- const legacyMiddleware: SearchMiddleware = ({
- search,
- next,
- }) => {
- let nextSearch = search
-
- if (
- 'preSearchFilters' in route.options &&
- route.options.preSearchFilters
- ) {
- nextSearch = route.options.preSearchFilters.reduce(
- (prev, next) => next(prev),
- search,
- )
- }
+ }
+ // TODO remove preSearchFilters and postSearchFilters in v2
+ else if (
+ route.options.preSearchFilters ||
+ route.options.postSearchFilters
+ ) {
+ const legacyMiddleware = ({
+ search,
+ next,
+ }: SearchMiddlewareContext) => {
+ let nextSearch = search
- const result = next(nextSearch)
+ if (
+ 'preSearchFilters' in route.options &&
+ route.options.preSearchFilters
+ ) {
+ nextSearch = route.options.preSearchFilters.reduce(
+ (prev, next) => next(prev),
+ search,
+ )
+ }
- if (
- 'postSearchFilters' in route.options &&
- route.options.postSearchFilters
- ) {
- return route.options.postSearchFilters.reduce(
- (prev, next) => next(prev),
- result,
- )
- }
+ const result = next(nextSearch)
- return result
+ if (
+ 'postSearchFilters' in route.options &&
+ route.options.postSearchFilters
+ ) {
+ return route.options.postSearchFilters.reduce(
+ (prev, next) => next(prev),
+ result,
+ )
}
- middlewares.push(legacyMiddleware)
+
+ return result
}
- if (_includeValidateSearch && route.options.validateSearch) {
- const validate: SearchMiddleware = ({ search, next }) => {
- const result = next(search)
- try {
- const validatedSearch = {
- ...result,
- ...(validateSearch(route.options.validateSearch, result) ??
- undefined),
- }
- return validatedSearch
- } catch {
- // ignore errors here because they are already handled in matchRoutes
- return result
+ middlewares.push(legacyMiddleware)
+ }
+
+ if (_includeValidateSearch && route.options.validateSearch) {
+ const validate = ({ search, next }: SearchMiddlewareContext) => {
+ const result = next(search)
+ try {
+ const validatedSearch = {
+ ...result,
+ ...(validateSearch(route.options.validateSearch, result) ??
+ undefined),
}
+ return validatedSearch
+ } catch {
+ // ignore errors here because they are already handled in matchRoutes
+ return result
}
-
- middlewares.push(validate)
}
- return acc.concat(middlewares)
- },
- [] as Array>,
- ) ?? []
+ middlewares.push(validate)
+ }
- // the chain ends here since `next` is not called
- const final: SearchMiddleware = ({ search }) => {
- if (!dest.search) {
- return {}
- }
- if (dest.search === true) {
- return search
- }
- return functionalUpdate(dest.search, search)
+ return acc.concat(
+ middlewares.map((middleware) => ({
+ middleware,
+ route: { id: route.id, fullPath: route.fullPath },
+ })),
+ )
+ },
+ [] as Array<{
+ middleware: SearchMiddleware
+ route: { id: string; fullPath: string }
+ }>,
+ )
+
+ const final = {
+ middleware: ({ search }: { search: any }) => {
+ if (!dest.search) {
+ return search
+ }
+ if (dest.search === true) {
+ return search
+ }
+ return functionalUpdate(dest.search, search)
+ },
+ route: { id: '', fullPath: '' },
}
allMiddlewares.push(final)
@@ -2795,13 +2865,25 @@ function applySearchMiddleware({
return currentSearch
}
- const middleware = allMiddlewares[index]!
+ const { middleware, route } = allMiddlewares[index]!
const next = (newSearch: any): any => {
return applyNext(index + 1, newSearch)
}
- return middleware({ search: currentSearch, next })
+ const middlewareFunction =
+ typeof middleware === 'function' ? middleware : middleware.middleware
+
+ return middlewareFunction({
+ search: currentSearch,
+ next,
+ route: { id: route.id, fullPath: route.fullPath },
+ router: {
+ ...router,
+ state: (router as any).__store?.state,
+ destPathname,
+ },
+ })
}
// Start applying middlewares
diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts
index 03ebbe213f..a74380163e 100644
--- a/packages/router-core/src/searchMiddleware.ts
+++ b/packages/router-core/src/searchMiddleware.ts
@@ -1,44 +1,46 @@
-import { deepEqual } from './utils'
-import type { NoInfer, PickOptional } from './utils'
-import type { SearchMiddleware } from './route'
-import type { IsRequiredParams } from './link'
+import { Store } from '@tanstack/store'
+import { deepEqual, replaceEqualDeep } from './utils'
+import type {
+ AnyRoute,
+ SearchMiddleware,
+ SearchMiddlewareObject,
+} from './route'
+import type { RouteById, RoutesById } from './routeInfo'
+import type { RegisteredRouter } from './router'
-export function retainSearchParams(
+export function retainSearchParams>(
keys: Array | true,
-): SearchMiddleware {
+): SearchMiddleware {
return ({ search, next }) => {
const result = next(search)
+
if (keys === true) {
- return { ...search, ...result }
+ return replaceEqualDeep({}, { ...search, ...result })
}
- // add missing keys from search to result
+
+ const newResult = { ...result } as Record
keys.forEach((key) => {
- if (!(key in result)) {
- result[key] = search[key]
+ if (!(key in newResult)) {
+ newResult[key as string] = (search as Record)[
+ key as string
+ ]
}
})
- return result
+ return replaceEqualDeep({}, newResult)
}
}
-export function stripSearchParams<
- TSearchSchema,
- TOptionalProps = PickOptional>,
- const TValues =
- | Partial>
- | Array,
- const TInput = IsRequiredParams extends never
- ? TValues | true
- : TValues,
->(input: NoInfer): SearchMiddleware {
+export function stripSearchParams>(
+ input: Partial | Array | true,
+): SearchMiddleware {
return ({ search, next }) => {
if (input === true) {
- return {}
+ return {} as any
}
const result = next(search) as Record
if (Array.isArray(input)) {
input.forEach((key) => {
- delete result[key]
+ delete result[key as string]
})
} else {
Object.entries(input as Record).forEach(
@@ -52,3 +54,243 @@ export function stripSearchParams<
return result as any
}
}
+
+export class SearchPersistenceStore {
+ private __store: Store>>
+
+ constructor() {
+ this.__store = new Store({})
+ }
+
+ get state() {
+ return this.__store.state
+ }
+
+ subscribe(listener: () => void) {
+ return this.__store.subscribe(listener)
+ }
+
+ get store() {
+ return this.__store
+ }
+
+ getTypedStore<
+ TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
+ >(): Store<{
+ [K in keyof RoutesById]: RouteById<
+ TRouteTree,
+ K
+ >['types']['fullSearchSchema']
+ }> {
+ return this.__store as Store<{
+ [K in keyof RoutesById]: RouteById<
+ TRouteTree,
+ K
+ >['types']['fullSearchSchema']
+ }>
+ }
+
+ getTypedState<
+ TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
+ >(): {
+ [K in keyof RoutesById]: RouteById<
+ TRouteTree,
+ K
+ >['types']['fullSearchSchema']
+ } {
+ return this.__store.state as {
+ [K in keyof RoutesById]: RouteById<
+ TRouteTree,
+ K
+ >['types']['fullSearchSchema']
+ }
+ }
+
+ saveSearch<
+ TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
+ TRouteId extends
+ keyof RoutesById = keyof RoutesById,
+ >(
+ routeId: TRouteId,
+ search: RouteById['types']['fullSearchSchema'],
+ ): void {
+ const searchRecord = search as Record
+ const cleanedSearch = Object.fromEntries(
+ Object.entries(searchRecord).filter(([_, value]) => {
+ if (value === null || value === undefined || value === '') return false
+ if (Array.isArray(value) && value.length === 0) return false
+ if (typeof value === 'object' && Object.keys(value).length === 0)
+ return false
+ return true
+ }),
+ )
+
+ this.__store.setState((prevState) => {
+ return Object.keys(cleanedSearch).length === 0
+ ? (() => {
+ const { [routeId]: _, ...rest } = prevState
+ return rest
+ })()
+ : replaceEqualDeep(prevState, {
+ ...prevState,
+ [routeId]: cleanedSearch,
+ })
+ })
+ }
+
+ getSearch<
+ TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
+ TRouteId extends
+ keyof RoutesById = keyof RoutesById,
+ >(
+ routeId: TRouteId,
+ ): RouteById['types']['fullSearchSchema'] | null {
+ return (
+ (this.state[routeId as string] as RouteById<
+ TRouteTree,
+ TRouteId
+ >['types']['fullSearchSchema']) || null
+ )
+ }
+
+ clearSearch<
+ TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
+ TRouteId extends
+ keyof RoutesById = keyof RoutesById,
+ >(routeId: TRouteId): void {
+ this.__store.setState((prevState) => {
+ const { [routeId as string]: _, ...rest } = prevState
+ return rest
+ })
+ }
+
+ clearAllSearches(): void {
+ this.__store.setState(() => ({}))
+ }
+}
+
+// Factory function to create a new SearchPersistenceStore instance
+export function createSearchPersistenceStore(): SearchPersistenceStore {
+ return new SearchPersistenceStore()
+}
+
+// Get a typed interface for an existing SearchPersistenceStore instance
+export function getSearchPersistenceStore<
+ TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
+>(
+ store: SearchPersistenceStore,
+): {
+ state: {
+ [K in keyof RoutesById]: RouteById<
+ TRouteTree,
+ K
+ >['types']['fullSearchSchema']
+ }
+ store: Store<{
+ [K in keyof RoutesById]: RouteById<
+ TRouteTree,
+ K
+ >['types']['fullSearchSchema']
+ }>
+ subscribe: (listener: () => void) => () => void
+ getSearch: >(
+ routeId: TRouteId,
+ ) => RouteById['types']['fullSearchSchema'] | null
+ saveSearch: >(
+ routeId: TRouteId,
+ search: RouteById['types']['fullSearchSchema'],
+ ) => void
+ clearSearch: >(
+ routeId: TRouteId,
+ ) => void
+ clearAllSearches: () => void
+} {
+ return {
+ get state() {
+ return store.getTypedState()
+ },
+ get store() {
+ return store.getTypedStore()
+ },
+ subscribe: (listener: () => void) => store.subscribe(listener),
+ getSearch: >(
+ routeId: TRouteId,
+ ) => store.getSearch(routeId),
+ saveSearch: >(
+ routeId: TRouteId,
+ search: RouteById['types']['fullSearchSchema'],
+ ) => store.saveSearch(routeId, search),
+ clearSearch: >(
+ routeId: TRouteId,
+ ) => store.clearSearch(routeId),
+ clearAllSearches: () => store.clearAllSearches(),
+ }
+}
+
+export function persistSearchParams>(
+ persistedSearchParams: Array,
+ exclude?: Array,
+): SearchMiddlewareObject {
+ return {
+ middleware: ({ search, next, router }) => {
+ const store = router.options.searchPersistenceStore as
+ | SearchPersistenceStore
+ | undefined
+
+ if (!store) {
+ return next(search)
+ }
+
+ const storageKey = router.destPathname || ''
+
+ const savedSearch = store.getSearch(storageKey)
+
+ let searchToProcess = search
+
+ if (savedSearch && Object.keys(savedSearch).length > 0) {
+ // User has saved preferences - restore them
+ const onlyOwnedParams = Object.fromEntries(
+ persistedSearchParams
+ .map((key) => [String(key), savedSearch[String(key)]])
+ .filter(([_, value]) => value !== undefined),
+ )
+ searchToProcess = { ...search, ...onlyOwnedParams }
+ } else {
+ // No saved preferences - remove our parameters to let validateSearch set defaults
+ const searchWithoutOwnedParams = { ...search } as Record<
+ string,
+ unknown
+ >
+ persistedSearchParams.forEach((key) => {
+ delete searchWithoutOwnedParams[String(key)]
+ })
+ searchToProcess = searchWithoutOwnedParams as any
+ }
+
+ const result = next(searchToProcess)
+
+ // Save only this route's parameters
+ const resultRecord = result as Record
+ const persistedKeysStr = persistedSearchParams.map((key) => String(key))
+ const paramsToSave = Object.fromEntries(
+ Object.entries(resultRecord).filter(([key]) =>
+ persistedKeysStr.includes(key),
+ ),
+ )
+
+ const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
+ const filteredResult = Object.fromEntries(
+ Object.entries(paramsToSave).filter(
+ ([key]) => !excludeKeys.includes(key),
+ ),
+ )
+
+ if (Object.keys(filteredResult).length > 0) {
+ store.saveSearch(storageKey, filteredResult)
+ }
+
+ return result
+ },
+ inheritParentMiddlewares: false,
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9a822a6677..408e6f1c32 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4649,6 +4649,37 @@ importers:
specifier: 6.3.5
version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)
+ examples/react/search-persistence:
+ dependencies:
+ '@tanstack/react-router':
+ specifier: workspace:*
+ version: link:../../../packages/react-router
+ '@tanstack/react-router-devtools':
+ specifier: workspace:^
+ version: link:../../../packages/react-router-devtools
+ '@tanstack/react-store':
+ specifier: ^0.7.0
+ version: 0.7.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@tanstack/router-plugin':
+ specifier: workspace:*
+ version: link:../../../packages/router-plugin
+ autoprefixer:
+ specifier: ^10.4.20
+ version: 10.4.20(postcss@8.5.3)
+ postcss:
+ specifier: ^8.5.1
+ version: 8.5.3
+ tailwindcss:
+ specifier: ^3.4.17
+ version: 3.4.17
+ zod:
+ specifier: ^3.24.2
+ version: 3.25.57
+ devDependencies:
+ '@vitejs/plugin-react':
+ specifier: ^4.3.4
+ version: 4.6.0(vite@6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))
+
examples/react/search-validator-adapters:
dependencies:
'@tanstack/arktype-adapter':
@@ -4719,6 +4750,61 @@ importers:
specifier: 6.3.5
version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)
+ examples/react/ssr-search-persistence:
+ dependencies:
+ '@tanstack/react-router':
+ specifier: workspace:*
+ version: link:../../../packages/react-router
+ '@tanstack/router-plugin':
+ specifier: workspace:*
+ version: link:../../../packages/router-plugin
+ compression:
+ specifier: ^1.8.0
+ version: 1.8.0
+ express:
+ specifier: ^4.21.2
+ version: 4.21.2
+ get-port:
+ specifier: ^7.1.0
+ version: 7.1.0
+ isbot:
+ specifier: ^5.1.28
+ version: 5.1.28
+ node-fetch:
+ specifier: ^3.3.2
+ version: 3.3.2
+ react:
+ specifier: ^19.0.0
+ version: 19.0.0
+ react-dom:
+ specifier: ^19.0.0
+ version: 19.0.0(react@19.0.0)
+ zod:
+ specifier: ^3.23.8
+ version: 3.25.57
+ devDependencies:
+ '@tanstack/react-router-devtools':
+ specifier: workspace:^
+ version: link:../../../packages/react-router-devtools
+ '@types/express':
+ specifier: ^4.17.23
+ version: 4.17.23
+ '@types/react':
+ specifier: ^19.0.8
+ version: 19.0.8
+ '@types/react-dom':
+ specifier: ^19.0.3
+ version: 19.0.3(@types/react@19.0.8)
+ '@vitejs/plugin-react':
+ specifier: ^4.5.2
+ version: 4.6.0(vite@6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))
+ typescript:
+ specifier: ^5.8.3
+ version: 5.9.2
+ vite:
+ specifier: 6.3.5
+ version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)
+
examples/react/start-bare:
dependencies:
'@tanstack/react-router':
diff --git a/port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt b/port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt
new file mode 100644
index 0000000000..64c54dd52c
--- /dev/null
+++ b/port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt
@@ -0,0 +1 @@
+49614
\ No newline at end of file
diff --git a/port-tanstack-router-e2e-react-basic-esbuild-file-based.txt b/port-tanstack-router-e2e-react-basic-esbuild-file-based.txt
new file mode 100644
index 0000000000..0e4201dff4
--- /dev/null
+++ b/port-tanstack-router-e2e-react-basic-esbuild-file-based.txt
@@ -0,0 +1 @@
+49613
\ No newline at end of file