Chapter 45: Search
Search is how users find things in your application. This chapter covers client-side search, server-side full-text search with Prisma, and UX patterns for a great search experience.
Client-Side Search (Small Datasets)​
For datasets under ~1000 items, filter on the client:
// shared/hooks/use-search.ts
import { useMemo, useState } from "react";
export function useClientSearch<T>(
items: T[],
searchFn: (item: T, query: string) => boolean
) {
const [query, setQuery] = useState("");
const results = useMemo(() => {
if (!query.trim()) return items;
const lowerQuery = query.toLowerCase();
return items.filter((item) => searchFn(item, lowerQuery));
}, [items, query, searchFn]);
return { query, setQuery, results };
}
// Usage
function ProjectList({ projects }: { projects: Project[] }) {
const { query, setQuery, results } = useClientSearch(
projects,
(project, q) =>
project.name.toLowerCase().includes(q) ||
(project.description?.toLowerCase().includes(q) ?? false)
);
return (
<div>
<SearchInput value={query} onChange={setQuery} placeholder="Search projects..." />
<ProjectGrid projects={results} />
</div>
);
}
Debounced Search Input​
// shared/components/forms/search-input.tsx
import { useEffect, useState } from "react";
import { useDebounce } from "@/shared/hooks/use-debounce";
import { Search, X } from "lucide-react";
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
debounceMs?: number;
}
export function SearchInput({
value: externalValue,
onChange,
placeholder = "Search...",
debounceMs = 300,
}: SearchInputProps) {
const [localValue, setLocalValue] = useState(externalValue);
const debouncedValue = useDebounce(localValue, debounceMs);
useEffect(() => {
onChange(debouncedValue);
}, [debouncedValue, onChange]);
// Sync external changes
useEffect(() => {
setLocalValue(externalValue);
}, [externalValue]);
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="search"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
placeholder={placeholder}
className="h-10 w-full rounded-md border border-input bg-background pl-10 pr-10 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{localValue && (
<button
onClick={() => { setLocalValue(""); onChange(""); }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
}
Server-Side Search with Prisma​
For large datasets, search on the server:
// In your Prisma repository
searchProjects: (query, orgId, page, pageSize) =>
Effect.tryPromise({
try: () => prisma.project.findMany({
where: {
organizationId: orgId,
deletedAt: null,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
],
},
orderBy: { updatedAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
catch: (e) => new DatabaseError({ operation: "project.search", cause: e }),
}),
PostgreSQL Full-Text Search​
For better search quality with PostgreSQL:
-- Add a search vector column
ALTER TABLE projects ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B')
) STORED;
-- Add GIN index for fast search
CREATE INDEX projects_search_idx ON projects USING GIN (search_vector);
// Use raw query for full-text search
searchProjects: (query, orgId) =>
Effect.tryPromise({
try: () => prisma.$queryRaw`
SELECT *
FROM projects
WHERE organization_id = ${orgId}
AND deleted_at IS NULL
AND search_vector @@ plainto_tsquery('english', ${query})
ORDER BY ts_rank(search_vector, plainto_tsquery('english', ${query})) DESC
LIMIT 20
`,
catch: (e) => new DatabaseError({ operation: "project.fullTextSearch", cause: e }),
}),
Search with URL State​
// Route search params
const searchParams = z.object({
q: z.string().optional(),
type: z.enum(["projects", "tasks", "all"]).default("all"),
page: z.number().default(1),
});
export const Route = createFileRoute("/_authenticated/search")({
validateSearch: searchParams,
component: SearchPage,
});
function SearchPage() {
const { q, type, page } = Route.useSearch();
const navigate = useNavigate();
const { data, isLoading } = useQuery({
queryKey: ["search", q, type, page],
queryFn: () => AppRuntime.runPromise(SearchService.search({ query: q, type, page })),
enabled: !!q,
});
return (
<div>
<SearchInput
value={q ?? ""}
onChange={(value) =>
navigate({ search: (prev) => ({ ...prev, q: value || undefined, page: 1 }) })
}
/>
{/* Type tabs */}
<div className="flex gap-2 mt-4">
{["all", "projects", "tasks"].map((t) => (
<Button
key={t}
variant={type === t ? "default" : "outline"}
size="sm"
onClick={() => navigate({ search: (prev) => ({ ...prev, type: t, page: 1 }) })}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</Button>
))}
</div>
{/* Results with highlight */}
{data?.items.map((result) => (
<SearchResult key={result.id} result={result} query={q ?? ""} />
))}
</div>
);
}
Search Result Highlighting​
function highlightMatch(text: string, query: string): React.ReactNode {
if (!query) return text;
const regex = new RegExp(`(${escapeRegex(query)})`, "gi");
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-yellow-200 dark:bg-yellow-800 rounded-sm px-0.5">
{part}
</mark>
) : (
part
)
);
}
function escapeRegex(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
Summary​
- ✅ Client-side search for small datasets with
useMemofiltering - ✅ Debounced input prevents excessive API calls
- ✅ Server-side search with Prisma
containsfor basic matching - ✅ PostgreSQL full-text search with
tsvectorfor production quality - ✅ URL state for shareable, bookmarkable search results
- ✅ Result highlighting shows users why a result matched