From 30f64043ae049c1aec1249452077425006560046 Mon Sep 17 00:00:00 2001
From: Nitsan Cohen <77798308+NitsanCohen770@users.noreply.github.com>
Date: Wed, 20 Aug 2025 21:47:44 +0300
Subject: [PATCH 1/8] feat: add search persistence middleware
- Add persistSearchParams middleware for automatic search parameter persistence
- Add getSearchPersistenceStore() function with full type inference
- Include comprehensive example with Users and Products routes
- Add API documentation following project conventions
- Support selective parameter exclusion with typed arrays
- SSR compatible with proper route context handling
- Framework agnostic core with React integration
---
.../api/router/persistSearchParamsFunction.md | 209 ++++++++++++++++++
examples/react/search-persistence/README.md | 95 ++++++++
examples/react/search-persistence/index.html | 12 +
.../react/search-persistence/package.json | 24 ++
.../search-persistence/postcss.config.mjs | 6 +
.../react/search-persistence/src/main.tsx | 29 +++
.../search-persistence/src/routeTree.gen.ts | 95 ++++++++
.../search-persistence/src/routes/__root.tsx | 46 ++++
.../search-persistence/src/routes/index.tsx | 70 ++++++
.../src/routes/products.tsx | 134 +++++++++++
.../search-persistence/src/routes/users.tsx | 109 +++++++++
.../react/search-persistence/src/styles.css | 13 ++
.../src/type-inference-test.ts | 28 +++
.../src/utils/localStorage-sync.ts | 29 +++
.../search-persistence/tailwind.config.mjs | 4 +
.../react/search-persistence/tsconfig.json | 9 +
.../react/search-persistence/vite.config.js | 14 ++
packages/react-router/src/index.tsx | 5 +
packages/router-core/src/index.ts | 7 +-
packages/router-core/src/route.ts | 1 +
packages/router-core/src/router.ts | 204 +++++++++--------
packages/router-core/src/searchMiddleware.ts | 196 +++++++++++++++-
22 files changed, 1241 insertions(+), 98 deletions(-)
create mode 100644 docs/router/framework/react/api/router/persistSearchParamsFunction.md
create mode 100644 examples/react/search-persistence/README.md
create mode 100644 examples/react/search-persistence/index.html
create mode 100644 examples/react/search-persistence/package.json
create mode 100644 examples/react/search-persistence/postcss.config.mjs
create mode 100644 examples/react/search-persistence/src/main.tsx
create mode 100644 examples/react/search-persistence/src/routeTree.gen.ts
create mode 100644 examples/react/search-persistence/src/routes/__root.tsx
create mode 100644 examples/react/search-persistence/src/routes/index.tsx
create mode 100644 examples/react/search-persistence/src/routes/products.tsx
create mode 100644 examples/react/search-persistence/src/routes/users.tsx
create mode 100644 examples/react/search-persistence/src/styles.css
create mode 100644 examples/react/search-persistence/src/type-inference-test.ts
create mode 100644 examples/react/search-persistence/src/utils/localStorage-sync.ts
create mode 100644 examples/react/search-persistence/tailwind.config.mjs
create mode 100644 examples/react/search-persistence/tsconfig.json
create mode 100644 examples/react/search-persistence/vite.config.js
diff --git a/docs/router/framework/react/api/router/persistSearchParamsFunction.md b/docs/router/framework/react/api/router/persistSearchParamsFunction.md
new file mode 100644
index 00000000000..00aaa281b76
--- /dev/null
+++ b/docs/router/framework/react/api/router/persistSearchParamsFunction.md
@@ -0,0 +1,209 @@
+---
+id: persistSearchParams
+title: Search middleware to persist search params
+---
+
+`persistSearchParams` is a search middleware that automatically saves and restores search parameters when navigating between routes.
+
+## persistSearchParams props
+
+`persistSearchParams` accepts one of the following inputs:
+
+- `undefined` (no arguments): persist all search params
+- a list of keys of those search params that shall be excluded from persistence
+
+## How it works
+
+The middleware has two main functions:
+
+1. **Saving**: Automatically saves search parameters when they change
+2. **Restoring**: Restores saved parameters when the middleware is triggered with empty search
+
+**Important**: The middleware only runs when search parameters are being processed. This means:
+
+- **Without search prop**: ` ` → Middleware doesn't run → No restoration
+- **With search function**: ` prev}>` → Middleware runs → Restoration happens
+- **With explicit search**: ` ` → Middleware runs → No restoration (params provided)
+
+## Restoration Behavior
+
+⚠️ **Unexpected behavior warning**: If you use the persistence middleware but navigate without the `search` prop, the middleware will only trigger later when you modify search parameters. This can cause unexpected restoration of saved parameters mixed with your new changes.
+
+**Recommended**: Always be explicit about restoration intent using the `search` prop.
+
+## Examples
+
+```tsx
+import { z } from 'zod'
+import { createFileRoute, persistSearchParams } from '@tanstack/react-router'
+
+const usersSearchSchema = z.object({
+ name: z.string().optional().catch(''),
+ status: z.enum(['active', 'inactive', 'all']).optional().catch('all'),
+ page: z.number().optional().catch(0),
+})
+
+export const Route = createFileRoute('/users')({
+ validateSearch: usersSearchSchema,
+ search: {
+ // persist all search params
+ middlewares: [persistSearchParams()],
+ },
+})
+```
+
+```tsx
+import { z } from 'zod'
+import { createFileRoute, persistSearchParams } from '@tanstack/react-router'
+
+const productsSearchSchema = z.object({
+ category: z.string().optional(),
+ minPrice: z.number().optional(),
+ maxPrice: z.number().optional(),
+ tempFilter: z.string().optional(),
+})
+
+export const Route = createFileRoute('/products')({
+ validateSearch: productsSearchSchema,
+ search: {
+ // exclude tempFilter from persistence
+ middlewares: [persistSearchParams(['tempFilter'])],
+ },
+})
+```
+
+```tsx
+import { z } from 'zod'
+import { createFileRoute, persistSearchParams } from '@tanstack/react-router'
+
+const searchSchema = z.object({
+ category: z.string().optional(),
+ sortBy: z.string().optional(),
+ sortOrder: z.string().optional(),
+ tempFilter: z.string().optional(),
+})
+
+export const Route = createFileRoute('/products')({
+ validateSearch: searchSchema,
+ search: {
+ // exclude tempFilter and sortBy from persistence
+ middlewares: [persistSearchParams(['tempFilter', 'sortBy'])],
+ },
+})
+```
+
+## Restoration Patterns
+
+### Automatic Restoration with Links
+
+Use `search={(prev) => prev}` to trigger middleware restoration:
+
+```tsx
+import { Link } from '@tanstack/react-router'
+
+function Navigation() {
+ return (
+
+ {/* Full restoration - restores all saved parameters */}
+ prev}>
+ Users
+
+
+ {/* Partial override - restore saved params but override specific ones */}
+ ({ ...prev, category: 'Electronics' })}>
+ Electronics Products
+
+
+ {/* Clean navigation - no restoration */}
+
+ Users (clean slate)
+
+
+ )
+}
+```
+
+### Exclusion Strategies
+
+You have two ways to exclude parameters from persistence:
+
+**1. Middleware-level exclusion** (permanent):
+```tsx
+// These parameters are never saved
+middlewares: [persistSearchParams(['tempFilter', 'sortBy'])]
+```
+
+**2. Link-level exclusion** (per navigation):
+```tsx
+// Restore saved params but exclude specific ones
+ {
+ const { tempFilter, ...rest } = prev || {}
+ return rest
+}}>
+ Products (excluding temp filter)
+
+```
+
+### Manual Restoration
+
+Access the store directly for full control:
+
+```tsx
+import { getSearchPersistenceStore, Link } from '@tanstack/react-router'
+
+function CustomNavigation() {
+ const store = getSearchPersistenceStore()
+ const savedUsersSearch = store.getSearch('/users')
+
+ return (
+
+ Users (with saved search)
+
+ )
+}
+```
+
+## Using the search persistence store
+
+You can also access the search persistence store directly for manual control:
+
+```tsx
+import { getSearchPersistenceStore } from '@tanstack/react-router'
+
+// Get the fully typed store instance
+const store = getSearchPersistenceStore()
+
+// Get persisted search for a route
+const savedSearch = store.getSearch('/users')
+
+// Clear persisted search for a specific route
+store.clearSearch('/users')
+
+// Clear all persisted searches
+store.clearAllSearches()
+
+// Manually save search for a route
+store.saveSearch('/users', { name: 'John', status: 'active' })
+```
+
+```tsx
+import { getSearchPersistenceStore } from '@tanstack/react-router'
+import { useStore } from '@tanstack/react-store'
+import React from 'react'
+
+function MyComponent() {
+ const store = getSearchPersistenceStore()
+ const storeState = useStore(store.store)
+
+ const clearUserSearch = () => {
+ store.clearSearch('/users')
+ }
+
+ return (
+
+
Saved search: {JSON.stringify(storeState['/users'])}
+
Clear saved search
+
+ )
+}
+```
\ No newline at end of file
diff --git a/examples/react/search-persistence/README.md b/examples/react/search-persistence/README.md
new file mode 100644
index 00000000000..f6e6ab0b315
--- /dev/null
+++ b/examples/react/search-persistence/README.md
@@ -0,0 +1,95 @@
+# Search Persistence Example
+
+This example demonstrates TanStack Router's search persistence middleware, which automatically saves and restores search parameters when navigating between routes.
+
+## Overview
+
+The `persistSearchParams` middleware provides seamless search parameter persistence across route navigation. Search parameters are automatically saved when you leave a route and restored when you return, maintaining user context and improving UX.
+
+## Key Features
+
+- **Automatic Persistence**: Search parameters are saved/restored automatically
+- **Selective Exclusion**: Choose which parameters to exclude from persistence
+- **Type Safety**: Full TypeScript support with automatic type inference
+- **Manual Control**: Direct store access for advanced use cases
+
+## Basic Usage
+
+```tsx
+import { createFileRoute, persistSearchParams } from '@tanstack/react-router'
+
+// Persist all search parameters
+export const Route = createFileRoute('/users')({
+ validateSearch: usersSearchSchema,
+ search: {
+ middlewares: [persistSearchParams()],
+ },
+})
+
+// Exclude specific parameters from persistence
+export const Route = createFileRoute('/products')({
+ validateSearch: productsSearchSchema,
+ search: {
+ middlewares: [persistSearchParams(['tempFilter', 'sortBy'])],
+ },
+})
+```
+
+## Restoration Patterns
+
+⚠️ **Important**: The middleware only runs when search parameters are being processed. Always be explicit about your restoration intent.
+
+### Automatic Restoration
+```tsx
+import { Link } from '@tanstack/react-router'
+
+// Full restoration - restores all saved parameters
+ prev}>
+ Users (restore all)
+
+
+// Partial override - restore but override specific parameters
+ ({ ...prev, category: 'Electronics' })}>
+ Electronics Products
+
+
+// Clean navigation - no restoration
+
+ Users (clean slate)
+
+```
+
+### Manual Restoration
+Access the store directly for full control:
+
+```tsx
+import { getSearchPersistenceStore } from '@tanstack/react-router'
+
+const store = getSearchPersistenceStore()
+const savedSearch = store.getSearch('/users')
+
+
+ Users (manual restoration)
+
+```
+
+### ⚠️ Unexpected Behavior Warning
+
+If you use the persistence middleware but navigate without the `search` prop, restoration will only trigger later when you modify search parameters. This can cause saved parameters to unexpectedly appear mixed with your new changes.
+
+**Recommended**: Always use the `search` prop to be explicit about restoration intent.
+
+## Try It
+
+1. Navigate to `/users` and search for a name
+2. Navigate to `/products` and set some filters
+3. Use the test links on the homepage to see both restoration patterns!
+
+## Running the Example
+
+```bash
+pnpm install
+pnpm dev
+```
+
+Navigate between Users and Products routes to see automatic search parameter persistence in action.
\ No newline at end of file
diff --git a/examples/react/search-persistence/index.html b/examples/react/search-persistence/index.html
new file mode 100644
index 00000000000..9b6335c0ac1
--- /dev/null
+++ b/examples/react/search-persistence/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Vite App
+
+
+
+
+
+
diff --git a/examples/react/search-persistence/package.json b/examples/react/search-persistence/package.json
new file mode 100644
index 00000000000..e69517a1bed
--- /dev/null
+++ b/examples/react/search-persistence/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "tanstack-router-react-example-basic-file-based",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port 3000",
+ "build": "vite build && tsc --noEmit",
+ "serve": "vite preview",
+ "start": "vite"
+ },
+ "dependencies": {
+ "@tanstack/react-router": "workspace:*",
+ "@tanstack/react-router-devtools": "workspace:*",
+ "@tanstack/react-store": "^0.7.0",
+ "@tanstack/router-plugin": "workspace:*",
+ "postcss": "^8.5.1",
+ "autoprefixer": "^10.4.20",
+ "tailwindcss": "^3.4.17",
+ "zod": "^3.24.2"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.3.4"
+ }
+}
diff --git a/examples/react/search-persistence/postcss.config.mjs b/examples/react/search-persistence/postcss.config.mjs
new file mode 100644
index 00000000000..2e7af2b7f1a
--- /dev/null
+++ b/examples/react/search-persistence/postcss.config.mjs
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/examples/react/search-persistence/src/main.tsx b/examples/react/search-persistence/src/main.tsx
new file mode 100644
index 00000000000..c211466d131
--- /dev/null
+++ b/examples/react/search-persistence/src/main.tsx
@@ -0,0 +1,29 @@
+import { StrictMode } from 'react'
+import ReactDOM from 'react-dom/client'
+import { RouterProvider, createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+import { setupLocalStorageSync } from './utils/localStorage-sync'
+import './styles.css'
+
+// Setup localStorage sync for search persistence (optional)
+if (typeof window !== 'undefined') {
+ setupLocalStorageSync()
+}
+
+const router = createRouter({ routeTree })
+
+declare module '@tanstack/react-router' {
+ interface Register {
+ router: typeof router
+ }
+}
+
+const rootElement = document.getElementById('app')
+if (rootElement && !rootElement.innerHTML) {
+ const root = ReactDOM.createRoot(rootElement)
+ root.render(
+
+
+ ,
+ )
+}
diff --git a/examples/react/search-persistence/src/routeTree.gen.ts b/examples/react/search-persistence/src/routeTree.gen.ts
new file mode 100644
index 00000000000..08d6c7fc112
--- /dev/null
+++ b/examples/react/search-persistence/src/routeTree.gen.ts
@@ -0,0 +1,95 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as UsersRouteImport } from './routes/users'
+import { Route as ProductsRouteImport } from './routes/products'
+import { Route as IndexRouteImport } from './routes/index'
+
+const UsersRoute = UsersRouteImport.update({
+ id: '/users',
+ path: '/users',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ProductsRoute = ProductsRouteImport.update({
+ id: '/products',
+ path: '/products',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/products': typeof ProductsRoute
+ '/users': typeof UsersRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/products': typeof ProductsRoute
+ '/users': typeof UsersRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/products': typeof ProductsRoute
+ '/users': typeof UsersRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/products' | '/users'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/products' | '/users'
+ id: '__root__' | '/' | '/products' | '/users'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ ProductsRoute: typeof ProductsRoute
+ UsersRoute: typeof UsersRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/users': {
+ id: '/users'
+ path: '/users'
+ fullPath: '/users'
+ preLoaderRoute: typeof UsersRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/products': {
+ id: '/products'
+ path: '/products'
+ fullPath: '/products'
+ preLoaderRoute: typeof ProductsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ ProductsRoute: ProductsRoute,
+ UsersRoute: UsersRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
diff --git a/examples/react/search-persistence/src/routes/__root.tsx b/examples/react/search-persistence/src/routes/__root.tsx
new file mode 100644
index 00000000000..59ad074c995
--- /dev/null
+++ b/examples/react/search-persistence/src/routes/__root.tsx
@@ -0,0 +1,46 @@
+import { Link, Outlet, createRootRoute } from '@tanstack/react-router'
+import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
+
+
+export const Route = createRootRoute({
+ component: RootComponent,
+})
+
+function RootComponent() {
+ return (
+ <>
+
+
+ Home
+ {' '}
+ prev}
+ activeProps={{
+ className: 'font-bold',
+ }}
+ >
+ Users
+ {' '}
+
+ Products
+
+
+
+
+
+ >
+ )
+}
diff --git a/examples/react/search-persistence/src/routes/index.tsx b/examples/react/search-persistence/src/routes/index.tsx
new file mode 100644
index 00000000000..8772d51c3bd
--- /dev/null
+++ b/examples/react/search-persistence/src/routes/index.tsx
@@ -0,0 +1,70 @@
+import { createFileRoute, Link } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/')({
+ component: HomeComponent,
+})
+
+function HomeComponent() {
+ return (
+
+
Search Persistence Middleware Demo
+
Navigate to Users or Products and filter some data, then navigate back to see persistence in action!
+
+
+
🧪 Restoration Patterns
+
+
Full restoration:
+
+ prev}
+ className="inline-block bg-purple-500 text-white px-3 py-1 rounded text-sm"
+ >
+ Users (restore all)
+
+ prev}
+ className="inline-block bg-orange-500 text-white px-3 py-1 rounded text-sm"
+ >
+ Products (restore all)
+
+
+
+
Partial override:
+
+ ({ ...prev, category: 'Electronics' })}
+ className="inline-block bg-yellow-600 text-white px-3 py-1 rounded text-sm"
+ >
+ Products (Electronics)
+
+ ({ ...prev, category: 'Books' })}
+ className="inline-block bg-yellow-700 text-white px-3 py-1 rounded text-sm"
+ >
+ Products (Books)
+
+
+
+
Clean navigation (no restoration):
+
+
+ Users (clean)
+
+
+ Products (clean)
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/examples/react/search-persistence/src/routes/products.tsx b/examples/react/search-persistence/src/routes/products.tsx
new file mode 100644
index 00000000000..69fd93e76ff
--- /dev/null
+++ b/examples/react/search-persistence/src/routes/products.tsx
@@ -0,0 +1,134 @@
+import { createFileRoute, useNavigate, persistSearchParams, } from '@tanstack/react-router'
+import { z } from 'zod'
+import React from 'react'
+
+const productsSearchSchema = z.object({
+ category: z.string().optional().catch(''),
+ minPrice: z.number().optional().catch(0),
+ maxPrice: z.number().optional().catch(1000),
+ sortBy: z.enum(['name', 'price', 'rating']).optional().catch('name'),
+})
+
+export type ProductsSearchSchema = z.infer
+
+export const Route = createFileRoute('/products')({
+ validateSearch: productsSearchSchema,
+ search: {
+ middlewares: [
+ persistSearchParams(['sortBy']), // Exclude 'sortBy' from persistence - fully typed!
+ ],
+ },
+ component: ProductsComponent,
+})
+
+const mockProducts = [
+ { id: 1, name: 'Laptop', category: 'Electronics', price: 999, rating: 4.5 },
+ { id: 2, name: 'Chair', category: 'Home', price: 199, rating: 4.2 },
+ { id: 3, name: 'Phone', category: 'Electronics', price: 699, rating: 4.7 },
+ { id: 4, name: 'Desk', category: 'Home', price: 299, rating: 4.0 },
+ { id: 5, name: 'Tablet', category: 'Electronics', price: 399, rating: 4.3 },
+ { id: 6, name: 'Lamp', category: 'Home', price: 79, rating: 4.1 },
+]
+
+function ProductsComponent() {
+ const search = Route.useSearch()
+ const navigate = useNavigate()
+
+ const filteredProducts = React.useMemo(() => {
+ let products = [...mockProducts]
+
+ if (search.category) {
+ products = products.filter(product => product.category === search.category)
+ }
+
+ products = products.filter(product =>
+ product.price >= (search.minPrice ?? 0) && product.price <= (search.maxPrice ?? 1000)
+ )
+
+ products = products.sort((a, b) => {
+ if (search.sortBy === 'name') return a.name.localeCompare(b.name)
+ if (search.sortBy === 'price') return a.price - b.price
+ if (search.sortBy === 'rating') return b.rating - a.rating
+ return 0
+ })
+
+ return products
+ }, [search.category, search.minPrice, search.maxPrice, search.sortBy])
+
+ const updateSearch = (updates: Partial) => {
+ navigate({
+ search: (prev: ProductsSearchSchema) => ({ ...prev, ...updates }),
+ } as any)
+ }
+
+ return (
+
+
Products
+
Advanced filtering with excluded parameters (sortBy won't persist)
+
+
+
updateSearch({ category: e.target.value })}
+ className="border p-2 rounded"
+ >
+ All Categories
+ Electronics
+ Home
+
+
+
+ updateSearch({ minPrice: Number(e.target.value) })}
+ className="border"
+ />
+ Min: ${search.minPrice ?? 0}
+
+
+
+ updateSearch({ maxPrice: Number(e.target.value) })}
+ className="border"
+ />
+ Max: ${search.maxPrice ?? 1000}
+
+
+
updateSearch({ sortBy: e.target.value as any })}
+ className="border p-2 rounded"
+ >
+ Sort by Name
+ Sort by Price
+ Sort by Rating
+
+
+
navigate({ search: {} } as any)}
+ className="border p-2 rounded"
+ >
+ Reset
+
+
+
+
+ {filteredProducts.map((product) => (
+
+
{product.name}
+
{product.category}
+
${product.price} - ⭐ {product.rating}
+
+ ))}
+
+
+ )
+}
\ No newline at end of file
diff --git a/examples/react/search-persistence/src/routes/users.tsx b/examples/react/search-persistence/src/routes/users.tsx
new file mode 100644
index 00000000000..5e6045a9f22
--- /dev/null
+++ b/examples/react/search-persistence/src/routes/users.tsx
@@ -0,0 +1,109 @@
+import { createFileRoute, useNavigate, persistSearchParams } from '@tanstack/react-router'
+import { z } from 'zod'
+import React from 'react'
+
+const usersSearchSchema = z.object({
+ name: z.string().optional().catch(''),
+ status: z.enum(['active', 'inactive', 'all']).optional().catch('all'),
+ page: z.number().optional().catch(0),
+ limit: z.number().optional().catch(10),
+})
+
+export type UsersSearchSchema = z.infer
+
+export const Route = createFileRoute('/users')({
+ validateSearch: usersSearchSchema,
+ search: {
+ middlewares: [
+ persistSearchParams(),
+ ],
+ },
+ component: UsersComponent,
+})
+
+const mockUsers = [
+ { id: 1, name: 'Alice Johnson', email: 'alice@example.com', status: 'active' },
+ { id: 2, name: 'Bob Smith', email: 'bob@example.com', status: 'inactive' },
+ { id: 3, name: 'Charlie Brown', email: 'charlie@example.com', status: 'active' },
+ { id: 4, name: 'Diana Ross', email: 'diana@example.com', status: 'active' },
+ { id: 5, name: 'Edward Norton', email: 'edward@example.com', status: 'inactive' },
+ { id: 6, name: 'Fiona Apple', email: 'fiona@example.com', status: 'active' },
+ { id: 7, name: 'George Lucas', email: 'george@example.com', status: 'active' },
+ { id: 8, name: 'Helen Hunt', email: 'helen@example.com', status: 'inactive' },
+ { id: 9, name: 'Ian McKellen', email: 'ian@example.com', status: 'active' },
+ { id: 10, name: 'Julia Roberts', email: 'julia@example.com', status: 'active' },
+ { id: 11, name: 'Kevin Costner', email: 'kevin@example.com', status: 'inactive' },
+ { id: 12, name: 'Lisa Simpson', email: 'lisa@example.com', status: 'active' },
+]
+
+function UsersComponent() {
+ const search = Route.useSearch()
+ const navigate = useNavigate()
+
+ const filteredUsers = React.useMemo(() => {
+ let users = mockUsers
+
+ if (search.name) {
+ users = users.filter(user =>
+ user.name.toLowerCase().includes(search.name?.toLowerCase() || '')
+ )
+ }
+
+ if (search.status && search.status !== 'all') {
+ users = users.filter(user => user.status === search.status)
+ }
+
+ return users
+ }, [search.name, search.status])
+
+ const updateSearch = (updates: Partial) => {
+ navigate({
+ search: (prev: UsersSearchSchema) => ({ ...prev, ...updates, page: 0 }),
+ } as any)
+ }
+
+ return (
+
+
Users
+
Search parameters are automatically persisted when you navigate away and back
+
+
+ updateSearch({ name: e.target.value })}
+ className="border p-2 rounded"
+ />
+
+ updateSearch({ status: e.target.value as any })}
+ className="border p-2 rounded"
+ >
+ All Users
+ Active
+ Inactive
+
+
+ navigate({ search: {} } as any)}
+ className="border p-2 rounded"
+ >
+ Reset
+
+
+
+
+ {filteredUsers.map((user) => (
+
+
{user.name}
+
{user.email}
+
{user.status}
+
+ ))}
+
+
+ )
+}
\ No newline at end of file
diff --git a/examples/react/search-persistence/src/styles.css b/examples/react/search-persistence/src/styles.css
new file mode 100644
index 00000000000..90ad286005f
--- /dev/null
+++ b/examples/react/search-persistence/src/styles.css
@@ -0,0 +1,13 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html {
+ color-scheme: light dark;
+}
+* {
+ @apply border-gray-200 dark:border-gray-800;
+}
+body {
+ @apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200;
+}
\ No newline at end of file
diff --git a/examples/react/search-persistence/src/type-inference-test.ts b/examples/react/search-persistence/src/type-inference-test.ts
new file mode 100644
index 00000000000..59b65c29c1e
--- /dev/null
+++ b/examples/react/search-persistence/src/type-inference-test.ts
@@ -0,0 +1,28 @@
+// Test file to verify type inference is working with existing TanStack Router types
+import { getSearchPersistenceStore } from '@tanstack/react-router'
+
+// ✨ CLEAN API: 100% typed by default! ✨
+const store = getSearchPersistenceStore()
+
+// Test 1: Store state is automatically 100% typed
+const state = store.state // 🎉 Automatically typed: {'/users': UsersSchema, '/products': ProductsSchema, ...}
+
+// Test 2: Store for useStore hook is automatically 100% typed
+const storeForUseStore = store.store // 🎉 Automatically typed: Store<{mapped route schemas}>
+
+// Test 3: All methods are automatically 100% typed
+const usersSearch = store.getSearch('/users') // 🎉 Automatically infers Users route search schema
+const productsSearch = store.getSearch('/products') // 🎉 Automatically infers Products route search schema
+const homeSearch = store.getSearch('/') // 🎉 Automatically infers home route search schema
+
+// Test 4: saveSearch automatically enforces proper route-specific search schemas
+store.saveSearch('/users', { name: 'Alice', page: 0 }) // 🎉 Fully typed, no manual annotations needed
+store.saveSearch('/products', { category: 'Electronics', minPrice: 100 }) // 🎉 Fully typed
+
+// Test 5: Other methods are also perfectly typed
+store.clearSearch('/users') // 🎉 Route ID is typed
+store.subscribe(() => {}) // 🎉 Works perfectly
+
+// 🎉 Perfect! Clean API with 100% type inference by default!
+
+export { store, state, storeForUseStore, usersSearch, productsSearch, homeSearch }
diff --git a/examples/react/search-persistence/src/utils/localStorage-sync.ts b/examples/react/search-persistence/src/utils/localStorage-sync.ts
new file mode 100644
index 00000000000..3ce8dfdfdb0
--- /dev/null
+++ b/examples/react/search-persistence/src/utils/localStorage-sync.ts
@@ -0,0 +1,29 @@
+import { getSearchPersistenceStore } from '@tanstack/react-router'
+
+const STORAGE_KEY = 'search-persistence'
+
+export function setupLocalStorageSync() {
+ const store = getSearchPersistenceStore()
+
+ // Restore from localStorage on initialization
+ const stored = localStorage.getItem(STORAGE_KEY)
+ if (stored) {
+ try {
+ const parsedState = JSON.parse(stored)
+ Object.entries(parsedState).forEach(([routeId, search]) => {
+ store.saveSearch(routeId as any, search as Record)
+ })
+ } catch (error) {
+ console.warn('Failed to restore search persistence from localStorage:', error)
+ }
+ }
+
+ // Subscribe to changes and sync to localStorage
+ return store.subscribe(() => {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(store.state))
+ } catch (error) {
+ console.warn('Failed to sync search persistence to localStorage:', error)
+ }
+ })
+}
diff --git a/examples/react/search-persistence/tailwind.config.mjs b/examples/react/search-persistence/tailwind.config.mjs
new file mode 100644
index 00000000000..4986094b9d5
--- /dev/null
+++ b/examples/react/search-persistence/tailwind.config.mjs
@@ -0,0 +1,4 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'],
+}
diff --git a/examples/react/search-persistence/tsconfig.json b/examples/react/search-persistence/tsconfig.json
new file mode 100644
index 00000000000..93048aa449f
--- /dev/null
+++ b/examples/react/search-persistence/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "skipLibCheck": true
+ }
+}
diff --git a/examples/react/search-persistence/vite.config.js b/examples/react/search-persistence/vite.config.js
new file mode 100644
index 00000000000..47e327b7462
--- /dev/null
+++ b/examples/react/search-persistence/vite.config.js
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import { tanstackRouter } from '@tanstack/router-plugin/vite'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [
+ tanstackRouter({
+ target: 'react',
+ autoCodeSplitting: true,
+ }),
+ react(),
+ ],
+})
diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx
index ed783173156..0b15d635c0d 100644
--- a/packages/react-router/src/index.tsx
+++ b/packages/react-router/src/index.tsx
@@ -32,8 +32,12 @@ export {
createControlledPromise,
retainSearchParams,
stripSearchParams,
+ persistSearchParams,
+ getSearchPersistenceStore,
} from '@tanstack/router-core'
+
+
export type {
AnyRoute,
DeferredPromiseState,
@@ -72,6 +76,7 @@ export type {
TrimPathRight,
StringifyParamsFn,
ParamsOptions,
+
InferAllParams,
InferAllContext,
LooseReturnType,
diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts
index ca34da17222..af4e51258a7 100644
--- a/packages/router-core/src/index.ts
+++ b/packages/router-core/src/index.ts
@@ -254,7 +254,12 @@ export type {
BuildLocationFn,
} from './RouterProvider'
-export { retainSearchParams, stripSearchParams } from './searchMiddleware'
+export {
+ 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 bdda0408454..15d5560d1fe 100644
--- a/packages/router-core/src/route.ts
+++ b/packages/router-core/src/route.ts
@@ -74,6 +74,7 @@ export type SearchFilter = (prev: TInput) => TResult
export type SearchMiddlewareContext = {
search: TSearchSchema
next: (newSearch: TSearchSchema) => TSearchSchema
+ route: { id: string; fullPath: string }
}
export type SearchMiddleware = (
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 6a132b57831..79d7ff03159 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -1337,20 +1337,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 +1452,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 +1474,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 +1488,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))
}
@@ -1518,7 +1518,7 @@ export class RouterCore<
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,8 +1527,8 @@ 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
}
}
})
@@ -1598,9 +1598,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 +2279,8 @@ export class RouterCore<
this.basepath,
baseLocation.pathname,
{
- ...opts,
- to: next.pathname,
+ ...opts,
+ to: next.pathname,
},
this.parsePathnameCache,
) as any
@@ -2635,10 +2635,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 +2668,7 @@ export function getMatchedRoutes({
}
} else {
foundRoute = route
- routeParams = matchedParams
+ routeParams = matchedParams
break
}
}
@@ -2704,87 +2704,101 @@ function applySearchMiddleware({
destRoutes: Array
_includeValidateSearch: boolean | undefined
}) {
- const allMiddlewares =
+ const allMiddlewares: Array<{
+ middleware: SearchMiddleware
+ route: { id: string; fullPath: string }
+ }> =
destRoutes.reduce(
- (acc, route) => {
- const middlewares: Array> = []
+ (acc, route) => {
+ const middlewares: Array> = []
- if ('search' in route.options) {
- if (route.options.search?.middlewares) {
- middlewares.push(...route.options.search.middlewares)
- }
+ if ('search' in route.options) {
+ if (route.options.search?.middlewares) {
+ middlewares.push(...route.options.search.middlewares)
}
- // 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: SearchMiddleware = ({
+ search,
+ next,
+ }) => {
+ 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
}
+ middlewares.push(legacyMiddleware)
+ }
- if (_includeValidateSearch && route.options.validateSearch) {
- const validate: SearchMiddleware = ({ search, next }) => {
- const result = next(search)
- try {
- const validatedSearch = {
- ...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
}
+ return validatedSearch
+ } catch {
+ // ignore errors here because they are already handled in matchRoutes
+ return result
}
-
- middlewares.push(validate)
}
- return acc.concat(middlewares)
+ middlewares.push(validate)
+ }
+
+ return acc.concat(
+ middlewares.map((middleware) => ({
+ middleware,
+ route: { id: route.id, fullPath: route.fullPath },
+ }))
+ )
},
- [] as Array>,
+ [] as Array<{
+ middleware: SearchMiddleware
+ route: { id: string; fullPath: string }
+ }>,
) ?? []
// 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)
+ const final = {
+ middleware: ({ search }: { search: any }) => {
+ if (!dest.search) {
+ return {}
+ }
+ if (dest.search === true) {
+ return search
+ }
+ return functionalUpdate(dest.search, search)
+ },
+ route: { id: '', fullPath: '' },
}
allMiddlewares.push(final)
@@ -2795,13 +2809,17 @@ 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 })
+ return middleware({
+ search: currentSearch,
+ next,
+ route: { id: route.id, fullPath: route.fullPath }
+ })
}
// Start applying middlewares
diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts
index 03ebbe213f5..546f1bfdb1f 100644
--- a/packages/router-core/src/searchMiddleware.ts
+++ b/packages/router-core/src/searchMiddleware.ts
@@ -1,7 +1,10 @@
-import { deepEqual } from './utils'
+import { Store } from '@tanstack/store'
+import { deepEqual, replaceEqualDeep } from './utils'
import type { NoInfer, PickOptional } from './utils'
-import type { SearchMiddleware } from './route'
+import type { SearchMiddleware, AnyRoute } from './route'
import type { IsRequiredParams } from './link'
+import type { RoutesById, RouteById } from './routeInfo'
+import type { RegisteredRouter } from './router'
export function retainSearchParams(
keys: Array | true,
@@ -33,7 +36,7 @@ export function stripSearchParams<
>(input: NoInfer): SearchMiddleware {
return ({ search, next }) => {
if (input === true) {
- return {}
+ return {} as TSearchSchema
}
const result = next(search) as Record
if (Array.isArray(input)) {
@@ -49,6 +52,191 @@ export function stripSearchParams<
},
)
}
- return result as any
+ return result as TSearchSchema
+ }
+}
+
+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['types']['fullSearchSchema']
+ }> {
+ return this.__store as Store<{
+ [K in keyof RoutesById]: RouteById['types']['fullSearchSchema']
+ }>
+ }
+
+ getTypedState<
+ TRouteTree extends AnyRoute = RegisteredRouter['routeTree']
+ >(): {
+ [K in keyof RoutesById]: RouteById['types']['fullSearchSchema']
+ } {
+ return this.__store.state as {
+ [K in keyof RoutesById]: RouteById['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' && value !== null && Object.keys(value).length === 0) return false
+ return true
+ }),
+ )
+
+ this.__store.setState((prevState) => {
+ if (Object.keys(cleanedSearch).length === 0) {
+ const { [routeId]: _, ...rest } = prevState
+ return rest
+ }
+
+ return 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['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(() => ({}))
+ }
+}
+
+const searchPersistenceStore = new SearchPersistenceStore()
+
+// Clean API: Get the properly typed store instance
+export function getSearchPersistenceStore<
+ TRouteTree extends AnyRoute = RegisteredRouter['routeTree']
+>(): {
+ state: {
+ [K in keyof RoutesById]: RouteById['types']['fullSearchSchema']
+ }
+ store: Store<{
+ [K in keyof RoutesById]: RouteById['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 searchPersistenceStore.getTypedState()
+ },
+ get store() {
+ return searchPersistenceStore.getTypedStore()
+ },
+ subscribe: (listener: () => void) => searchPersistenceStore.subscribe(listener),
+ getSearch: >(routeId: TRouteId) =>
+ searchPersistenceStore.getSearch(routeId),
+ saveSearch: >(
+ routeId: TRouteId,
+ search: RouteById['types']['fullSearchSchema'],
+ ) => searchPersistenceStore.saveSearch(routeId, search),
+ clearSearch: >(routeId: TRouteId) =>
+ searchPersistenceStore.clearSearch(routeId),
+ clearAllSearches: () => searchPersistenceStore.clearAllSearches(),
+ }
+}
+
+export function persistSearchParams(
+ exclude?: Array,
+): SearchMiddleware {
+ return ({ search, next, route }) => {
+ // Check if we should restore from store (when search is empty - initial navigation)
+ const savedSearch = searchPersistenceStore.getSearch(route.id)
+ let searchToProcess = search
+
+ // If search is empty and we have saved search, restore it
+ if (savedSearch && Object.keys(savedSearch).length > 0) {
+ const currentSearch = search as Record
+ const shouldRestore = Object.keys(currentSearch).length === 0 ||
+ Object.values(currentSearch).every(value => {
+ if (value === null || value === undefined || value === '') return true
+ if (Array.isArray(value) && value.length === 0) return true
+ if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) return true
+ return false
+ })
+
+ if (shouldRestore) {
+ searchToProcess = { ...search, ...savedSearch } as TSearchSchema
+ }
+ }
+
+ // Process through validation and other middleware
+ const result = next(searchToProcess)
+
+ // Save the result for future restoration (but only if it's not empty)
+ const resultRecord = result as Record
+ if (Object.keys(resultRecord).length > 0) {
+ // Filter out excluded keys in middleware before saving
+ const excludeKeys = exclude ? exclude.map(key => String(key)) : []
+ const filteredResult = Object.fromEntries(
+ Object.entries(resultRecord)
+ .filter(([key]) => !excludeKeys.includes(key))
+ )
+
+ searchPersistenceStore.saveSearch(route.id, filteredResult)
+ }
+
+ return result
}
}
From 41d598fca19aec9b9908e3eb112434e3d36c7a88 Mon Sep 17 00:00:00 2001
From: Nitsan Cohen <77798308+NitsanCohen770@users.noreply.github.com>
Date: Wed, 20 Aug 2025 22:02:20 +0300
Subject: [PATCH 2/8] style: format code with prettier and fix eslint issues
- Run prettier:write to format all files according to project standards
- Fix ESLint import sorting and method signature style issues
- All files now pass prettier and eslint checks
---
.../api/router/persistSearchParamsFunction.md | 30 ++--
examples/react/search-persistence/README.md | 12 +-
.../search-persistence/src/routes/__root.tsx | 1 -
.../search-persistence/src/routes/index.tsx | 15 +-
.../src/routes/products.tsx | 32 ++--
.../search-persistence/src/routes/users.tsx | 71 ++++++--
.../react/search-persistence/src/styles.css | 2 +-
.../src/type-inference-test.ts | 27 +--
.../src/utils/localStorage-sync.ts | 7 +-
packages/react-router/src/index.tsx | 3 -
packages/router-core/src/router.ts | 166 +++++++++---------
packages/router-core/src/searchMiddleware.ts | 137 +++++++++------
12 files changed, 302 insertions(+), 201 deletions(-)
diff --git a/docs/router/framework/react/api/router/persistSearchParamsFunction.md b/docs/router/framework/react/api/router/persistSearchParamsFunction.md
index 00aaa281b76..cfa832a4881 100644
--- a/docs/router/framework/react/api/router/persistSearchParamsFunction.md
+++ b/docs/router/framework/react/api/router/persistSearchParamsFunction.md
@@ -108,16 +108,17 @@ function Navigation() {
prev}>
Users
-
+
{/* Partial override - restore saved params but override specific ones */}
- ({ ...prev, category: 'Electronics' })}>
+ ({ ...prev, category: 'Electronics' })}
+ >
Electronics Products
-
+
{/* Clean navigation - no restoration */}
-
- Users (clean slate)
-
+ Users (clean slate)
)
}
@@ -128,18 +129,23 @@ function Navigation() {
You have two ways to exclude parameters from persistence:
**1. Middleware-level exclusion** (permanent):
+
```tsx
// These parameters are never saved
middlewares: [persistSearchParams(['tempFilter', 'sortBy'])]
```
**2. Link-level exclusion** (per navigation):
+
```tsx
// Restore saved params but exclude specific ones
- {
- const { tempFilter, ...rest } = prev || {}
- return rest
-}}>
+ {
+ const { tempFilter, ...rest } = prev || {}
+ return rest
+ }}
+>
Products (excluding temp filter)
```
@@ -154,7 +160,7 @@ import { getSearchPersistenceStore, Link } from '@tanstack/react-router'
function CustomNavigation() {
const store = getSearchPersistenceStore()
const savedUsersSearch = store.getSearch('/users')
-
+
return (
Users (with saved search)
@@ -206,4 +212,4 @@ function MyComponent() {
)
}
-```
\ No newline at end of file
+```
diff --git a/examples/react/search-persistence/README.md b/examples/react/search-persistence/README.md
index f6e6ab0b315..531e5b1399f 100644
--- a/examples/react/search-persistence/README.md
+++ b/examples/react/search-persistence/README.md
@@ -9,7 +9,7 @@ The `persistSearchParams` middleware provides seamless search parameter persiste
## Key Features
- **Automatic Persistence**: Search parameters are saved/restored automatically
-- **Selective Exclusion**: Choose which parameters to exclude from persistence
+- **Selective Exclusion**: Choose which parameters to exclude from persistence
- **Type Safety**: Full TypeScript support with automatic type inference
- **Manual Control**: Direct store access for advanced use cases
@@ -40,6 +40,7 @@ export const Route = createFileRoute('/products')({
⚠️ **Important**: The middleware only runs when search parameters are being processed. Always be explicit about your restoration intent.
### Automatic Restoration
+
```tsx
import { Link } from '@tanstack/react-router'
@@ -48,7 +49,7 @@ import { Link } from '@tanstack/react-router'
Users (restore all)
-// Partial override - restore but override specific parameters
+// Partial override - restore but override specific parameters
({ ...prev, category: 'Electronics' })}>
Electronics Products
@@ -59,7 +60,8 @@ import { Link } from '@tanstack/react-router'
```
-### Manual Restoration
+### Manual Restoration
+
Access the store directly for full control:
```tsx
@@ -82,7 +84,7 @@ If you use the persistence middleware but navigate without the `search` prop, re
## Try It
1. Navigate to `/users` and search for a name
-2. Navigate to `/products` and set some filters
+2. Navigate to `/products` and set some filters
3. Use the test links on the homepage to see both restoration patterns!
## Running the Example
@@ -92,4 +94,4 @@ pnpm install
pnpm dev
```
-Navigate between Users and Products routes to see automatic search parameter persistence in action.
\ No newline at end of file
+Navigate between Users and Products routes to see automatic search parameter persistence in action.
diff --git a/examples/react/search-persistence/src/routes/__root.tsx b/examples/react/search-persistence/src/routes/__root.tsx
index 59ad074c995..9a563b79acf 100644
--- a/examples/react/search-persistence/src/routes/__root.tsx
+++ b/examples/react/search-persistence/src/routes/__root.tsx
@@ -1,7 +1,6 @@
import { Link, Outlet, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
-
export const Route = createRootRoute({
component: RootComponent,
})
diff --git a/examples/react/search-persistence/src/routes/index.tsx b/examples/react/search-persistence/src/routes/index.tsx
index 8772d51c3bd..faddbd9858b 100644
--- a/examples/react/search-persistence/src/routes/index.tsx
+++ b/examples/react/search-persistence/src/routes/index.tsx
@@ -8,11 +8,14 @@ function HomeComponent() {
return (
Search Persistence Middleware Demo
-
Navigate to Users or Products and filter some data, then navigate back to see persistence in action!
-
+
+ Navigate to Users or Products and filter some data, then navigate back
+ to see persistence in action!
+
+
🧪 Restoration Patterns
-
+
Full restoration:
-
+
Partial override:
-
+
Clean navigation (no restoration):
)
-}
\ No newline at end of file
+}
diff --git a/examples/react/search-persistence/src/routes/products.tsx b/examples/react/search-persistence/src/routes/products.tsx
index 69fd93e76ff..4cdfde5af1d 100644
--- a/examples/react/search-persistence/src/routes/products.tsx
+++ b/examples/react/search-persistence/src/routes/products.tsx
@@ -1,4 +1,8 @@
-import { createFileRoute, useNavigate, persistSearchParams, } from '@tanstack/react-router'
+import {
+ createFileRoute,
+ useNavigate,
+ persistSearchParams,
+} from '@tanstack/react-router'
import { z } from 'zod'
import React from 'react'
@@ -38,11 +42,15 @@ function ProductsComponent() {
let products = [...mockProducts]
if (search.category) {
- products = products.filter(product => product.category === search.category)
+ products = products.filter(
+ (product) => product.category === search.category,
+ )
}
- products = products.filter(product =>
- product.price >= (search.minPrice ?? 0) && product.price <= (search.maxPrice ?? 1000)
+ products = products.filter(
+ (product) =>
+ product.price >= (search.minPrice ?? 0) &&
+ product.price <= (search.maxPrice ?? 1000),
)
products = products.sort((a, b) => {
@@ -65,7 +73,7 @@ function ProductsComponent() {
Products
Advanced filtering with excluded parameters (sortBy won't persist)
-
+
Electronics
Home
-
+
Min: ${search.minPrice ?? 0}
-
+
Max: ${search.maxPrice ?? 1000}
-
+
updateSearch({ sortBy: e.target.value as any })}
@@ -110,7 +118,7 @@ function ProductsComponent() {
Sort by Price
Sort by Rating
-
+
navigate({ search: {} } as any)}
@@ -125,10 +133,12 @@ function ProductsComponent() {
{product.name}
{product.category}
-
${product.price} - ⭐ {product.rating}
+
+ ${product.price} - ⭐ {product.rating}
+
))}
)
-}
\ No newline at end of file
+}
diff --git a/examples/react/search-persistence/src/routes/users.tsx b/examples/react/search-persistence/src/routes/users.tsx
index 5e6045a9f22..befccdd6f6e 100644
--- a/examples/react/search-persistence/src/routes/users.tsx
+++ b/examples/react/search-persistence/src/routes/users.tsx
@@ -1,4 +1,8 @@
-import { createFileRoute, useNavigate, persistSearchParams } from '@tanstack/react-router'
+import {
+ createFileRoute,
+ useNavigate,
+ persistSearchParams,
+} from '@tanstack/react-router'
import { z } from 'zod'
import React from 'react'
@@ -14,25 +18,53 @@ export type UsersSearchSchema = z.infer
export const Route = createFileRoute('/users')({
validateSearch: usersSearchSchema,
search: {
- middlewares: [
- persistSearchParams(),
- ],
+ middlewares: [persistSearchParams()],
},
component: UsersComponent,
})
const mockUsers = [
- { id: 1, name: 'Alice Johnson', email: 'alice@example.com', status: 'active' },
+ {
+ id: 1,
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ status: 'active',
+ },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', status: 'inactive' },
- { id: 3, name: 'Charlie Brown', email: 'charlie@example.com', status: 'active' },
+ {
+ id: 3,
+ name: 'Charlie Brown',
+ email: 'charlie@example.com',
+ status: 'active',
+ },
{ id: 4, name: 'Diana Ross', email: 'diana@example.com', status: 'active' },
- { id: 5, name: 'Edward Norton', email: 'edward@example.com', status: 'inactive' },
+ {
+ id: 5,
+ name: 'Edward Norton',
+ email: 'edward@example.com',
+ status: 'inactive',
+ },
{ id: 6, name: 'Fiona Apple', email: 'fiona@example.com', status: 'active' },
- { id: 7, name: 'George Lucas', email: 'george@example.com', status: 'active' },
+ {
+ id: 7,
+ name: 'George Lucas',
+ email: 'george@example.com',
+ status: 'active',
+ },
{ id: 8, name: 'Helen Hunt', email: 'helen@example.com', status: 'inactive' },
{ id: 9, name: 'Ian McKellen', email: 'ian@example.com', status: 'active' },
- { id: 10, name: 'Julia Roberts', email: 'julia@example.com', status: 'active' },
- { id: 11, name: 'Kevin Costner', email: 'kevin@example.com', status: 'inactive' },
+ {
+ id: 10,
+ name: 'Julia Roberts',
+ email: 'julia@example.com',
+ status: 'active',
+ },
+ {
+ id: 11,
+ name: 'Kevin Costner',
+ email: 'kevin@example.com',
+ status: 'inactive',
+ },
{ id: 12, name: 'Lisa Simpson', email: 'lisa@example.com', status: 'active' },
]
@@ -44,13 +76,13 @@ function UsersComponent() {
let users = mockUsers
if (search.name) {
- users = users.filter(user =>
- user.name.toLowerCase().includes(search.name?.toLowerCase() || '')
+ users = users.filter((user) =>
+ user.name.toLowerCase().includes(search.name?.toLowerCase() || ''),
)
}
if (search.status && search.status !== 'all') {
- users = users.filter(user => user.status === search.status)
+ users = users.filter((user) => user.status === search.status)
}
return users
@@ -65,8 +97,11 @@ function UsersComponent() {
return (
Users
-
Search parameters are automatically persisted when you navigate away and back
-
+
+ Search parameters are automatically persisted when you navigate away and
+ back
+
+
updateSearch({ name: e.target.value })}
className="border p-2 rounded"
/>
-
+
updateSearch({ status: e.target.value as any })}
@@ -85,7 +120,7 @@ function UsersComponent() {
Active
Inactive
-
+
navigate({ search: {} } as any)}
@@ -106,4 +141,4 @@ function UsersComponent() {
)
-}
\ No newline at end of file
+}
diff --git a/examples/react/search-persistence/src/styles.css b/examples/react/search-persistence/src/styles.css
index 90ad286005f..0b8e317099c 100644
--- a/examples/react/search-persistence/src/styles.css
+++ b/examples/react/search-persistence/src/styles.css
@@ -10,4 +10,4 @@ html {
}
body {
@apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200;
-}
\ No newline at end of file
+}
diff --git a/examples/react/search-persistence/src/type-inference-test.ts b/examples/react/search-persistence/src/type-inference-test.ts
index 59b65c29c1e..df19b7bc3a4 100644
--- a/examples/react/search-persistence/src/type-inference-test.ts
+++ b/examples/react/search-persistence/src/type-inference-test.ts
@@ -5,24 +5,31 @@ import { getSearchPersistenceStore } from '@tanstack/react-router'
const store = getSearchPersistenceStore()
// Test 1: Store state is automatically 100% typed
-const state = store.state // 🎉 Automatically typed: {'/users': UsersSchema, '/products': ProductsSchema, ...}
+const state = store.state // 🎉 Automatically typed: {'/users': UsersSchema, '/products': ProductsSchema, ...}
// Test 2: Store for useStore hook is automatically 100% typed
-const storeForUseStore = store.store // 🎉 Automatically typed: Store<{mapped route schemas}>
+const storeForUseStore = store.store // 🎉 Automatically typed: Store<{mapped route schemas}>
// Test 3: All methods are automatically 100% typed
-const usersSearch = store.getSearch('/users') // 🎉 Automatically infers Users route search schema
-const productsSearch = store.getSearch('/products') // 🎉 Automatically infers Products route search schema
-const homeSearch = store.getSearch('/') // 🎉 Automatically infers home route search schema
+const usersSearch = store.getSearch('/users') // 🎉 Automatically infers Users route search schema
+const productsSearch = store.getSearch('/products') // 🎉 Automatically infers Products route search schema
+const homeSearch = store.getSearch('/') // 🎉 Automatically infers home route search schema
// Test 4: saveSearch automatically enforces proper route-specific search schemas
-store.saveSearch('/users', { name: 'Alice', page: 0 }) // 🎉 Fully typed, no manual annotations needed
-store.saveSearch('/products', { category: 'Electronics', minPrice: 100 }) // 🎉 Fully typed
+store.saveSearch('/users', { name: 'Alice', page: 0 }) // 🎉 Fully typed, no manual annotations needed
+store.saveSearch('/products', { category: 'Electronics', minPrice: 100 }) // 🎉 Fully typed
// Test 5: Other methods are also perfectly typed
-store.clearSearch('/users') // 🎉 Route ID is typed
-store.subscribe(() => {}) // 🎉 Works perfectly
+store.clearSearch('/users') // 🎉 Route ID is typed
+store.subscribe(() => {}) // 🎉 Works perfectly
// 🎉 Perfect! Clean API with 100% type inference by default!
-export { store, state, storeForUseStore, usersSearch, productsSearch, homeSearch }
+export {
+ store,
+ state,
+ storeForUseStore,
+ usersSearch,
+ productsSearch,
+ homeSearch,
+}
diff --git a/examples/react/search-persistence/src/utils/localStorage-sync.ts b/examples/react/search-persistence/src/utils/localStorage-sync.ts
index 3ce8dfdfdb0..3a7a9581c2d 100644
--- a/examples/react/search-persistence/src/utils/localStorage-sync.ts
+++ b/examples/react/search-persistence/src/utils/localStorage-sync.ts
@@ -4,7 +4,7 @@ const STORAGE_KEY = 'search-persistence'
export function setupLocalStorageSync() {
const store = getSearchPersistenceStore()
-
+
// Restore from localStorage on initialization
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
@@ -14,7 +14,10 @@ export function setupLocalStorageSync() {
store.saveSearch(routeId as any, search as Record)
})
} catch (error) {
- console.warn('Failed to restore search persistence from localStorage:', error)
+ console.warn(
+ 'Failed to restore search persistence from localStorage:',
+ error,
+ )
}
}
diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx
index 0b15d635c0d..ca8e02c6e24 100644
--- a/packages/react-router/src/index.tsx
+++ b/packages/react-router/src/index.tsx
@@ -36,8 +36,6 @@ export {
getSearchPersistenceStore,
} from '@tanstack/router-core'
-
-
export type {
AnyRoute,
DeferredPromiseState,
@@ -76,7 +74,6 @@ export type {
TrimPathRight,
StringifyParamsFn,
ParamsOptions,
-
InferAllParams,
InferAllContext,
LooseReturnType,
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 79d7ff03159..71078f1bf0e 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -1337,20 +1337,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 +1452,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 +1474,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 +1488,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))
}
@@ -1518,7 +1518,7 @@ export class RouterCore<
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,8 +1527,8 @@ 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
}
}
})
@@ -1598,9 +1598,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 +2279,8 @@ export class RouterCore<
this.basepath,
baseLocation.pathname,
{
- ...opts,
- to: next.pathname,
+ ...opts,
+ to: next.pathname,
},
this.parsePathnameCache,
) as any
@@ -2635,10 +2635,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 +2668,7 @@ export function getMatchedRoutes({
}
} else {
foundRoute = route
- routeParams = matchedParams
+ routeParams = matchedParams
break
}
}
@@ -2709,76 +2709,76 @@ function applySearchMiddleware({
route: { id: string; fullPath: string }
}> =
destRoutes.reduce(
- (acc, route) => {
- const middlewares: Array> = []
+ (acc, route) => {
+ const middlewares: Array> = []
- if ('search' in route.options) {
- if (route.options.search?.middlewares) {
- middlewares.push(...route.options.search.middlewares)
+ if ('search' in route.options) {
+ if (route.options.search?.middlewares) {
+ middlewares.push(...route.options.search.middlewares)
+ }
}
- }
- // TODO remove preSearchFilters and postSearchFilters in v2
- else if (
- route.options.preSearchFilters ||
- route.options.postSearchFilters
- ) {
- const legacyMiddleware: SearchMiddleware = ({
- search,
- next,
- }) => {
- let nextSearch = search
+ // 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,
+ )
+ }
- if (
- 'preSearchFilters' in route.options &&
- route.options.preSearchFilters
- ) {
- nextSearch = route.options.preSearchFilters.reduce(
- (prev, next) => next(prev),
- search,
- )
- }
+ const result = next(nextSearch)
- const result = next(nextSearch)
+ if (
+ 'postSearchFilters' in route.options &&
+ route.options.postSearchFilters
+ ) {
+ return route.options.postSearchFilters.reduce(
+ (prev, next) => next(prev),
+ result,
+ )
+ }
- if (
- 'postSearchFilters' in route.options &&
- route.options.postSearchFilters
- ) {
- return route.options.postSearchFilters.reduce(
- (prev, next) => next(prev),
- result,
- )
+ return result
}
-
- return result
+ middlewares.push(legacyMiddleware)
}
- middlewares.push(legacyMiddleware)
- }
- if (_includeValidateSearch && route.options.validateSearch) {
- const validate: SearchMiddleware = ({ search, next }) => {
- const result = next(search)
- try {
- const validatedSearch = {
- ...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
}
- return validatedSearch
- } catch {
- // ignore errors here because they are already handled in matchRoutes
- return result
}
- }
- middlewares.push(validate)
- }
+ middlewares.push(validate)
+ }
- return acc.concat(
+ return acc.concat(
middlewares.map((middleware) => ({
middleware,
route: { id: route.id, fullPath: route.fullPath },
- }))
+ })),
)
},
[] as Array<{
@@ -2818,7 +2818,7 @@ function applySearchMiddleware({
return middleware({
search: currentSearch,
next,
- route: { id: route.id, fullPath: route.fullPath }
+ route: { id: route.id, fullPath: route.fullPath },
})
}
diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts
index 546f1bfdb1f..246a1124dd8 100644
--- a/packages/router-core/src/searchMiddleware.ts
+++ b/packages/router-core/src/searchMiddleware.ts
@@ -1,9 +1,9 @@
import { Store } from '@tanstack/store'
import { deepEqual, replaceEqualDeep } from './utils'
import type { NoInfer, PickOptional } from './utils'
-import type { SearchMiddleware, AnyRoute } from './route'
+import type { AnyRoute, SearchMiddleware } from './route'
import type { IsRequiredParams } from './link'
-import type { RoutesById, RouteById } from './routeInfo'
+import type { RouteById, RoutesById } from './routeInfo'
import type { RegisteredRouter } from './router'
export function retainSearchParams(
@@ -76,41 +76,58 @@ export class SearchPersistenceStore {
}
getTypedStore<
- TRouteTree extends AnyRoute = RegisteredRouter['routeTree']
+ TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
>(): Store<{
- [K in keyof RoutesById]: RouteById['types']['fullSearchSchema']
+ [K in keyof RoutesById]: RouteById<
+ TRouteTree,
+ K
+ >['types']['fullSearchSchema']
}> {
return this.__store as Store<{
- [K in keyof RoutesById]: RouteById['types']['fullSearchSchema']
+ [K in keyof RoutesById]: RouteById<
+ TRouteTree,
+ K
+ >['types']['fullSearchSchema']
}>
}
getTypedState<
- TRouteTree extends AnyRoute = RegisteredRouter['routeTree']
+ TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
>(): {
- [K in keyof RoutesById]: RouteById['types']['fullSearchSchema']
+ [K in keyof RoutesById]: RouteById<
+ TRouteTree,
+ K
+ >['types']['fullSearchSchema']
} {
return this.__store.state as {
- [K in keyof RoutesById]: RouteById['types']['fullSearchSchema']
+ [K in keyof RoutesById]: RouteById<
+ TRouteTree,
+ K
+ >['types']['fullSearchSchema']
}
}
saveSearch<
TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
- TRouteId extends keyof RoutesById = keyof RoutesById
+ 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' && value !== null && Object.keys(value).length === 0) return false
- return true
- }),
+ 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' &&
+ value !== null &&
+ Object.keys(value).length === 0
+ )
+ return false
+ return true
+ }),
)
this.__store.setState((prevState) => {
@@ -118,7 +135,7 @@ export class SearchPersistenceStore {
const { [routeId]: _, ...rest } = prevState
return rest
}
-
+
return replaceEqualDeep(prevState, {
...prevState,
[routeId]: cleanedSearch,
@@ -128,19 +145,24 @@ export class SearchPersistenceStore {
getSearch<
TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
- TRouteId extends keyof RoutesById = keyof RoutesById
+ TRouteId extends
+ keyof RoutesById = keyof RoutesById,
>(
routeId: TRouteId,
): RouteById['types']['fullSearchSchema'] | null {
- return (this.state[routeId as string] as 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 {
+ TRouteId extends
+ keyof RoutesById = keyof RoutesById,
+ >(routeId: TRouteId): void {
this.__store.setState((prevState) => {
const { [routeId as string]: _, ...rest } = prevState
return rest
@@ -156,26 +178,32 @@ const searchPersistenceStore = new SearchPersistenceStore()
// Clean API: Get the properly typed store instance
export function getSearchPersistenceStore<
- TRouteTree extends AnyRoute = RegisteredRouter['routeTree']
+ TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
>(): {
state: {
- [K in keyof RoutesById]: RouteById['types']['fullSearchSchema']
+ [K in keyof RoutesById]: RouteById<
+ TRouteTree,
+ K
+ >['types']['fullSearchSchema']
}
store: Store<{
- [K in keyof RoutesById]: RouteById['types']['fullSearchSchema']
+ [K in keyof RoutesById]: RouteById<
+ TRouteTree,
+ K
+ >['types']['fullSearchSchema']
}>
subscribe: (listener: () => void) => () => void
- getSearch>(
+ getSearch: >(
routeId: TRouteId,
- ): RouteById['types']['fullSearchSchema'] | null
- saveSearch>(
+ ) => RouteById['types']['fullSearchSchema'] | null
+ saveSearch: >(
routeId: TRouteId,
search: RouteById['types']['fullSearchSchema'],
- ): void
- clearSearch>(
+ ) => void
+ clearSearch: >(
routeId: TRouteId,
- ): void
- clearAllSearches(): void
+ ) => void
+ clearAllSearches: () => void
} {
return {
get state() {
@@ -184,15 +212,19 @@ export function getSearchPersistenceStore<
get store() {
return searchPersistenceStore.getTypedStore()
},
- subscribe: (listener: () => void) => searchPersistenceStore.subscribe(listener),
- getSearch: >(routeId: TRouteId) =>
- searchPersistenceStore.getSearch(routeId),
+ subscribe: (listener: () => void) =>
+ searchPersistenceStore.subscribe(listener),
+ getSearch: >(
+ routeId: TRouteId,
+ ) => searchPersistenceStore.getSearch(routeId),
saveSearch: >(
routeId: TRouteId,
search: RouteById['types']['fullSearchSchema'],
- ) => searchPersistenceStore.saveSearch(routeId, search),
- clearSearch: >(routeId: TRouteId) =>
- searchPersistenceStore.clearSearch(routeId),
+ ) =>
+ searchPersistenceStore.saveSearch(routeId, search),
+ clearSearch: >(
+ routeId: TRouteId,
+ ) => searchPersistenceStore.clearSearch(routeId),
clearAllSearches: () => searchPersistenceStore.clearAllSearches(),
}
}
@@ -204,34 +236,41 @@ export function persistSearchParams(
// Check if we should restore from store (when search is empty - initial navigation)
const savedSearch = searchPersistenceStore.getSearch(route.id)
let searchToProcess = search
-
+
// If search is empty and we have saved search, restore it
if (savedSearch && Object.keys(savedSearch).length > 0) {
const currentSearch = search as Record
- const shouldRestore = Object.keys(currentSearch).length === 0 ||
- Object.values(currentSearch).every(value => {
+ const shouldRestore =
+ Object.keys(currentSearch).length === 0 ||
+ Object.values(currentSearch).every((value) => {
if (value === null || value === undefined || value === '') return true
if (Array.isArray(value) && value.length === 0) return true
- if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) return true
+ if (
+ typeof value === 'object' &&
+ value !== null &&
+ Object.keys(value).length === 0
+ )
+ return true
return false
})
-
+
if (shouldRestore) {
searchToProcess = { ...search, ...savedSearch } as TSearchSchema
}
}
-
+
// Process through validation and other middleware
const result = next(searchToProcess)
-
+
// Save the result for future restoration (but only if it's not empty)
const resultRecord = result as Record
if (Object.keys(resultRecord).length > 0) {
// Filter out excluded keys in middleware before saving
- const excludeKeys = exclude ? exclude.map(key => String(key)) : []
+ const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
const filteredResult = Object.fromEntries(
- Object.entries(resultRecord)
- .filter(([key]) => !excludeKeys.includes(key))
+ Object.entries(resultRecord).filter(
+ ([key]) => !excludeKeys.includes(key),
+ ),
)
searchPersistenceStore.saveSearch(route.id, filteredResult)
From fe839f2dade92726271ce9dfde5eb7ce1c38973e Mon Sep 17 00:00:00 2001
From: Nitsan Cohen <77798308+NitsanCohen770@users.noreply.github.com>
Date: Thu, 21 Aug 2025 01:25:01 +0300
Subject: [PATCH 3/8] fix: prevent search param contamination between routes
with explicit allowlist
---
.../react/search-persistence/src/main.tsx | 6 +-
.../search-persistence/src/routes/__root.tsx | 2 +-
.../search-persistence/src/routes/index.tsx | 158 ++++++++++++------
.../src/routes/products.tsx | 2 +-
.../search-persistence/src/routes/users.tsx | 2 +-
packages/router-core/src/searchMiddleware.ts | 72 ++++----
6 files changed, 147 insertions(+), 95 deletions(-)
diff --git a/examples/react/search-persistence/src/main.tsx b/examples/react/search-persistence/src/main.tsx
index c211466d131..de10fa4018c 100644
--- a/examples/react/search-persistence/src/main.tsx
+++ b/examples/react/search-persistence/src/main.tsx
@@ -6,9 +6,9 @@ import { setupLocalStorageSync } from './utils/localStorage-sync'
import './styles.css'
// Setup localStorage sync for search persistence (optional)
-if (typeof window !== 'undefined') {
- setupLocalStorageSync()
-}
+// if (typeof window !== 'undefined') {
+// setupLocalStorageSync()
+// }
const router = createRouter({ routeTree })
diff --git a/examples/react/search-persistence/src/routes/__root.tsx b/examples/react/search-persistence/src/routes/__root.tsx
index 9a563b79acf..234b7196154 100644
--- a/examples/react/search-persistence/src/routes/__root.tsx
+++ b/examples/react/search-persistence/src/routes/__root.tsx
@@ -29,7 +29,7 @@ function RootComponent() {
{' '}
prev}
activeProps={{
className: 'font-bold',
}}
diff --git a/examples/react/search-persistence/src/routes/index.tsx b/examples/react/search-persistence/src/routes/index.tsx
index faddbd9858b..bf0e7557391 100644
--- a/examples/react/search-persistence/src/routes/index.tsx
+++ b/examples/react/search-persistence/src/routes/index.tsx
@@ -6,66 +6,114 @@ export const Route = createFileRoute('/')({
function HomeComponent() {
return (
-
-
Search Persistence Middleware Demo
-
- Navigate to Users or Products and filter some data, then navigate back
- to see persistence in action!
-
-
-
-
🧪 Restoration Patterns
+
+
+
+ Search Persistence Middleware
+
+
+ Navigate to Users or Products, filter some data, then navigate back to
+ see persistence in action!
+
+
-
Full restoration:
-
-
prev}
- className="inline-block bg-purple-500 text-white px-3 py-1 rounded text-sm"
- >
- Users (restore all)
-
-
prev}
- className="inline-block bg-orange-500 text-white px-3 py-1 rounded text-sm"
- >
- Products (restore all)
-
+
+
+ 🧪
+
+ Test Restoration Patterns
+
-
Partial override:
-
-
({ ...prev, category: 'Electronics' })}
- className="inline-block bg-yellow-600 text-white px-3 py-1 rounded text-sm"
- >
- Products (Electronics)
-
-
({ ...prev, category: 'Books' })}
- className="inline-block bg-yellow-700 text-white px-3 py-1 rounded text-sm"
- >
- Products (Books)
-
+
+
+
+
+ ✓
+
+ Full Restoration
+
+
+ Clean navigation - middleware automatically restores saved
+ parameters
+
+
+
+ Users (auto-restore)
+
+
+ Products (auto-restore)
+
+
+
+
+
+
+
+ ~
+
+ Partial Override
+
+
+ Restore saved parameters but override specific ones
+
+
+ ({ ...prev, category: 'Electronics' })}
+ className="bg-amber-600 hover:bg-amber-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md"
+ >
+ Products → Electronics
+
+ ({ ...prev, category: 'Books' })}
+ className="bg-amber-700 hover:bg-amber-800 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md"
+ >
+ Products → Books
+
+
+
+
+
+
+
+ ×
+
+ Clean Navigation
+
+
+ Navigate without any parameter restoration
+
+
+
+ Users (clean slate)
+
+
+ Products (clean slate)
+
+
+
-
Clean navigation (no restoration):
-
-
- Users (clean)
-
-
- Products (clean)
-
+
+
+ 💡 Tip: Try filtering data on the Users or Products
+ pages, then use these buttons to test different restoration
+ behaviors.
+
diff --git a/examples/react/search-persistence/src/routes/products.tsx b/examples/react/search-persistence/src/routes/products.tsx
index 4cdfde5af1d..4665648a2f7 100644
--- a/examples/react/search-persistence/src/routes/products.tsx
+++ b/examples/react/search-persistence/src/routes/products.tsx
@@ -19,7 +19,7 @@ export const Route = createFileRoute('/products')({
validateSearch: productsSearchSchema,
search: {
middlewares: [
- persistSearchParams(['sortBy']), // Exclude 'sortBy' from persistence - fully typed!
+ persistSearchParams(['category', 'minPrice', 'maxPrice'], ['sortBy']),
],
},
component: ProductsComponent,
diff --git a/examples/react/search-persistence/src/routes/users.tsx b/examples/react/search-persistence/src/routes/users.tsx
index befccdd6f6e..bc88d3db5ba 100644
--- a/examples/react/search-persistence/src/routes/users.tsx
+++ b/examples/react/search-persistence/src/routes/users.tsx
@@ -18,7 +18,7 @@ export type UsersSearchSchema = z.infer
export const Route = createFileRoute('/users')({
validateSearch: usersSearchSchema,
search: {
- middlewares: [persistSearchParams()],
+ middlewares: [persistSearchParams(['name', 'status', 'page'])],
},
component: UsersComponent,
})
diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts
index 246a1124dd8..748489f7ed2 100644
--- a/packages/router-core/src/searchMiddleware.ts
+++ b/packages/router-core/src/searchMiddleware.ts
@@ -131,15 +131,15 @@ export class SearchPersistenceStore {
)
this.__store.setState((prevState) => {
- if (Object.keys(cleanedSearch).length === 0) {
- const { [routeId]: _, ...rest } = prevState
- return rest
- }
-
- return replaceEqualDeep(prevState, {
- ...prevState,
- [routeId]: cleanedSearch,
- })
+ return Object.keys(cleanedSearch).length === 0
+ ? (() => {
+ const { [routeId]: _, ...rest } = prevState
+ return rest
+ })()
+ : replaceEqualDeep(prevState, {
+ ...prevState,
+ [routeId]: cleanedSearch,
+ })
})
}
@@ -176,7 +176,7 @@ export class SearchPersistenceStore {
const searchPersistenceStore = new SearchPersistenceStore()
-// Clean API: Get the properly typed store instance
+// Get the properly typed store instance
export function getSearchPersistenceStore<
TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
>(): {
@@ -230,50 +230,54 @@ export function getSearchPersistenceStore<
}
export function persistSearchParams(
+ persistedSearchParams: Array,
exclude?: Array,
): SearchMiddleware {
return ({ search, next, route }) => {
- // Check if we should restore from store (when search is empty - initial navigation)
+ // Filter input to only explicitly allowed keys for this route
+ const searchRecord = search as Record
+ const allowedKeysStr = persistedSearchParams.map((key) => String(key))
+ const filteredSearch = Object.fromEntries(
+ Object.entries(searchRecord).filter(([key]) =>
+ allowedKeysStr.includes(key),
+ ),
+ ) as TSearchSchema
+
+ // Restore from store if current search is empty
const savedSearch = searchPersistenceStore.getSearch(route.id)
- let searchToProcess = search
+ let searchToProcess = filteredSearch
- // If search is empty and we have saved search, restore it
if (savedSearch && Object.keys(savedSearch).length > 0) {
- const currentSearch = search as Record
- const shouldRestore =
- Object.keys(currentSearch).length === 0 ||
- Object.values(currentSearch).every((value) => {
- if (value === null || value === undefined || value === '') return true
- if (Array.isArray(value) && value.length === 0) return true
- if (
- typeof value === 'object' &&
- value !== null &&
- Object.keys(value).length === 0
- )
- return true
- return false
- })
+ const currentSearch = filteredSearch as Record
+ const isEmpty = Object.keys(currentSearch).length === 0
- if (shouldRestore) {
- searchToProcess = { ...search, ...savedSearch } as TSearchSchema
+ if (isEmpty) {
+ searchToProcess = savedSearch as TSearchSchema
}
}
- // Process through validation and other middleware
const result = next(searchToProcess)
- // Save the result for future restoration (but only if it's not empty)
+ // Save only the allowed parameters for persistence
const resultRecord = result as Record
if (Object.keys(resultRecord).length > 0) {
- // Filter out excluded keys in middleware before saving
+ 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(resultRecord).filter(
+ Object.entries(paramsToSave).filter(
([key]) => !excludeKeys.includes(key),
),
)
- searchPersistenceStore.saveSearch(route.id, filteredResult)
+ if (Object.keys(filteredResult).length > 0) {
+ searchPersistenceStore.saveSearch(route.id, filteredResult)
+ }
}
return result
From 27c82226c1276102d2f5353d3b04616881b0b1ac Mon Sep 17 00:00:00 2001
From: Nitsan Cohen <77798308+NitsanCohen770@users.noreply.github.com>
Date: Thu, 21 Aug 2025 11:58:36 +0300
Subject: [PATCH 4/8] feat: add SSR-safe search parameter persistence
middleware
Add comprehensive search parameter persistence system with SSR support:
## Core Features
- **persistSearchParams middleware**: Automatically saves/restores search params per route
- **Explicit allowlists**: Required persistedSearchParams array with optional exclude
- **SSR-safe architecture**: Per-router store injection prevents cross-request state leakage
- **Smart restoration**: Bypasses validation for trusted restored data to prevent Zod defaults corruption
## Technical Implementation
- **SearchPersistenceStore**: Client-only store with subscribe/save/restore methods
- **Router integration**: Optional searchPersistenceStore injection via RouterOptions
- **Middleware bypass**: Restored data skips validation to prevent SSR hydration issues
- **Memory efficient**: Stores garbage collected per request in SSR environments
## Breaking Changes
- persistSearchParams now requires explicit persistedSearchParams array as first argument
- Removed auto-discovery from Zod schemas due to .optional().catch() permissiveness
## Added
- packages/router-core: SearchPersistenceStore class and persistence utilities
- examples/react/ssr-search-persistence: Complete SSR demo with server database integration
- docs: Comprehensive SSR documentation with architecture examples
## Fixed
- SSR validation timing issues causing restored values to be overridden by Zod defaults
- Cross-request state contamination in server environments
- Homepage navigation resetting persisted search parameters
- Hydration mismatches in SSR applications
Resolves search parameter persistence requirements for both client-only and SSR applications.
---
.../api/router/persistSearchParamsFunction.md | 99 +++++++++--
.../search-persistence/src/routes/__root.tsx | 2 +-
.../react/ssr-search-persistence/.gitignore | 3 +
.../react/ssr-search-persistence/README.md | 88 +++++++++
.../react/ssr-search-persistence/package.json | 34 ++++
.../react/ssr-search-persistence/server.js | 104 +++++++++++
.../src/components/ClientOnly.tsx | 20 +++
.../src/entry-client.tsx | 7 +
.../src/entry-server.tsx | 75 ++++++++
.../src/fetch-polyfill.js | 11 ++
.../src/routeTree.gen.ts | 113 ++++++++++++
.../ssr-search-persistence/src/router.tsx | 68 +++++++
.../src/routerContext.tsx | 3 +
.../src/routes/__root.tsx | 122 +++++++++++++
.../src/routes/database.tsx | 145 +++++++++++++++
.../src/routes/index.tsx | 56 ++++++
.../src/routes/products.tsx | 142 +++++++++++++++
.../src/routes/users.tsx | 168 ++++++++++++++++++
.../ssr-search-persistence/tsconfig.json | 13 ++
.../ssr-search-persistence/vite.config.ts | 53 ++++++
packages/react-router/src/index.tsx | 1 +
packages/router-core/src/index.ts | 2 +
packages/router-core/src/route.ts | 7 +
packages/router-core/src/router.ts | 19 ++
packages/router-core/src/searchMiddleware.ts | 106 +++++++----
pnpm-lock.yaml | 86 +++++++++
...eact-basic-esbuild-file-based-external.txt | 1 +
...ter-e2e-react-basic-esbuild-file-based.txt | 1 +
28 files changed, 1498 insertions(+), 51 deletions(-)
create mode 100644 examples/react/ssr-search-persistence/.gitignore
create mode 100644 examples/react/ssr-search-persistence/README.md
create mode 100644 examples/react/ssr-search-persistence/package.json
create mode 100644 examples/react/ssr-search-persistence/server.js
create mode 100644 examples/react/ssr-search-persistence/src/components/ClientOnly.tsx
create mode 100644 examples/react/ssr-search-persistence/src/entry-client.tsx
create mode 100644 examples/react/ssr-search-persistence/src/entry-server.tsx
create mode 100644 examples/react/ssr-search-persistence/src/fetch-polyfill.js
create mode 100644 examples/react/ssr-search-persistence/src/routeTree.gen.ts
create mode 100644 examples/react/ssr-search-persistence/src/router.tsx
create mode 100644 examples/react/ssr-search-persistence/src/routerContext.tsx
create mode 100644 examples/react/ssr-search-persistence/src/routes/__root.tsx
create mode 100644 examples/react/ssr-search-persistence/src/routes/database.tsx
create mode 100644 examples/react/ssr-search-persistence/src/routes/index.tsx
create mode 100644 examples/react/ssr-search-persistence/src/routes/products.tsx
create mode 100644 examples/react/ssr-search-persistence/src/routes/users.tsx
create mode 100644 examples/react/ssr-search-persistence/tsconfig.json
create mode 100644 examples/react/ssr-search-persistence/vite.config.ts
create mode 100644 port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt
create mode 100644 port-tanstack-router-e2e-react-basic-esbuild-file-based.txt
diff --git a/docs/router/framework/react/api/router/persistSearchParamsFunction.md b/docs/router/framework/react/api/router/persistSearchParamsFunction.md
index cfa832a4881..b161e2fc8f6 100644
--- a/docs/router/framework/react/api/router/persistSearchParamsFunction.md
+++ b/docs/router/framework/react/api/router/persistSearchParamsFunction.md
@@ -7,10 +7,10 @@ title: Search middleware to persist search params
## persistSearchParams props
-`persistSearchParams` accepts one of the following inputs:
+`persistSearchParams` accepts the following parameters:
-- `undefined` (no arguments): persist all search params
-- a list of keys of those search params that shall be excluded from persistence
+- `persistedSearchParams` (required): Array of search param keys to persist
+- `exclude` (optional): Array of search param keys to exclude from persistence
## How it works
@@ -46,8 +46,8 @@ const usersSearchSchema = z.object({
export const Route = createFileRoute('/users')({
validateSearch: usersSearchSchema,
search: {
- // persist all search params
- middlewares: [persistSearchParams()],
+ // persist name, status, and page
+ middlewares: [persistSearchParams(['name', 'status', 'page'])],
},
})
```
@@ -66,8 +66,10 @@ const productsSearchSchema = z.object({
export const Route = createFileRoute('/products')({
validateSearch: productsSearchSchema,
search: {
- // exclude tempFilter from persistence
- middlewares: [persistSearchParams(['tempFilter'])],
+ // persist category, minPrice, maxPrice but exclude tempFilter
+ middlewares: [
+ persistSearchParams(['category', 'minPrice', 'maxPrice'], ['tempFilter']),
+ ],
},
})
```
@@ -86,8 +88,10 @@ const searchSchema = z.object({
export const Route = createFileRoute('/products')({
validateSearch: searchSchema,
search: {
- // exclude tempFilter and sortBy from persistence
- middlewares: [persistSearchParams(['tempFilter', 'sortBy'])],
+ // persist category and sortOrder, exclude tempFilter and sortBy
+ middlewares: [
+ persistSearchParams(['category', 'sortOrder'], ['tempFilter', 'sortBy']),
+ ],
},
})
```
@@ -131,8 +135,10 @@ You have two ways to exclude parameters from persistence:
**1. Middleware-level exclusion** (permanent):
```tsx
-// These parameters are never saved
-middlewares: [persistSearchParams(['tempFilter', 'sortBy'])]
+// Persist category and minPrice, exclude tempFilter and sortBy
+middlewares: [
+ persistSearchParams(['category', 'minPrice'], ['tempFilter', 'sortBy']),
+]
```
**2. Link-level exclusion** (per navigation):
@@ -169,6 +175,77 @@ function CustomNavigation() {
}
```
+## Server-Side Rendering (SSR)
+
+The search persistence middleware is **SSR-safe** and automatically creates isolated store instances per request to prevent state leakage between users.
+
+### Key SSR Features
+
+- **Per-request isolation**: Each SSR request gets its own `SearchPersistenceStore` instance
+- **Automatic hydration**: Client seamlessly takes over from server-rendered state
+- **No global state**: Prevents cross-request contamination in server environments
+- **Custom store injection**: Integrate with your own persistence backend
+
+### Basic SSR Setup
+
+```tsx
+import { createRouter, SearchPersistenceStore } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+
+export function createAppRouter() {
+ // Create isolated store per router instance (per SSR request)
+ const searchPersistenceStore =
+ typeof window !== 'undefined' ? new SearchPersistenceStore() : undefined
+
+ return createRouter({
+ routeTree,
+ searchPersistenceStore, // Inject the store
+ // ... other options
+ })
+}
+```
+
+### Custom Persistence Backend
+
+For production SSR applications, integrate with your own persistence layer:
+
+```tsx
+import { createRouter, SearchPersistenceStore } from '@tanstack/react-router'
+
+export function createAppRouter(userId?: string) {
+ let searchPersistenceStore: SearchPersistenceStore | undefined
+
+ if (typeof window !== 'undefined') {
+ searchPersistenceStore = new SearchPersistenceStore()
+
+ // Load user's saved searches from your backend
+ loadUserSearches(userId).then((savedSearches) => {
+ Object.entries(savedSearches).forEach(([routeId, searchParams]) => {
+ searchPersistenceStore.saveSearch(routeId, searchParams)
+ })
+ })
+
+ // Save changes back to your backend
+ searchPersistenceStore.subscribe(() => {
+ const state = searchPersistenceStore.state
+ saveUserSearches(userId, state)
+ })
+ }
+
+ return createRouter({
+ routeTree,
+ searchPersistenceStore,
+ })
+}
+```
+
+### SSR Considerations
+
+- **Client-only store**: Store is only created on client-side (`typeof window !== 'undefined'`)
+- **Hydration-safe**: No server/client mismatch issues
+- **Performance**: Restored data bypasses validation to prevent SSR timing issues
+- **Memory efficient**: Stores are garbage collected per request
+
## Using the search persistence store
You can also access the search persistence store directly for manual control:
diff --git a/examples/react/search-persistence/src/routes/__root.tsx b/examples/react/search-persistence/src/routes/__root.tsx
index 234b7196154..f144152dbff 100644
--- a/examples/react/search-persistence/src/routes/__root.tsx
+++ b/examples/react/search-persistence/src/routes/__root.tsx
@@ -29,7 +29,7 @@ function RootComponent() {
{' '}
prev}
+ search={(prev) => prev}
activeProps={{
className: 'font-bold',
}}
diff --git a/examples/react/ssr-search-persistence/.gitignore b/examples/react/ssr-search-persistence/.gitignore
new file mode 100644
index 00000000000..0ca39c007c3
--- /dev/null
+++ b/examples/react/ssr-search-persistence/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+.DS_Store
diff --git a/examples/react/ssr-search-persistence/README.md b/examples/react/ssr-search-persistence/README.md
new file mode 100644
index 00000000000..79e5cb2eb4e
--- /dev/null
+++ b/examples/react/ssr-search-persistence/README.md
@@ -0,0 +1,88 @@
+# SSR Search Persistence with Database Example
+
+This example demonstrates TanStack Router's SSR-safe search parameter persistence using the new per-router store injection pattern, complete with a demo database that syncs with the SearchPersistenceStore.
+
+## Features
+
+- **SSR-Safe**: Each SSR request gets its own `SearchPersistenceStore` instance
+- **Route Isolation**: Search parameters are isolated per route (no contamination)
+- **Selective Persistence**: Choose which search params to persist vs. exclude
+- **Client Hydration**: Seamless client-side hydration maintains search state
+- **Database Integration**: Demo database automatically syncs with search persistence store
+- **Real-time Updates**: Database records update live as you navigate and filter
+
+## Key Implementation Details
+
+### Per-Request Store Creation (SSR)
+
+```tsx
+// entry-server.tsx
+const requestSearchStore = new SearchPersistenceStore()
+router.update({
+ searchPersistenceStore: requestSearchStore,
+})
+```
+
+### Client-Side Store (Browser)
+
+```tsx
+// router.tsx
+const searchPersistenceStore =
+ typeof window !== 'undefined' ? new SearchPersistenceStore() : undefined
+```
+
+### Route Configuration
+
+```tsx
+// routes/products.tsx
+search: {
+ middlewares: [
+ persistSearchParams(['category', 'minPrice', 'maxPrice'], ['sortBy'])
+ ],
+}
+```
+
+### Database Integration
+
+```tsx
+// lib/searchDatabase.ts
+export class SearchDatabase {
+ // Syncs with SearchPersistenceStore
+ syncWithStore(store: SearchPersistenceStore, userId = 'anonymous'): () => void
+
+ // Subscribe to changes
+ subscribe(callback: () => void): () => void
+
+ // CRUD operations
+ saveSearchParams(routeId: string, searchParams: Record): void
+ getSearchParams(routeId: string): Record | null
+}
+```
+
+### Database Provider
+
+```tsx
+// lib/SearchDatabaseProvider.tsx
+
+ {/* Your app with automatic database sync */}
+
+```
+
+## Running the Example
+
+```bash
+pnpm install
+pnpm run dev
+```
+
+Then open http://localhost:3000
+
+## Testing Search Persistence + Database
+
+1. Navigate to Products, set some filters
+2. Go to Database tab - see your search params stored in real-time!
+3. Navigate to Users, set different filters
+4. Check Database again - both routes have isolated records
+5. Navigate back to Products - your filters persist from database!
+6. Refresh the page - everything restores correctly from database
+7. Each route maintains its own isolated search state in the database
diff --git a/examples/react/ssr-search-persistence/package.json b/examples/react/ssr-search-persistence/package.json
new file mode 100644
index 00000000000..264ba784850
--- /dev/null
+++ b/examples/react/ssr-search-persistence/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "tanstack-router-react-example-ssr-search-persistence",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "node server",
+ "build": "pnpm run build:client && pnpm run build:server",
+ "build:client": "vite build",
+ "build:server": "vite build --ssr",
+ "serve": "NODE_ENV=production node server",
+ "debug": "node --inspect-brk server"
+ },
+ "dependencies": {
+ "@tanstack/react-router": "^1.131.27",
+ "@tanstack/router-plugin": "^1.131.27",
+ "compression": "^1.8.0",
+ "express": "^4.21.2",
+ "get-port": "^7.1.0",
+ "isbot": "^5.1.28",
+ "node-fetch": "^3.3.2",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "@tanstack/react-router-devtools": "^1.131.27",
+ "@types/express": "^4.17.23",
+ "@types/react": "^19.0.8",
+ "@types/react-dom": "^19.0.1",
+ "@vitejs/plugin-react": "^4.5.2",
+ "typescript": "^5.8.3",
+ "vite": "^6.3.5"
+ }
+}
diff --git a/examples/react/ssr-search-persistence/server.js b/examples/react/ssr-search-persistence/server.js
new file mode 100644
index 00000000000..5686edc048f
--- /dev/null
+++ b/examples/react/ssr-search-persistence/server.js
@@ -0,0 +1,104 @@
+import path from 'node:path'
+import express from 'express'
+import getPort, { portNumbers } from 'get-port'
+import * as zlib from 'node:zlib'
+
+const isTest = process.env.NODE_ENV === 'test' || !!process.env.VITE_TEST_BUILD
+
+export async function createServer(
+ root = process.cwd(),
+ isProd = process.env.NODE_ENV === 'production',
+ hmrPort,
+) {
+ const app = express()
+
+ /**
+ * @type {import('vite').ViteDevServer}
+ */
+ let vite
+ if (!isProd) {
+ vite = await (
+ await import('vite')
+ ).createServer({
+ root,
+ logLevel: isTest ? 'error' : 'info',
+ server: {
+ middlewareMode: true,
+ watch: {
+ // During tests we edit the files too fast and sometimes chokidar
+ // misses change events, so enforce polling for consistency
+ usePolling: true,
+ interval: 100,
+ },
+ hmr: {
+ port: hmrPort,
+ },
+ },
+ appType: 'custom',
+ })
+ // use vite's connect instance as middleware
+ app.use(vite.middlewares)
+ } else {
+ app.use(
+ (await import('compression')).default({
+ brotli: {
+ flush: zlib.constants.BROTLI_OPERATION_FLUSH,
+ },
+ flush: zlib.constants.Z_SYNC_FLUSH,
+ }),
+ )
+ }
+
+ if (isProd) app.use(express.static('./dist/client'))
+
+ app.use('*', async (req, res) => {
+ try {
+ const url = req.originalUrl
+
+ if (path.extname(url) !== '') {
+ console.warn(`${url} is not valid router path`)
+ res.status(404)
+ res.end(`${url} is not valid router path`)
+ return
+ }
+
+ // Best effort extraction of the head from vite's index transformation hook
+ let viteHead = !isProd
+ ? await vite.transformIndexHtml(
+ url,
+ ``,
+ )
+ : ''
+
+ viteHead = viteHead.substring(
+ viteHead.indexOf('') + 6,
+ viteHead.indexOf(''),
+ )
+
+ const entry = await (async () => {
+ if (!isProd) {
+ return vite.ssrLoadModule('/src/entry-server.tsx')
+ } else {
+ return import('./dist/server/entry-server.js')
+ }
+ })()
+
+ console.info('Rendering: ', url, '...')
+ entry.render({ req, res, head: viteHead })
+ } catch (e) {
+ !isProd && vite.ssrFixStacktrace(e)
+ console.info(e.stack)
+ res.status(500).end(e.stack)
+ }
+ })
+
+ return { app, vite }
+}
+
+if (!isTest) {
+ createServer().then(async ({ app }) =>
+ app.listen(await getPort({ port: portNumbers(3000, 3100) }), () => {
+ console.info('Client Server: http://localhost:3000')
+ }),
+ )
+}
diff --git a/examples/react/ssr-search-persistence/src/components/ClientOnly.tsx b/examples/react/ssr-search-persistence/src/components/ClientOnly.tsx
new file mode 100644
index 00000000000..39bac41083d
--- /dev/null
+++ b/examples/react/ssr-search-persistence/src/components/ClientOnly.tsx
@@ -0,0 +1,20 @@
+import { useEffect, useState } from 'react'
+
+interface ClientOnlyProps {
+ children: React.ReactNode
+ fallback?: React.ReactNode
+}
+
+export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
+ const [hasMounted, setHasMounted] = useState(false)
+
+ useEffect(() => {
+ setHasMounted(true)
+ }, [])
+
+ if (!hasMounted) {
+ return <>{fallback}>
+ }
+
+ return <>{children}>
+}
diff --git a/examples/react/ssr-search-persistence/src/entry-client.tsx b/examples/react/ssr-search-persistence/src/entry-client.tsx
new file mode 100644
index 00000000000..5b19c4a66a5
--- /dev/null
+++ b/examples/react/ssr-search-persistence/src/entry-client.tsx
@@ -0,0 +1,7 @@
+import { hydrateRoot } from 'react-dom/client'
+import { RouterClient } from '@tanstack/react-router/ssr/client'
+import { createRouter } from './router'
+
+const router = createRouter()
+
+hydrateRoot(document, )
diff --git a/examples/react/ssr-search-persistence/src/entry-server.tsx b/examples/react/ssr-search-persistence/src/entry-server.tsx
new file mode 100644
index 00000000000..0da9f3a561c
--- /dev/null
+++ b/examples/react/ssr-search-persistence/src/entry-server.tsx
@@ -0,0 +1,75 @@
+import { pipeline } from 'node:stream/promises'
+import {
+ RouterServer,
+ createRequestHandler,
+ renderRouterToString,
+} from '@tanstack/react-router/ssr/server'
+import { SearchPersistenceStore } from '@tanstack/react-router'
+import { createRouter } from './router'
+import type express from 'express'
+import './fetch-polyfill'
+
+export async function render({
+ req,
+ res,
+ head,
+}: {
+ head: string
+ req: express.Request
+ res: express.Response
+}) {
+ // Convert the express request to a fetch request
+ const url = new URL(req.originalUrl || req.url, 'https://localhost:3000').href
+
+ const request = new Request(url, {
+ method: req.method,
+ headers: (() => {
+ const headers = new Headers()
+ for (const [key, value] of Object.entries(req.headers)) {
+ headers.set(key, value as any)
+ }
+ return headers
+ })(),
+ })
+
+ // Create a request handler
+ const handler = createRequestHandler({
+ request,
+ createRouter: () => {
+ const router = createRouter()
+
+ // For SSR: Create a fresh SearchPersistenceStore per request to avoid cross-request contamination
+ const requestSearchStore = new SearchPersistenceStore()
+
+ // Update each router instance with the head info from vite and per-request store
+ router.update({
+ context: {
+ ...router.options.context,
+ head: head,
+ },
+ searchPersistenceStore: requestSearchStore,
+ })
+ return router
+ },
+ })
+
+ // Let's use the default stream handler to create the response
+ const response = await handler(({ responseHeaders, router }) =>
+ renderRouterToString({
+ responseHeaders,
+ router,
+ children: ,
+ }),
+ )
+
+ // Convert the fetch response back to an express response
+ res.statusMessage = response.statusText
+ res.status(response.status)
+
+ response.headers.forEach((value, name) => {
+ res.setHeader(name, value)
+ })
+
+ // Stream the response body
+ return pipeline(response.body as any, res)
+}
diff --git a/examples/react/ssr-search-persistence/src/fetch-polyfill.js b/examples/react/ssr-search-persistence/src/fetch-polyfill.js
new file mode 100644
index 00000000000..9893c4c2090
--- /dev/null
+++ b/examples/react/ssr-search-persistence/src/fetch-polyfill.js
@@ -0,0 +1,11 @@
+import fetch, { Headers, Request, Response } from 'node-fetch'
+
+// Polyfill fetch for Node.js environments that don't have it built-in
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+globalThis.fetch = globalThis.fetch || fetch
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+globalThis.Headers = globalThis.Headers || Headers
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+globalThis.Request = globalThis.Request || Request
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+globalThis.Response = globalThis.Response || Response
diff --git a/examples/react/ssr-search-persistence/src/routeTree.gen.ts b/examples/react/ssr-search-persistence/src/routeTree.gen.ts
new file mode 100644
index 00000000000..189ea5eea8b
--- /dev/null
+++ b/examples/react/ssr-search-persistence/src/routeTree.gen.ts
@@ -0,0 +1,113 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as UsersRouteImport } from './routes/users'
+import { Route as ProductsRouteImport } from './routes/products'
+import { Route as DatabaseRouteImport } from './routes/database'
+import { Route as IndexRouteImport } from './routes/index'
+
+const UsersRoute = UsersRouteImport.update({
+ id: '/users',
+ path: '/users',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ProductsRoute = ProductsRouteImport.update({
+ id: '/products',
+ path: '/products',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const DatabaseRoute = DatabaseRouteImport.update({
+ id: '/database',
+ path: '/database',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/database': typeof DatabaseRoute
+ '/products': typeof ProductsRoute
+ '/users': typeof UsersRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/database': typeof DatabaseRoute
+ '/products': typeof ProductsRoute
+ '/users': typeof UsersRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/database': typeof DatabaseRoute
+ '/products': typeof ProductsRoute
+ '/users': typeof UsersRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/database' | '/products' | '/users'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/database' | '/products' | '/users'
+ id: '__root__' | '/' | '/database' | '/products' | '/users'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ DatabaseRoute: typeof DatabaseRoute
+ ProductsRoute: typeof ProductsRoute
+ UsersRoute: typeof UsersRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/users': {
+ id: '/users'
+ path: '/users'
+ fullPath: '/users'
+ preLoaderRoute: typeof UsersRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/products': {
+ id: '/products'
+ path: '/products'
+ fullPath: '/products'
+ preLoaderRoute: typeof ProductsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/database': {
+ id: '/database'
+ path: '/database'
+ fullPath: '/database'
+ preLoaderRoute: typeof DatabaseRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ DatabaseRoute: DatabaseRoute,
+ ProductsRoute: ProductsRoute,
+ UsersRoute: UsersRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
diff --git a/examples/react/ssr-search-persistence/src/router.tsx b/examples/react/ssr-search-persistence/src/router.tsx
new file mode 100644
index 00000000000..595aa9ef8f7
--- /dev/null
+++ b/examples/react/ssr-search-persistence/src/router.tsx
@@ -0,0 +1,68 @@
+import {
+ SearchPersistenceStore,
+ createRouter as createReactRouter,
+} from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+import { serverDatabase } from './lib/serverDatabase'
+
+export function createRouter() {
+ let searchPersistenceStore: SearchPersistenceStore | undefined
+
+ if (typeof window !== 'undefined') {
+ searchPersistenceStore = new SearchPersistenceStore()
+
+ // Load initial data from server database into router store
+ const allRecords = serverDatabase.getAllRecords()
+ allRecords.forEach((record) => {
+ searchPersistenceStore!.saveSearch(
+ record.routeId as any,
+ record.searchParams,
+ )
+ })
+
+ // Subscribe to router store changes and save to server database
+ searchPersistenceStore.subscribe(() => {
+ if (!searchPersistenceStore) return
+ const storeState = searchPersistenceStore.state
+
+ // Save routes that have non-empty search params
+ Object.entries(storeState).forEach(([routeId, searchParams]) => {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (Object.keys(searchParams || {}).length > 0) {
+ serverDatabase.save(routeId, searchParams, 'demo-user')
+ }
+ })
+
+ // Restore routes from database if they're missing from store
+ const allDbRecords = serverDatabase.getAllRecords()
+ const missingRoutes = allDbRecords.filter(
+ (record) => !(record.routeId in storeState),
+ )
+
+ if (missingRoutes.length > 0) {
+ missingRoutes.forEach((record) => {
+ searchPersistenceStore!.saveSearch(
+ record.routeId as any,
+ record.searchParams,
+ )
+ })
+ }
+ })
+ }
+
+ return createReactRouter({
+ routeTree,
+ context: {
+ head: '',
+ },
+ searchPersistenceStore,
+ defaultPreload: 'intent',
+ scrollRestoration: true,
+ })
+}
+
+declare module '@tanstack/react-router' {
+ interface Register {
+ router: ReturnType
+ }
+}
diff --git a/examples/react/ssr-search-persistence/src/routerContext.tsx b/examples/react/ssr-search-persistence/src/routerContext.tsx
new file mode 100644
index 00000000000..d452eb81402
--- /dev/null
+++ b/examples/react/ssr-search-persistence/src/routerContext.tsx
@@ -0,0 +1,3 @@
+export interface RouterContext {
+ head: string
+}
diff --git a/examples/react/ssr-search-persistence/src/routes/__root.tsx b/examples/react/ssr-search-persistence/src/routes/__root.tsx
new file mode 100644
index 00000000000..b9dd38f1a11
--- /dev/null
+++ b/examples/react/ssr-search-persistence/src/routes/__root.tsx
@@ -0,0 +1,122 @@
+import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
+import {
+ HeadContent,
+ Link,
+ Outlet,
+ Scripts,
+ createRootRouteWithContext,
+} from '@tanstack/react-router'
+import type { RouterContext } from '../routerContext'
+
+export const Route = createRootRouteWithContext()({
+ head: () => ({
+ links: [{ rel: 'icon', href: '/images/favicon.ico' }],
+ meta: [
+ {
+ title: 'TanStack Router SSR Search Persistence Example',
+ },
+ {
+ charSet: 'UTF-8',
+ },
+ {
+ name: 'viewport',
+ content: 'width=device-width, initial-scale=1.0',
+ },
+ ],
+ scripts: [
+ {
+ src: 'https://unpkg.com/@tailwindcss/browser@4',
+ },
+ ...(!import.meta.env.PROD
+ ? [
+ {
+ type: 'module',
+ children: `import RefreshRuntime from "/@react-refresh"
+ RefreshRuntime.injectIntoGlobalHook(window)
+ window.$RefreshReg$ = () => {}
+ window.$RefreshSig$ = () => (type) => type
+ window.__vite_plugin_react_preamble_installed__ = true`,
+ },
+ {
+ type: 'module',
+ src: '/@vite/client',
+ },
+ ]
+ : []),
+ {
+ type: 'module',
+ src: import.meta.env.PROD
+ ? '/static/entry-client.js'
+ : '/src/entry-client.tsx',
+ },
+ ],
+ }),
+ component: RootComponent,
+})
+
+function RootComponent() {
+ return (
+
+
+
+
+
+
+
SSR Search Persistence
+
+ This example demonstrates server-side search parameter persistence
+ using TanStack Router's search middleware with a custom database
+ adapter.
+
+
+
+
+ Home
+
+ prev}
+ className="hover:text-blue-500"
+ >
+ Products
+
+ prev}
+ >
+ Users
+
+
+ Database
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/examples/react/ssr-search-persistence/src/routes/database.tsx b/examples/react/ssr-search-persistence/src/routes/database.tsx
new file mode 100644
index 00000000000..2bb9efd0043
--- /dev/null
+++ b/examples/react/ssr-search-persistence/src/routes/database.tsx
@@ -0,0 +1,145 @@
+import { createFileRoute, useRouter } from '@tanstack/react-router'
+import { useEffect, useState } from 'react'
+import { serverDatabase } from '../lib/serverDatabase'
+import { ClientOnly } from '../components/ClientOnly'
+import type { ServerSearchRecord } from '../lib/serverDatabase'
+
+export const Route = createFileRoute('/database')({
+ component: DatabaseComponent,
+})
+
+function DatabaseComponent() {
+ return (
+ Loading database... }>
+
+
+ )
+}
+
+function DatabaseContent() {
+ const router = useRouter()
+ const [records, setRecords] = useState
>([])
+ useEffect(() => {
+ const loadRecords = () => {
+ const allRecords = serverDatabase.getAllRecords()
+ setRecords(allRecords)
+ }
+
+ loadRecords()
+ const interval = setInterval(loadRecords, 2000)
+
+ return () => clearInterval(interval)
+ }, [])
+
+ const clearAllRecords = () => {
+ serverDatabase.clear()
+
+ // Also clear from router store
+ const searchStore = router.options.searchPersistenceStore
+ if (searchStore) {
+ records.forEach((record) => {
+ searchStore.clearSearch(record.routeId)
+ })
+ }
+
+ setRecords([])
+ }
+
+ const formatTimestamp = (timestamp: number) => {
+ return new Date(timestamp).toLocaleTimeString()
+ }
+
+ return (
+
+
+
Search Database
+
+ {records.length} record{records.length !== 1 ? 's' : ''}
+
+
+
+
+
+ This demonstrates server-side search parameter persistence. Search
+ parameters are saved to a server database and restored across SSR
+ requests.
+
+
+
+
+
Persisted Search Parameters
+ {records.length > 0 && (
+
+ Clear All
+
+ )}
+
+
+ {records.length === 0 ? (
+
+
No search parameters saved
+
+ Navigate to Products or Users and set some filters to see them here.
+
+
+ ) : (
+
+ {records.map((record, index) => (
+
+
+
+ {record.routeId}
+ {record.userId}
+
+
+ {formatTimestamp(record.timestamp)}
+
+
+
+
+
+ {JSON.stringify(record.searchParams, null, 2)}
+
+
+
+
+ {
+ serverDatabase.delete(record.routeId, record.userId)
+
+ const searchStore = router.options.searchPersistenceStore
+ if (searchStore) {
+ searchStore.clearSearch(record.routeId)
+ }
+
+ setRecords(records.filter((r) => r !== record))
+ }}
+ className="text-red-600 hover:text-red-800 text-sm"
+ >
+ Delete
+
+
+
+ ))}
+
+ )}
+
+
+
+ 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.
+
+
+
+ )
+}
diff --git a/examples/react/ssr-search-persistence/src/routes/index.tsx b/examples/react/ssr-search-persistence/src/routes/index.tsx
new file mode 100644
index 00000000000..acf33c2f8b8
--- /dev/null
+++ b/examples/react/ssr-search-persistence/src/routes/index.tsx
@@ -0,0 +1,56 @@
+import { Link, createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/')({
+ component: IndexComponent,
+})
+
+function IndexComponent() {
+ return (
+
+
+ Welcome to SSR Search Persistence!
+
+
+
+
🔍 How It Works
+
+ • Search parameters are persisted per route using middleware
+ • Each route isolates its search params (no contamination!)
+ • SSR-safe: per-request store instances prevent state leakage
+ • Client hydration maintains search state seamlessly
+
+
+
+
+
Try the examples:
+
+
+ Products with Search
+
+
+ Users with Search
+
+
+
+
+
+
🧪 Test Instructions
+
+ Navigate to Products, set search filters
+ Navigate to Users, set different search filters
+ Navigate back to Products - your filters persist!
+ Navigate back to Users - different filters persist!
+ Refresh the page - search params restore correctly
+
+
+
+ )
+}
diff --git a/examples/react/ssr-search-persistence/src/routes/products.tsx b/examples/react/ssr-search-persistence/src/routes/products.tsx
new file mode 100644
index 00000000000..02c5cff5ac6
--- /dev/null
+++ b/examples/react/ssr-search-persistence/src/routes/products.tsx
@@ -0,0 +1,142 @@
+import {
+ createFileRoute,
+ persistSearchParams,
+ useNavigate,
+} from '@tanstack/react-router'
+import { z } from 'zod'
+
+const productsSearchSchema = z.object({
+ category: z.string().optional().catch(''),
+ minPrice: z.number().optional().catch(0),
+ maxPrice: z.number().optional().catch(1000),
+ sortBy: z.enum(['name', 'price', 'rating']).optional().catch('name'),
+})
+
+export type ProductsSearchSchema = z.infer
+
+export const Route = createFileRoute('/products')({
+ validateSearch: productsSearchSchema,
+ search: {
+ middlewares: [
+ persistSearchParams(['category', 'minPrice', 'maxPrice'], ['sortBy']),
+ ],
+ },
+ component: ProductsComponent,
+})
+
+function ProductsComponent() {
+ const search = Route.useSearch()
+ const navigate = useNavigate()
+
+ const updateSearch = (updates: Partial) => {
+ navigate({ search: { ...search, ...updates } })
+ }
+
+ return (
+
+
+
Products
+
+ Persisted: category, minPrice, maxPrice |
+ Excluded: sortBy (temporary filter)
+
+
+
+
+
+
+ Category
+ updateSearch({ category: e.target.value })}
+ className="w-full border rounded px-3 py-2"
+ >
+ All Categories
+ Electronics
+ Books
+ Clothing
+ Home & Garden
+
+
+
+
+
+ Min Price: ${search.minPrice || 0}
+
+
+ updateSearch({ minPrice: Number(e.target.value) })
+ }
+ className="w-full"
+ />
+
+
+
+
+ Max Price: ${search.maxPrice || 1000}
+
+
+ updateSearch({ maxPrice: Number(e.target.value) })
+ }
+ className="w-full"
+ />
+
+
+
+
+ Sort By (not persisted)
+
+
+ updateSearch({
+ sortBy: e.target.value as ProductsSearchSchema['sortBy'],
+ })
+ }
+ className="w-full border rounded px-3 py-2"
+ >
+ Name
+ Price
+ Rating
+
+
+
+
navigate({ search: {} })}
+ className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
+ >
+ Clear All Filters
+
+
+
+
+
Current Search State:
+
+ {JSON.stringify(search, null, 2)}
+
+
+
+
+
+
🎯 Test Search Persistence
+
+ Set some filters above (category, price range)
+ Navigate to Users page
+ Come back - your filters should be restored!
+ Notice: sortBy resets (not persisted) but others remain
+
+
+
+ )
+}
diff --git a/examples/react/ssr-search-persistence/src/routes/users.tsx b/examples/react/ssr-search-persistence/src/routes/users.tsx
new file mode 100644
index 00000000000..59e43eaee80
--- /dev/null
+++ b/examples/react/ssr-search-persistence/src/routes/users.tsx
@@ -0,0 +1,168 @@
+import {
+ createFileRoute,
+ persistSearchParams,
+ useNavigate,
+} from '@tanstack/react-router'
+import { z } from 'zod'
+
+const usersSearchSchema = z.object({
+ name: z.string().optional().catch(''),
+ status: z.enum(['active', 'inactive', 'pending']).optional().catch('active'),
+ page: z.number().optional().catch(1),
+ limit: z.number().optional().catch(10),
+})
+
+export type UsersSearchSchema = z.infer
+
+export const Route = createFileRoute('/users')({
+ validateSearch: usersSearchSchema,
+ search: {
+ middlewares: [persistSearchParams(['name', 'status', 'page'])],
+ },
+ component: UsersComponent,
+})
+
+function UsersComponent() {
+ const search = Route.useSearch()
+ const navigate = useNavigate()
+
+ const updateSearch = (updates: Partial) => {
+ navigate({ search: { ...search, ...updates } })
+ }
+
+ return (
+
+
+
Users
+
+ Persisted: name, status, page |
+ Not persisted: limit (resets to default)
+
+
+
+
+
+
+
+ Search Name
+
+ updateSearch({ name: e.target.value })}
+ placeholder="Enter user name..."
+ className="w-full border rounded px-3 py-2"
+ />
+
+
+
+
+ Status
+
+
+ updateSearch({
+ status: e.target.value as UsersSearchSchema['status'],
+ })
+ }
+ className="w-full border rounded px-3 py-2"
+ >
+ Active
+ Inactive
+ Pending
+
+
+
+
+
Page
+
+
+ updateSearch({ page: Math.max(1, (search.page || 1) - 1) })
+ }
+ disabled={(search.page || 1) <= 1}
+ className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50"
+ >
+ Previous
+
+
+ Page {search.page || 1}
+
+ updateSearch({ page: (search.page || 1) + 1 })}
+ className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300"
+ >
+ Next
+
+
+
+
+
+
+ Items per page (not persisted)
+
+ updateSearch({ limit: Number(e.target.value) })}
+ className="w-full border rounded px-3 py-2"
+ >
+ 5 per page
+ 10 per page
+ 25 per page
+ 50 per page
+
+
+
+
navigate({ search: {} })}
+ className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
+ >
+ Clear All Filters
+
+
+
+
+
Current Search State:
+
+ {JSON.stringify(search, null, 2)}
+
+
+
+
+
+
🎯 Test Search Isolation
+
+ Set filters here (name, status, page)
+ Go to Products, set completely different filters
+ 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 00000000000..4aaf48e2631
--- /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 00000000000..09cc95baa88
--- /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 ca8e02c6e24..3f000f74a2f 100644
--- a/packages/react-router/src/index.tsx
+++ b/packages/react-router/src/index.tsx
@@ -28,6 +28,7 @@ export {
isPlainObject,
isPlainArray,
deepEqual,
+ SearchPersistenceStore,
shallow,
createControlledPromise,
retainSearchParams,
diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts
index af4e51258a7..b7a2a33ce4c 100644
--- a/packages/router-core/src/index.ts
+++ b/packages/router-core/src/index.ts
@@ -255,6 +255,8 @@ export type {
} from './RouterProvider'
export {
+ SearchPersistenceStore,
+ createSearchPersistenceStore,
retainSearchParams,
stripSearchParams,
persistSearchParams,
diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts
index 15d5560d1fe..bb0bf29aba0 100644
--- a/packages/router-core/src/route.ts
+++ b/packages/router-core/src/route.ts
@@ -71,10 +71,17 @@ export type RoutePathOptionsIntersection = {
export type SearchFilter = (prev: TInput) => TResult
+export interface SearchMiddlewareRouter {
+ options: {
+ searchPersistenceStore?: any // Avoid circular dependency, will be typed at usage
+ }
+}
+
export type SearchMiddlewareContext = {
search: TSearchSchema
next: (newSearch: TSearchSchema) => TSearchSchema
route: { id: string; fullPath: string }
+ router: SearchMiddlewareRouter
}
export type SearchMiddleware = (
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 71078f1bf0e..4ddf85452f7 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'
@@ -276,6 +277,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 +932,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 &&
@@ -1540,6 +1555,7 @@ export class RouterCore<
dest,
destRoutes,
_includeValidateSearch: opts._includeValidateSearch,
+ router: this,
})
// Replace the equal deep
@@ -2698,11 +2714,13 @@ function applySearchMiddleware({
dest,
destRoutes,
_includeValidateSearch,
+ router,
}: {
search: any
dest: BuildNextOptions
destRoutes: Array
_includeValidateSearch: boolean | undefined
+ router: { options: { searchPersistenceStore?: any } }
}) {
const allMiddlewares: Array<{
middleware: SearchMiddleware
@@ -2819,6 +2837,7 @@ function applySearchMiddleware({
search: currentSearch,
next,
route: { id: route.id, fullPath: route.fullPath },
+ router,
})
}
diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts
index 748489f7ed2..724220f36e6 100644
--- a/packages/router-core/src/searchMiddleware.ts
+++ b/packages/router-core/src/searchMiddleware.ts
@@ -120,11 +120,7 @@ export class SearchPersistenceStore {
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' &&
- value !== null &&
- Object.keys(value).length === 0
- )
+ if (typeof value === 'object' && Object.keys(value).length === 0)
return false
return true
}),
@@ -174,12 +170,17 @@ export class SearchPersistenceStore {
}
}
-const searchPersistenceStore = new SearchPersistenceStore()
+// Factory function to create a new SearchPersistenceStore instance
+export function createSearchPersistenceStore(): SearchPersistenceStore {
+ return new SearchPersistenceStore()
+}
-// Get the properly typed store instance
+// 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,
@@ -207,25 +208,23 @@ export function getSearchPersistenceStore<
} {
return {
get state() {
- return searchPersistenceStore.getTypedState()
+ return store.getTypedState()
},
get store() {
- return searchPersistenceStore.getTypedStore()
+ return store.getTypedStore()
},
- subscribe: (listener: () => void) =>
- searchPersistenceStore.subscribe(listener),
+ subscribe: (listener: () => void) => store.subscribe(listener),
getSearch: >(
routeId: TRouteId,
- ) => searchPersistenceStore.getSearch(routeId),
+ ) => store.getSearch(routeId),
saveSearch: >(
routeId: TRouteId,
search: RouteById['types']['fullSearchSchema'],
- ) =>
- searchPersistenceStore.saveSearch(routeId, search),
+ ) => store.saveSearch(routeId, search),
clearSearch: >(
routeId: TRouteId,
- ) => searchPersistenceStore.clearSearch(routeId),
- clearAllSearches: () => searchPersistenceStore.clearAllSearches(),
+ ) => store.clearSearch(routeId),
+ clearAllSearches: () => store.clearAllSearches(),
}
}
@@ -233,8 +232,15 @@ export function persistSearchParams(
persistedSearchParams: Array,
exclude?: Array,
): SearchMiddleware {
- return ({ search, next, route }) => {
- // Filter input to only explicitly allowed keys for this route
+ return ({ search, next, route, router }) => {
+ const store = router.options.searchPersistenceStore as
+ | SearchPersistenceStore
+ | undefined
+
+ if (!store) {
+ return next(search)
+ }
+
const searchRecord = search as Record
const allowedKeysStr = persistedSearchParams.map((key) => String(key))
const filteredSearch = Object.fromEntries(
@@ -243,16 +249,38 @@ export function persistSearchParams(
),
) as TSearchSchema
- // Restore from store if current search is empty
- const savedSearch = searchPersistenceStore.getSearch(route.id)
- let searchToProcess = filteredSearch
+ const savedSearch = store.getSearch(route.id)
+ const searchToProcess = filteredSearch
if (savedSearch && Object.keys(savedSearch).length > 0) {
const currentSearch = filteredSearch as Record
const isEmpty = Object.keys(currentSearch).length === 0
if (isEmpty) {
- searchToProcess = savedSearch as TSearchSchema
+ // Skip router validation for restored data since we know it's valid
+ // This prevents Zod .catch() defaults from overriding our restored values
+ const result = savedSearch as TSearchSchema
+
+ 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(route.id, filteredResult)
+ }
+
+ return result
}
}
@@ -260,24 +288,24 @@ export function persistSearchParams(
// Save only the allowed parameters for persistence
const resultRecord = result as Record
- if (Object.keys(resultRecord).length > 0) {
- const persistedKeysStr = persistedSearchParams.map((key) => String(key))
- const paramsToSave = Object.fromEntries(
- Object.entries(resultRecord).filter(([key]) =>
- persistedKeysStr.includes(key),
- ),
- )
+ 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),
- ),
- )
+ 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) {
- searchPersistenceStore.saveSearch(route.id, filteredResult)
- }
+ // Only save if we have actual search params to persist
+ // Don't save empty objects as they overwrite existing data
+ if (Object.keys(filteredResult).length > 0) {
+ store.saveSearch(route.id, filteredResult)
}
return result
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9a822a66775..408e6f1c329 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 00000000000..2bb82c7c3d4
--- /dev/null
+++ b/port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt
@@ -0,0 +1 @@
+49420
\ 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 00000000000..229d1849bc3
--- /dev/null
+++ b/port-tanstack-router-e2e-react-basic-esbuild-file-based.txt
@@ -0,0 +1 @@
+49419
\ No newline at end of file
From 94082e3061ab8bc27f17f326ee26ddc377da8372 Mon Sep 17 00:00:00 2001
From: Nitsan Cohen <77798308+NitsanCohen770@users.noreply.github.com>
Date: Thu, 21 Aug 2025 12:04:11 +0300
Subject: [PATCH 5/8] Delete
port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt
---
...nstack-router-e2e-react-basic-esbuild-file-based-external.txt | 1 -
1 file changed, 1 deletion(-)
delete mode 100644 port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt
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
deleted file mode 100644
index 2bb82c7c3d4..00000000000
--- a/port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt
+++ /dev/null
@@ -1 +0,0 @@
-49420
\ No newline at end of file
From 6887082d73e7c8a311787e8cef762f1b5ef683d6 Mon Sep 17 00:00:00 2001
From: Nitsan Cohen <77798308+NitsanCohen770@users.noreply.github.com>
Date: Thu, 21 Aug 2025 12:04:36 +0300
Subject: [PATCH 6/8] Delete
port-tanstack-router-e2e-react-basic-esbuild-file-based.txt
---
port-tanstack-router-e2e-react-basic-esbuild-file-based.txt | 1 -
1 file changed, 1 deletion(-)
delete mode 100644 port-tanstack-router-e2e-react-basic-esbuild-file-based.txt
diff --git a/port-tanstack-router-e2e-react-basic-esbuild-file-based.txt b/port-tanstack-router-e2e-react-basic-esbuild-file-based.txt
deleted file mode 100644
index 229d1849bc3..00000000000
--- a/port-tanstack-router-e2e-react-basic-esbuild-file-based.txt
+++ /dev/null
@@ -1 +0,0 @@
-49419
\ No newline at end of file
From 320ba6ebb751b99d41de007e5782413b01054046 Mon Sep 17 00:00:00 2001
From: Nitsan Cohen <77798308+NitsanCohen770@users.noreply.github.com>
Date: Thu, 21 Aug 2025 12:42:11 +0300
Subject: [PATCH 7/8] fix: update search-persistence example package name to
avoid conflicts
- Changed package name from 'tanstack-router-react-example-basic-file-based'
to 'tanstack-router-react-example-search-persistence'
- Resolves NX project graph conflict that prevented running tests
---
examples/react/search-persistence/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/examples/react/search-persistence/package.json b/examples/react/search-persistence/package.json
index e69517a1bed..57a21faa400 100644
--- a/examples/react/search-persistence/package.json
+++ b/examples/react/search-persistence/package.json
@@ -1,5 +1,5 @@
{
- "name": "tanstack-router-react-example-basic-file-based",
+ "name": "tanstack-router-react-example-search-persistence",
"private": true,
"type": "module",
"scripts": {
From ea29c02ea426d462bcb164c3a7e21161e8a4bb5f Mon Sep 17 00:00:00 2001
From: Nitsan Cohen <77798308+NitsanCohen770@users.noreply.github.com>
Date: Wed, 27 Aug 2025 19:01:31 +0300
Subject: [PATCH 8/8] fix: inherit from parent flag to avoid running persist on
parent routes
---
.../search-persistence/src/routeTree.gen.ts | 42 ++-
.../search-persistence/src/routes/__root.tsx | 4 +-
.../search-persistence/src/routes/index.tsx | 1 -
.../src/routes/users.$userId.tsx | 357 ++++++++++++++++++
.../search-persistence/src/routes/users.tsx | 64 +++-
packages/router-core/src/route.ts | 17 +-
packages/router-core/src/router.ts | 247 +++++++-----
packages/router-core/src/searchMiddleware.ts | 163 ++++----
...eact-basic-esbuild-file-based-external.txt | 1 +
...ter-e2e-react-basic-esbuild-file-based.txt | 1 +
10 files changed, 689 insertions(+), 208 deletions(-)
create mode 100644 examples/react/search-persistence/src/routes/users.$userId.tsx
create mode 100644 port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt
create mode 100644 port-tanstack-router-e2e-react-basic-esbuild-file-based.txt
diff --git a/examples/react/search-persistence/src/routeTree.gen.ts b/examples/react/search-persistence/src/routeTree.gen.ts
index 08d6c7fc112..0bc4bd38b37 100644
--- a/examples/react/search-persistence/src/routeTree.gen.ts
+++ b/examples/react/search-persistence/src/routeTree.gen.ts
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as UsersRouteImport } from './routes/users'
import { Route as ProductsRouteImport } from './routes/products'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as UsersUserIdRouteImport } from './routes/users.$userId'
const UsersRoute = UsersRouteImport.update({
id: '/users',
@@ -28,35 +29,43 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const UsersUserIdRoute = UsersUserIdRouteImport.update({
+ id: '/$userId',
+ path: '/$userId',
+ getParentRoute: () => UsersRoute,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/products': typeof ProductsRoute
- '/users': typeof UsersRoute
+ '/users': typeof UsersRouteWithChildren
+ '/users/$userId': typeof UsersUserIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/products': typeof ProductsRoute
- '/users': typeof UsersRoute
+ '/users': typeof UsersRouteWithChildren
+ '/users/$userId': typeof UsersUserIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/products': typeof ProductsRoute
- '/users': typeof UsersRoute
+ '/users': typeof UsersRouteWithChildren
+ '/users/$userId': typeof UsersUserIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/products' | '/users'
+ fullPaths: '/' | '/products' | '/users' | '/users/$userId'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/products' | '/users'
- id: '__root__' | '/' | '/products' | '/users'
+ to: '/' | '/products' | '/users' | '/users/$userId'
+ id: '__root__' | '/' | '/products' | '/users' | '/users/$userId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ProductsRoute: typeof ProductsRoute
- UsersRoute: typeof UsersRoute
+ UsersRoute: typeof UsersRouteWithChildren
}
declare module '@tanstack/react-router' {
@@ -82,13 +91,30 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/users/$userId': {
+ id: '/users/$userId'
+ path: '/$userId'
+ fullPath: '/users/$userId'
+ preLoaderRoute: typeof UsersUserIdRouteImport
+ parentRoute: typeof UsersRoute
+ }
}
}
+interface UsersRouteChildren {
+ UsersUserIdRoute: typeof UsersUserIdRoute
+}
+
+const UsersRouteChildren: UsersRouteChildren = {
+ UsersUserIdRoute: UsersUserIdRoute,
+}
+
+const UsersRouteWithChildren = UsersRoute._addFileChildren(UsersRouteChildren)
+
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ProductsRoute: ProductsRoute,
- UsersRoute: UsersRoute,
+ UsersRoute: UsersRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/examples/react/search-persistence/src/routes/__root.tsx b/examples/react/search-persistence/src/routes/__root.tsx
index f144152dbff..ce726024609 100644
--- a/examples/react/search-persistence/src/routes/__root.tsx
+++ b/examples/react/search-persistence/src/routes/__root.tsx
@@ -20,19 +20,19 @@ function RootComponent() {
{' '}
prev}
activeProps={{
className: 'font-bold',
}}
+ search={{}} // You have to pass an empty object to override the persisted search params
>
Users
{' '}
prev}
activeProps={{
className: 'font-bold',
}}
+ search={{}}
>
Products
diff --git a/examples/react/search-persistence/src/routes/index.tsx b/examples/react/search-persistence/src/routes/index.tsx
index bf0e7557391..fd79fe684b8 100644
--- a/examples/react/search-persistence/src/routes/index.tsx
+++ b/examples/react/search-persistence/src/routes/index.tsx
@@ -19,7 +19,6 @@ function HomeComponent() {
-
🧪
Test Restoration Patterns
diff --git a/examples/react/search-persistence/src/routes/users.$userId.tsx b/examples/react/search-persistence/src/routes/users.$userId.tsx
new file mode 100644
index 00000000000..f3e2f96d753
--- /dev/null
+++ b/examples/react/search-persistence/src/routes/users.$userId.tsx
@@ -0,0 +1,357 @@
+import {
+ createFileRoute,
+ retainSearchParams,
+ persistSearchParams,
+ Link,
+} from '@tanstack/react-router'
+import { z } from 'zod'
+
+const userDetailSearchSchema = z.object({
+ name: z.string().optional().catch(''),
+ status: z.enum(['active', 'inactive', 'all']).optional().catch('all'),
+ page: z.number().optional().catch(0),
+ tab: z.enum(['profile', 'activity', 'settings']).optional().catch('profile'),
+})
+
+export type UserDetailSearchSchema = z.infer
+
+export const Route = createFileRoute('/users/$userId')({
+ validateSearch: userDetailSearchSchema,
+ search: {
+ middlewares: [
+ retainSearchParams(['name', 'status', 'page']),
+ persistSearchParams(['tab']),
+ ],
+ },
+ component: UserDetailComponent,
+})
+
+const mockUsers = [
+ {
+ id: 1,
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ status: 'active',
+ department: 'Engineering',
+ role: 'Senior Developer',
+ },
+ {
+ id: 2,
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ status: 'inactive',
+ department: 'Marketing',
+ role: 'Marketing Manager',
+ },
+ {
+ id: 3,
+ name: 'Charlie Brown',
+ email: 'charlie@example.com',
+ status: 'active',
+ department: 'Design',
+ role: 'UI Designer',
+ },
+ {
+ id: 4,
+ name: 'Diana Ross',
+ email: 'diana@example.com',
+ status: 'active',
+ department: 'Sales',
+ role: 'Sales Director',
+ },
+ {
+ id: 5,
+ name: 'Edward Norton',
+ email: 'edward@example.com',
+ status: 'inactive',
+ department: 'Engineering',
+ role: 'DevOps Engineer',
+ },
+ {
+ id: 6,
+ name: 'Fiona Apple',
+ email: 'fiona@example.com',
+ status: 'active',
+ department: 'Product',
+ role: 'Product Manager',
+ },
+ {
+ id: 7,
+ name: 'George Lucas',
+ email: 'george@example.com',
+ status: 'active',
+ department: 'Engineering',
+ role: 'Tech Lead',
+ },
+ {
+ id: 8,
+ name: 'Helen Hunt',
+ email: 'helen@example.com',
+ status: 'inactive',
+ department: 'HR',
+ role: 'HR Manager',
+ },
+ {
+ id: 9,
+ name: 'Ian McKellen',
+ email: 'ian@example.com',
+ status: 'active',
+ department: 'Legal',
+ role: 'Legal Counsel',
+ },
+ {
+ id: 10,
+ name: 'Julia Roberts',
+ email: 'julia@example.com',
+ status: 'active',
+ department: 'Finance',
+ role: 'CFO',
+ },
+ {
+ id: 11,
+ name: 'Kevin Costner',
+ email: 'kevin@example.com',
+ status: 'inactive',
+ department: 'Operations',
+ role: 'Operations Manager',
+ },
+ {
+ id: 12,
+ name: 'Lisa Simpson',
+ email: 'lisa@example.com',
+ status: 'active',
+ department: 'Engineering',
+ role: 'Junior Developer',
+ },
+]
+
+function UserDetailComponent() {
+ const { userId } = Route.useParams()
+ const search = Route.useSearch()
+ const navigate = Route.useNavigate()
+
+ const user = mockUsers.find((u) => u.id.toString() === userId)
+
+ if (!user) {
+ return (
+
+
User Not Found
+
+ ← Back to Users
+
+
+ )
+ }
+
+ const updateTab = (tab: UserDetailSearchSchema['tab']) => {
+ const newSearch = {
+ name: search.name,
+ status: search.status,
+ page: search.page,
+ tab: tab,
+ }
+
+ navigate({
+ search: newSearch,
+ })
+ }
+
+ return (
+
+
+
+
+ 🔄 Retained from Users List (retainSearchParams):
+
+
+
+ Name Filter:{' '}
+
+ {search.name || '(none)'}
+
+
+
+ Status Filter:{' '}
+
+ {search.status || 'all'}
+
+
+
+ Page:{' '}
+
+ {search.page || 0}
+
+
+
+
+ These search parameters were retained from the Users list when you
+ navigated here!
+
+
+
+
+
+ 💾 Persisted on This Route (persistSearchParams):
+
+
+
+ Active Tab:{' '}
+
+ {search.tab || 'profile'}
+
+
+
+ Route: /users/{userId}
+
+
+
+ This tab selection will be restored when you navigate back to this
+ user!
+
+
+
+
+
+
+
+ {user.name
+ .split(' ')
+ .map((n) => n[0])
+ .join('')}
+
+
+
{user.name}
+
+ {user.role} • {user.department}
+
+
+
+
+ {user.status}
+
+
+
+
+
+
+ {(['profile', 'activity', 'settings'] as const).map((tab) => {
+ return (
+ updateTab(tab)}
+ className={`py-2 px-1 border-b-2 font-medium text-sm ${
+ search.tab === tab
+ ? 'border-blue-500 text-blue-600'
+ : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
+ }`}
+ >
+ {tab.charAt(0).toUpperCase() + tab.slice(1)}
+
+ )
+ })}
+
+
+
+
+ {search.tab === 'profile' && (
+
+
Profile Information
+
+
+
+ Email
+
+
{user.email}
+
+
+
+ Department
+
+
+ {user.department}
+
+
+
+
+ Role
+
+
{user.role}
+
+
+
+ User ID
+
+
{user.id}
+
+
+
+ )}
+
+ {search.tab === 'activity' && (
+
+
Recent Activity
+
+
+
+ Logged in to the system
+
+
2 hours ago
+
+
+
+ Updated profile information
+
+
1 day ago
+
+
+
Changed password
+
3 days ago
+
+
+
+ )}
+
+ {search.tab === 'settings' && (
+
+
User Settings
+
+
+
+ Email Notifications
+
+
+
+
+
+ Two-Factor Authentication
+
+
+
+
+
+ Profile Visibility
+
+
+ Public
+ Private
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/examples/react/search-persistence/src/routes/users.tsx b/examples/react/search-persistence/src/routes/users.tsx
index bc88d3db5ba..14438ed77dc 100644
--- a/examples/react/search-persistence/src/routes/users.tsx
+++ b/examples/react/search-persistence/src/routes/users.tsx
@@ -2,6 +2,11 @@ import {
createFileRoute,
useNavigate,
persistSearchParams,
+ retainSearchParams,
+ stripSearchParams,
+ Link,
+ Outlet,
+ useLocation,
} from '@tanstack/react-router'
import { z } from 'zod'
import React from 'react'
@@ -18,7 +23,11 @@ export type UsersSearchSchema = z.infer
export const Route = createFileRoute('/users')({
validateSearch: usersSearchSchema,
search: {
- middlewares: [persistSearchParams(['name', 'status', 'page'])],
+ middlewares: [
+ retainSearchParams(['name', 'status', 'page']),
+ persistSearchParams(['name', 'status', 'page']),
+ stripSearchParams(['limit']),
+ ],
},
component: UsersComponent,
})
@@ -71,6 +80,12 @@ const mockUsers = [
function UsersComponent() {
const search = Route.useSearch()
const navigate = useNavigate()
+ const location = useLocation()
+
+ // Extract userId from pathname like "/users/123"
+ const currentUserId = location.pathname.startsWith('/users/')
+ ? location.pathname.split('/users/')[1]?.split('?')[0]
+ : null
const filteredUsers = React.useMemo(() => {
let users = mockUsers
@@ -102,6 +117,19 @@ function UsersComponent() {
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!
+
+
+
{filteredUsers.map((user) => (
-
-
{user.name}
-
{user.email}
-
{user.status}
+
+
+
+
{user.name}
+
{user.email}
+
{user.status}
+
+ {currentUserId === user.id.toString() ? (
+
prev}
+ className="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded text-sm"
+ >
+ ← Back
+
+ ) : (
+
+ View Details
+
+ )}
+
+ {currentUserId === user.id.toString() && (
+
+
+
+ )}
))}
diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts
index bb0bf29aba0..b88e043b311 100644
--- a/packages/router-core/src/route.ts
+++ b/packages/router-core/src/route.ts
@@ -75,6 +75,12 @@ 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
= {
@@ -84,10 +90,19 @@ export type SearchMiddlewareContext = {
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 4ddf85452f7..65be3b9e215 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -62,6 +62,7 @@ import type {
RouteContextOptions,
RouteMask,
SearchMiddleware,
+ SearchMiddlewareContext,
} from './route'
import type {
FullSearchSchema,
@@ -1352,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
}
@@ -1467,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}`)
}
}
}
@@ -1489,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),
@@ -1503,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))
}
@@ -1530,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,
@@ -1542,20 +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
@@ -1614,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,
)
@@ -2295,8 +2317,8 @@ export class RouterCore<
this.basepath,
baseLocation.pathname,
{
- ...opts,
- to: next.pathname,
+ ...opts,
+ to: next.pathname,
},
this.parsePathnameCache,
) as any
@@ -2651,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,
)
@@ -2684,7 +2706,7 @@ export function getMatchedRoutes({
}
} else {
foundRoute = route
- routeParams = matchedParams
+ routeParams = matchedParams
break
}
}
@@ -2715,101 +2737,117 @@ function applySearchMiddleware({
destRoutes,
_includeValidateSearch,
router,
+ currentLocationMatches,
+ destPathname,
}: {
search: any
dest: BuildNextOptions
destRoutes: Array
_includeValidateSearch: boolean | undefined
router: { options: { searchPersistenceStore?: any } }
+ currentLocationMatches: Array
+ destPathname: string
}) {
const allMiddlewares: Array<{
middleware: SearchMiddleware
route: { id: string; fullPath: string }
- }> =
- destRoutes.reduce(
- (acc, route) => {
- const middlewares: Array> = []
-
- if ('search' in route.options) {
- if (route.options.search?.middlewares) {
- middlewares.push(...route.options.search.middlewares)
+ }> = 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.map((middleware) => ({
- middleware,
- route: { id: route.id, fullPath: route.fullPath },
- })),
- )
- },
- [] as Array<{
- middleware: SearchMiddleware
- route: { id: string; fullPath: string }
- }>,
- ) ?? []
+ middlewares.push(validate)
+ }
+
+ return acc.concat(
+ middlewares.map((middleware) => ({
+ middleware,
+ route: { id: route.id, fullPath: route.fullPath },
+ })),
+ )
+ },
+ [] as Array<{
+ middleware: SearchMiddleware
+ route: { id: string; fullPath: string }
+ }>,
+ )
- // the chain ends here since `next` is not called
const final = {
middleware: ({ search }: { search: any }) => {
if (!dest.search) {
- return {}
+ return search
}
if (dest.search === true) {
return search
@@ -2833,11 +2871,18 @@ function applySearchMiddleware({
return applyNext(index + 1, newSearch)
}
- return middleware({
+ const middlewareFunction =
+ typeof middleware === 'function' ? middleware : middleware.middleware
+
+ return middlewareFunction({
search: currentSearch,
next,
route: { id: route.id, fullPath: route.fullPath },
- router,
+ router: {
+ ...router,
+ state: (router as any).__store?.state,
+ destPathname,
+ },
})
}
diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts
index 724220f36e6..a74380163ed 100644
--- a/packages/router-core/src/searchMiddleware.ts
+++ b/packages/router-core/src/searchMiddleware.ts
@@ -1,47 +1,46 @@
import { Store } from '@tanstack/store'
import { deepEqual, replaceEqualDeep } from './utils'
-import type { NoInfer, PickOptional } from './utils'
-import type { AnyRoute, SearchMiddleware } from './route'
-import type { IsRequiredParams } from './link'
+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 {} as TSearchSchema
+ 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,7 +51,7 @@ export function stripSearchParams<
},
)
}
- return result as TSearchSchema
+ return result as any
}
}
@@ -228,86 +227,70 @@ export function getSearchPersistenceStore<
}
}
-export function persistSearchParams(
+export function persistSearchParams>(
persistedSearchParams: Array,
exclude?: Array,
-): SearchMiddleware {
- return ({ search, next, route, router }) => {
- const store = router.options.searchPersistenceStore as
- | SearchPersistenceStore
- | undefined
-
- if (!store) {
- return next(search)
- }
-
- const searchRecord = search as Record
- const allowedKeysStr = persistedSearchParams.map((key) => String(key))
- const filteredSearch = Object.fromEntries(
- Object.entries(searchRecord).filter(([key]) =>
- allowedKeysStr.includes(key),
- ),
- ) as TSearchSchema
+): SearchMiddlewareObject {
+ return {
+ middleware: ({ search, next, router }) => {
+ const store = router.options.searchPersistenceStore as
+ | SearchPersistenceStore
+ | undefined
- const savedSearch = store.getSearch(route.id)
- const searchToProcess = filteredSearch
+ if (!store) {
+ return next(search)
+ }
- if (savedSearch && Object.keys(savedSearch).length > 0) {
- const currentSearch = filteredSearch as Record
- const isEmpty = Object.keys(currentSearch).length === 0
+ const storageKey = router.destPathname || ''
- if (isEmpty) {
- // Skip router validation for restored data since we know it's valid
- // This prevents Zod .catch() defaults from overriding our restored values
- const result = savedSearch as TSearchSchema
+ const savedSearch = store.getSearch(storageKey)
- const resultRecord = result as Record
- const persistedKeysStr = persistedSearchParams.map((key) => String(key))
- const paramsToSave = Object.fromEntries(
- Object.entries(resultRecord).filter(([key]) =>
- persistedKeysStr.includes(key),
- ),
- )
+ let searchToProcess = search
- const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
- const filteredResult = Object.fromEntries(
- Object.entries(paramsToSave).filter(
- ([key]) => !excludeKeys.includes(key),
- ),
+ 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),
)
-
- if (Object.keys(filteredResult).length > 0) {
- store.saveSearch(route.id, filteredResult)
- }
-
- return result
+ 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)
+ const result = next(searchToProcess)
- // Save only the allowed parameters for persistence
- const resultRecord = result as Record
- const persistedKeysStr = persistedSearchParams.map((key) => String(key))
- const paramsToSave = Object.fromEntries(
- Object.entries(resultRecord).filter(([key]) =>
- persistedKeysStr.includes(key),
- ),
- )
+ // 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),
- ),
- )
+ const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
+ const filteredResult = Object.fromEntries(
+ Object.entries(paramsToSave).filter(
+ ([key]) => !excludeKeys.includes(key),
+ ),
+ )
- // Only save if we have actual search params to persist
- // Don't save empty objects as they overwrite existing data
- if (Object.keys(filteredResult).length > 0) {
- store.saveSearch(route.id, filteredResult)
- }
+ if (Object.keys(filteredResult).length > 0) {
+ store.saveSearch(storageKey, filteredResult)
+ }
- return result
+ return result
+ },
+ inheritParentMiddlewares: false,
}
}
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 00000000000..64c54dd52cf
--- /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 00000000000..0e4201dff46
--- /dev/null
+++ b/port-tanstack-router-e2e-react-basic-esbuild-file-based.txt
@@ -0,0 +1 @@
+49613
\ No newline at end of file