Skip to content

feat: add search persistence middleware #5004

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 292 additions & 0 deletions docs/router/framework/react/api/router/persistSearchParamsFunction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
---
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 the following parameters:

- `persistedSearchParams` (required): Array of search param keys to persist
- `exclude` (optional): Array of search param keys to exclude 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

Comment on lines +20 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Grammar nit: add article for clarity

“with empty search” → “with an empty search”.

Apply this diff:

-2. **Restoring**: Restores saved parameters when the middleware is triggered with empty search
+2. **Restoring**: Restores saved parameters when the middleware is triggered with an empty search
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
2. **Restoring**: Restores saved parameters when the middleware is triggered with empty search
2. **Restoring**: Restores saved parameters when the middleware is triggered with an empty search
🤖 Prompt for AI Agents
In docs/router/framework/react/api/router/persistSearchParamsFunction.md around
lines 20 to 21, change the phrase "with empty search" to "with an empty search"
to add the missing article for grammatical clarity; update the sentence
accordingly so it reads "Restores saved parameters when the middleware is
triggered with an empty search."

**Important**: The middleware only runs when search parameters are being processed. This means:

- **Without search prop**: `<Link to="/users">` → Middleware doesn't run → No restoration
- **With search function**: `<Link to="/users" search={(prev) => prev}>` → Middleware runs → Restoration happens
- **With explicit search**: `<Link to="/users" search={{ name: 'John' }}>` → 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.

Comment on lines +28 to +33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Document “from” vs “to” route middleware invocation to avoid confusion/leakage.

Given the known behavior that search middlewares run for both the originating (“from”) and destination (“to”) routes, the docs should call this out to explain why an explicit allow-list is required. This directly addresses the bug noted in the PR conversation.

 ## Restoration Behavior
@@
-**Recommended**: Always be explicit about restoration intent using the `search` prop.
+**Recommended**: Always be explicit about restoration intent using the `search` prop.
+
+Note: Search middlewares run for both the originating (“from”) and destination (“to”) routes involved in a navigation. To prevent unintended cross-route persistence, `persistSearchParams` requires an explicit allow‑list (`persistedSearchParams`) and supports an optional `exclude` list. This ensures only the intended keys are saved/restored for the target route.

If helpful, I can add a minimal diagram showing “from” and “to” execution points.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## 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.
## 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.
Note: Search middlewares run for both the originating (“from”) and destination (“to”) routes involved in a navigation. To prevent unintended cross-route persistence, `persistSearchParams` requires an explicit allow-list (`persistedSearchParams`) and supports an optional `exclude` list. This ensures only the intended keys are saved/restored for the target route.
🤖 Prompt for AI Agents
In docs/router/framework/react/api/router/persistSearchParamsFunction.md around
lines 28 to 33, the docs warn about unexpected restoration but don't explain
that search middleware is invoked for both the originating ("from") and
destination ("to") routes; update this section to explicitly state that
middleware runs at both "from" and "to" execution points, show how that can
cause parameter leakage, and clarify that this is why an explicit allow-list (or
explicit use of the search prop) is required; add a brief example or minimal
diagram showing the "from" and "to" invocation points to illustrate the flow.

