Skip to main content

Chapter 18: Search Parameters

URL search parameters are one of TanStack Router's strongest features. Instead of hiding filter state in component state or a global store, put it in the URL. This gives you shareable URLs, browser history integration, and type safety.

Defining Search Params with Validation

// src/routes/_authenticated/projects/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod"; // Or Effect Schema

const projectSearchSchema = z.object({
status: z.enum(["active", "archived", "completed"]).optional(),
search: z.string().optional(),
priority: z.enum(["low", "medium", "high"]).optional(),
page: z.number().int().positive().default(1),
pageSize: z.number().int().min(10).max(100).default(20),
sortBy: z.enum(["name", "createdAt", "updatedAt", "priority"]).default("createdAt"),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
});

export const Route = createFileRoute("/_authenticated/projects/")({
validateSearch: projectSearchSchema,
component: ProjectListPage,
});

Now every search parameter is:

  • Typed — TypeScript knows the exact shape
  • Validated — invalid values are caught and defaults applied
  • Serialized — automatically converted to/from URL strings

Reading Search Params

function ProjectListPage() {
// Type-safe access to validated search params
const { status, search, page, pageSize, sortBy, sortOrder } = Route.useSearch();
// All fields are fully typed with their validated types

const projects = useProjects({
status,
search,
page,
pageSize,
sortBy,
sortOrder,
});

return (
<div>
<ProjectFilters />
<ProjectList projects={projects.data} />
<Pagination
page={page}
pageSize={pageSize}
total={projects.data?.total ?? 0}
/>
</div>
);
}

Updating Search Params

With useNavigate

function ProjectFilters() {
const search = Route.useSearch();
const navigate = useNavigate();

const setFilter = (key: string, value: string | number | undefined) => {
navigate({
search: (prev) => ({
...prev,
[key]: value,
page: 1, // Reset page when filters change
}),
});
};

return (
<div className="flex gap-3">
<Select
value={search.status ?? "all"}
onValueChange={(v) => setFilter("status", v === "all" ? undefined : v)}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
</SelectContent>
</Select>

<Input
value={search.search ?? ""}
onChange={(e) => setFilter("search", e.target.value || undefined)}
placeholder="Search projects..."
className="w-64"
/>
</div>
);
}
// Pagination with Link — search params are type-safe
function Pagination({ page, total, pageSize }: PaginationProps) {
const totalPages = Math.ceil(total / pageSize);

return (
<div className="flex gap-2">
<Link
to="."
search={(prev) => ({ ...prev, page: Math.max(1, page - 1) })}
disabled={page <= 1}
>
Previous
</Link>

<span>{page} of {totalPages}</span>

<Link
to="."
search={(prev) => ({ ...prev, page: Math.min(totalPages, page + 1) })}
disabled={page >= totalPages}
>
Next
</Link>
</div>
);
}

URL as State: Advanced Patterns

Debounced Search Input

import { useDebounce } from "@/shared/hooks/use-debounce";

function SearchInput() {
const { search: searchQuery } = Route.useSearch();
const navigate = useNavigate();
const [localValue, setLocalValue] = useState(searchQuery ?? "");
const debouncedValue = useDebounce(localValue, 300);

// Sync debounced value to URL
useEffect(() => {
navigate({
search: (prev) => ({
...prev,
search: debouncedValue || undefined,
page: 1,
}),
replace: true, // Don't create history entry for each keystroke
});
}, [debouncedValue, navigate]);

return (
<Input
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
placeholder="Search..."
/>
);
}

Tab State in URL

const projectDetailSearch = z.object({
tab: z.enum(["overview", "tasks", "members", "settings"]).default("overview"),
});

export const Route = createFileRoute("/_authenticated/projects/$projectId")({
validateSearch: projectDetailSearch,
component: ProjectDetailPage,
});

function ProjectDetailPage() {
const { tab } = Route.useSearch();
const navigate = useNavigate();

return (
<Tabs
value={tab}
onValueChange={(newTab) =>
navigate({ search: (prev) => ({ ...prev, tab: newTab }), replace: true })
}
>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="tasks">Tasks</TabsTrigger>
<TabsTrigger value="members">Members</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
{/* ... tab content */}
</Tabs>
);
}

Now the URL reads /projects/proj-123?tab=tasks — shareable, bookmarkable, and browser-back-button aware.

When to Use Search Params vs. Component State

State TypeUse Search ParamsUse Component State
Filters & sorting
Pagination
Active tabMaybe (if tabs are minor UI detail)
Modal open/closed❌ (usually)
Form input values
Hover/focus state
Dropdown open

Rule of thumb: If the state should survive a page refresh or be shareable via URL, use search params. If it is ephemeral UI state, use component state.

Summary

  • Validated search params with runtime validation and default values
  • Type-safe reading via Route.useSearch()
  • Type-safe updating via navigate({ search: ... }) or Link search
  • URL as state — filters, pagination, tabs are all bookmarkable and shareable
  • Debounced search with local state synced to URL after delay
  • ✅ Use replace: true for state changes that should not create history entries