Search
Best practices for implementing search functionality in web applications.
Global Recommendation
The best approach is to implement a backend-side search with a dedicated endpoint and a persistent index (DB or search engine), keeping the frontend as a pure paginated consumer. Recursive search or client-side indexing won't scale or maintain consistency once you exceed a few hundred files.
- Create a
/files/searchendpoint on your API that queries a server-side index (SQL table or dedicated engine) - Start with database + full-text index (Postgres/MySQL/SQLite) to keep infrastructure simple
- Move to ElasticSearch/Meilisearch only if you significantly exceed ~100k records or need advanced scoring/aggregations
- On the UI side: server-side search, paginated, with debounce (300–400ms) and incremental results, no complete recursive scan on client
Why Avoid Client-Side Options
Recursive Client-Side Search
- Requires traversing the entire tree on each search, so O(N) files per keystroke
- Not scalable beyond 1000+ files, very poor on mobile / slow networks, requires keeping a large state in memory
- Fragile UX: depends on user's navigation state (local cache vs reality)
Client-Side Index (Service Worker)
- Consistency problem: multi-device, multi-session, server updates not reflected, complicated invalidation
- No value for SEO or other clients (mobile app, desktop app, etc.), search logic duplicated everywhere
- Lots of complexity for limited gain when a server index is simpler to centralize
These options can remain as micro-optimizations (pre-filtering on server query results), but should not be the source of truth.
Option 1: Search Endpoint with DB (Recommended)
Minimal Data Model
Store one record per file in your DB (Postgres recommended) with:
| Field | Description |
|---|---|
id | Unique identifier |
owner_id | User / workspace |
path | Full path /projects/discord/discord_logo.svg |
name | File name |
extension | File extension |
size | File size |
created_at | Creation timestamp |
updated_at | Last update timestamp |
tags | Optional tags / favorites |
On Postgres, you can:
- Index
(owner_id, name)for fastILIKEqueries on small volumes - Set up full-text search to search in name + tags + optionally indexed content
Endpoint Design
Typical pattern:
GET /files/search
Query Parameters:
| Param | Type | Description |
|---|---|---|
q | string | Search query |
path | string | Optional, restrict to subfolder |
favorites | bool | Optional filter |
page | int | Page number |
pageSize | int | Items per page |
sortBy | string | Field to sort by |
order | string | asc or desc |
Response:
{
"data": {
"items": [
{
"id": "file_123",
"name": "discord_logo.svg",
"path": "/assets/discord",
"isFavorite": true,
"size": 12345,
"updatedAt": "2026-01-23T15:00:00Z"
}
],
"total": 42,
"page": 1,
"pageSize": 20,
"totalPages": 3
}
}This "envelope > data > items" structure is recommended for a consistent and extensible API.
Search Behavior
- Always filter by owner_id / workspace server-side
- Search options:
- Simple:
WHERE name ILIKE '%discord%'with index onLOWER(name)or trigram index for better performance - Full-text: build a
tsvectorcolumn (name + tags) and useto_tsquery/plainto_tsquery
- Simple:
- Add a deterministic
ORDER BY(e.g.,ORDER BY is_favorite DESC, updated_at DESC)
Frontend Integration (React + TanStack Query)
// Hook: useFileSearch(query, options)
const useFileSearch = (query: string, options?: SearchOptions) => {
return useQuery({
queryKey: ['files', 'search', query, options],
queryFn: () => searchFiles(query, options),
enabled: query.length > 0,
})
}UX Guidelines:
- Debounce of 300–400ms on the query
- Call
/files/searchwhenquery.length > 0 - Pagination:
page,pageSizein TanStack Query key - Loader skeleton during fetch
- "No results" if
items.length === 0andquery !== '' - Show "recent files" or "favorites" when
query === ''
Option 2: ElasticSearch / Meilisearch (At Scale)
When It Becomes Relevant
- Volumes of hundreds of thousands to millions of files
- Need for advanced scoring, suggestions, typo-tolerance, facets (by type, size, dates)
- Need for aggregations (file statistics) and ultra-low latency for complex queries
Postgres/SQL full-text is generally sufficient up to ~100k documents, with acceptable latencies (under 200ms) if properly indexed.
Basic Architecture
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Database │────▶│ Sync Worker │────▶│ Search │
│ (Source) │ │ │ │ Engine │
└─────────────┘ └──────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ /search │
│ endpoint │
└─────────────┘
- DB remains the source of truth
- A job or worker syncs files to the search engine
- The
/files/searchAPI queries exclusively the search engine and returns unified format
Trade-off: You add a service to maintain, indexing pipelines, and a slight lag between DB and index.
Key Implementation Points
| Principle | Description |
|---|---|
| Always server-side | Search must happen backend-side, never via complete client scan |
| Dedicated index | Table or full-text column with index, not just LIKE '%...%' without index |
| Clear API | Single /files/search endpoint, GET with well-defined filtering/pagination params |
| Mandatory pagination | Limit results per page, always return total and page for smooth UX |
| Debounce and cache | Frontend debounce + TanStack Query to avoid spamming backend while keeping UX reactive |
| Evolvable | Design API to switch search layer later (SQL → Meilisearch/ES) without changing frontend contract |
Quick Reference
// Debounced search input
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 300)
// Search hook with pagination
const { data, isLoading } = useFileSearch(debouncedQuery, {
page: 1,
pageSize: 20,
sortBy: 'updatedAt',
order: 'desc'
})