## 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 name, status, and page
middlewares: [persistSearchParams(['name', 'status', 'page'])],
},
})
```

```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: {
// persist category, minPrice, maxPrice but exclude tempFilter
middlewares: [
persistSearchParams(['category', 'minPrice', 'maxPrice'], ['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: {
// persist category and sortOrder, exclude tempFilter and sortBy
middlewares: [
persistSearchParams(['category', 'sortOrder'], ['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 (
<div>
{/* Full restoration - restores all saved parameters */}
<Link to="/users" search={(prev) => prev}>
Users
</Link>

{/* Partial override - restore saved params but override specific ones */}
<Link
to="/products"
search={(prev) => ({ ...prev, category: 'Electronics' })}
>
Electronics Products
</Link>

{/* Clean navigation - no restoration */}
<Link to="/users">Users (clean slate)</Link>
</div>
)
}
```

### Exclusion Strategies

You have two ways to exclude parameters from persistence:

**1. Middleware-level exclusion** (permanent):

```tsx
// Persist category and minPrice, exclude tempFilter and sortBy
middlewares: [
persistSearchParams(['category', 'minPrice'], ['tempFilter', 'sortBy']),
]
```

**2. Link-level exclusion** (per navigation):

```tsx
// Restore saved params but exclude specific ones
<Link
to="/products"
search={(prev) => {
const { tempFilter, ...rest } = prev || {}
return rest
}}
>
Products (excluding temp filter)
</Link>
```

### 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 (
<Link to="/users" search={savedUsersSearch || {}}>
Users (with saved search)
</Link>
)
}
```

## 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
})
}
```
Comment on lines +189 to +206
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

SSR “Basic Setup” contradicts the per-request isolation claim; fix sample to create the store on the server too.

The comment says “per SSR request,” but the code creates a store only on the client. Align the example with the server entry that injects a per-request store.

-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
-  })
-}
+export function createAppRouter() {
+  // Create a new store per router instance.
+  // On the server, ensure this is done per request.
+  const searchPersistenceStore = new SearchPersistenceStore()
+
+  return createRouter({
+    routeTree,
+    searchPersistenceStore, // Inject the store (server: per request; client: per hydration)
+    // ... other options
+  })
+}

Follow-up: ensure the server entry (e.g., your render handler) creates a fresh router per request and does not reuse a store across requests.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### 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
})
}
```
import { createRouter, SearchPersistenceStore } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function createAppRouter() {
// Create a new store per router instance.
// On the server, ensure this is done per request.
const searchPersistenceStore = new SearchPersistenceStore()
return createRouter({
routeTree,
searchPersistenceStore, // Inject the store (server: per request; client: per hydration)
// ... other options
})
}
🤖 Prompt for AI Agents
In docs/router/framework/react/api/router/persistSearchParamsFunction.md around
lines 189 to 206, the SSR "Basic Setup" example contradicts the "per SSR
request" claim by only instantiating SearchPersistenceStore on the client;
change the sample to always create a new SearchPersistenceStore instance for
each router creation (so the server path also constructs a fresh store per
request) and update the example to show the server/render entry creating a fresh
router (and store) per request rather than reusing a global/shared store across
requests.


### 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

Comment on lines +242 to +248
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Revise “Client-only store” bullet; it conflicts with the server example and per-request isolation.

Rephrase to reflect recommended usage: create a new store per SSR request on the server; create one on the client during hydration.

-- **Client-only store**: Store is only created on client-side (`typeof window !== 'undefined'`)
- - **Hydration-safe**: No server/client mismatch issues
+- **Per-request server store**: On the server, create a new `SearchPersistenceStore` per request and pass it to the router instance.
+- **Client-side hydration**: On the client, create a `SearchPersistenceStore` during hydration (or reuse one injected via SSR) to continue from server state.
+ - **Hydration-safe**: No server/client mismatch issues
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### 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
### SSR Considerations
- **Per-request server store**: On the server, create a new `SearchPersistenceStore` per request and pass it to the router instance.
- **Client-side hydration**: On the client, create a `SearchPersistenceStore` during hydration (or reuse one injected via SSR) to continue from server state.
- **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
🧰 Tools
🪛 LanguageTool

[grammar] ~244-~244: There might be a mistake here.
Context: ...ent-only store**: Store is only created on client-side (`typeof window !== 'undefi...

(QB_NEW_EN)

🤖 Prompt for AI Agents
In docs/router/framework/react/api/router/persistSearchParamsFunction.md around
lines 242 to 248, the "Client-only store" bullet is misleading because the docs
show a server example and recommend per-request isolation; update that bullet to
state that a new store should be created per SSR request on the server and a
separate store created on the client during hydration, making clear server-side
stores are per-request (garbage collected after the request) and the client
creates its own store on hydration to avoid cross-request/state leakage.

## 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 (
<div>
<p>Saved search: {JSON.stringify(storeState['/users'])}</p>
<button onClick={clearUserSearch}>Clear saved search</button>
</div>
)
}
```
97 changes: 97 additions & 0 deletions examples/react/search-persistence/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# 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
<Link to="/users" search={(prev) => prev}>
Users (restore all)
</Link>

// Partial override - restore but override specific parameters
<Link to="/products" search={(prev) => ({ ...prev, category: 'Electronics' })}>
Electronics Products
</Link>

// Clean navigation - no restoration
<Link to="/users">
Users (clean slate)
</Link>
```

### Manual Restoration

Access the store directly for full control:

```tsx
import { getSearchPersistenceStore } from '@tanstack/react-router'

const store = getSearchPersistenceStore()
const savedSearch = store.getSearch('/users')

<Link to="/users" search={savedSearch || {}}>
Users (manual restoration)
</Link>
```

### ⚠️ 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.
Loading