Skip to main content
RapidDev - Software Development Agency

How to Build Search filtering and sorting with V0

Build a faceted search system with full-text search, multi-filter sidebar, and sortable columns using V0 with Next.js and Supabase. You'll create debounced search with URL state sync, dynamic filter facets, and server-rendered results — all in about 1-2 hours with shareable filtered URLs that work out of the box.

What you'll build

  • Full-text search bar with 300ms debounce and URL state synchronization via useSearchParams
  • Multi-facet filter sidebar with dynamic checkbox groups and select dropdowns from Supabase
  • Sortable data table with column headers that toggle ascending/descending sort order
  • Active filter chips displayed as removable Badge components above results
  • Skeleton loading states during search and filter transitions
  • Server-rendered search results page with shareable URLs preserving all filter state
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate13 min read1-2 hoursV0 FreeApril 2026RapidDev Engineering Team
TL;DR

Build a faceted search system with full-text search, multi-filter sidebar, and sortable columns using V0 with Next.js and Supabase. You'll create debounced search with URL state sync, dynamic filter facets, and server-rendered results — all in about 1-2 hours with shareable filtered URLs that work out of the box.

What you're building

Any data-heavy app — product catalogs, job boards, directories — needs powerful search and filtering. Users expect to type a query, narrow results with facets, sort by relevance or price, and share the exact filtered view with a colleague via URL. Without this, your users scroll endlessly or leave frustrated.

V0 accelerates this dramatically. You describe the search bar, filter sidebar, and results table as separate prompts, and V0 generates the Next.js components with shadcn/ui. Use prompt queuing to queue all three while V0 builds each sequentially. Supabase handles full-text search with tsvector indexes and GIN indexes for blazing performance.

The architecture uses an App Router Server Component at app/search/page.tsx that reads searchParams for SSR-friendly URLs, an API route at app/api/search/route.ts for complex full-text queries with ts_rank ordering, and a Server Action for saving search presets. The UI is built entirely with shadcn/ui components.

Final result

A production-ready search interface with full-text search, multi-facet filtering, sortable columns, active filter chips, and shareable URL state that renders server-side for SEO.

Tech stack

V0AI Code Generator
Next.jsFull-Stack Framework
Tailwind CSSStyling
shadcn/uiComponent Library
SupabaseDatabase

Prerequisites

  • A V0 account (free tier works for this project)
  • A Supabase project (free tier works — connect via V0's Connect panel)
  • Sample data to search through (products, listings, or any structured data)
  • Basic understanding of how search and filters work from a user perspective

Build steps

1

Set up the database schema with full-text search indexes

Open V0 and create a new project. Use the Connect panel to add Supabase. Then prompt V0 to create the items table with a tsvector column and GIN index for full-text search, plus a filter_options table for dynamic filter definitions.

prompt.txt
1// Paste this prompt into V0's AI chat:
2// Create a Supabase schema for a searchable product catalog:
3// 1. items table: id (uuid PK), title (text), description (text), category (text), price (numeric), tags (text array), created_at (timestamptz), search_vector (tsvector)
4// 2. Add a GIN index on search_vector and btree indexes on category and price
5// 3. Create a trigger that auto-updates search_vector from title and description using to_tsvector('english', title || ' ' || description)
6// 4. filter_options table: id (uuid PK), filter_group (text), label (text), value (text) for dynamic filter definitions
7// 5. Seed with 20 sample products across 4 categories with varied prices
8// 6. Add RLS policies allowing public read access
9// Generate the SQL migration.

Pro tip: Use V0's prompt queuing — queue the schema prompt first, then immediately queue the search bar component prompt and the filter sidebar prompt. V0 will build them sequentially while you review each output.

Expected result: Supabase is connected, tables are created with GIN indexes, and 20 sample products are seeded with full-text search vectors auto-generated.

2

Build the search bar with debounced URL state sync

Create a client component for the search bar that debounces input by 300ms and updates URL search params without full page reloads. This enables shareable URLs — every search state is reflected in the URL.

components/search-bar.tsx
1'use client'
2
3import { useSearchParams, useRouter, usePathname } from 'next/navigation'
4import { useCallback, useState, useEffect } from 'react'
5import { Input } from '@/components/ui/input'
6
7export function SearchBar() {
8 const searchParams = useSearchParams()
9 const router = useRouter()
10 const pathname = usePathname()
11 const [query, setQuery] = useState(searchParams.get('q') ?? '')
12
13 const updateSearch = useCallback(
14 (value: string) => {
15 const params = new URLSearchParams(searchParams.toString())
16 if (value) {
17 params.set('q', value)
18 } else {
19 params.delete('q')
20 }
21 params.delete('page')
22 router.push(`${pathname}?${params.toString()}`)
23 },
24 [searchParams, router, pathname]
25 )
26
27 useEffect(() => {
28 const timer = setTimeout(() => updateSearch(query), 300)
29 return () => clearTimeout(timer)
30 }, [query, updateSearch])
31
32 return (
33 <Input
34 placeholder="Search products..."
35 value={query}
36 onChange={(e) => setQuery(e.target.value)}
37 className="w-full max-w-md"
38 />
39 )
40}

Expected result: A search bar that updates the URL with a ?q= parameter after 300ms of inactivity. Typing 'laptop' updates the URL to /search?q=laptop without a full page reload.

3

Create the filter sidebar with dynamic facets

Build a filter sidebar that reads available filter options from Supabase and renders checkbox groups and select dropdowns. Each filter selection updates the URL search params so the state is always shareable.

components/filter-sidebar.tsx
1'use client'
2
3import { useSearchParams, useRouter, usePathname } from 'next/navigation'
4import { Checkbox } from '@/components/ui/checkbox'
5import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
6import { Badge } from '@/components/ui/badge'
7import { Button } from '@/components/ui/button'
8
9interface FilterOption {
10 filter_group: string
11 label: string
12 value: string
13}
14
15export function FilterSidebar({ options }: { options: FilterOption[] }) {
16 const searchParams = useSearchParams()
17 const router = useRouter()
18 const pathname = usePathname()
19
20 const groups = options.reduce((acc, opt) => {
21 if (!acc[opt.filter_group]) acc[opt.filter_group] = []
22 acc[opt.filter_group].push(opt)
23 return acc
24 }, {} as Record<string, FilterOption[]>)
25
26 function toggleFilter(group: string, value: string) {
27 const params = new URLSearchParams(searchParams.toString())
28 const current = params.getAll(group)
29 if (current.includes(value)) {
30 params.delete(group)
31 current.filter((v) => v !== value).forEach((v) => params.append(group, v))
32 } else {
33 params.append(group, value)
34 }
35 params.delete('page')
36 router.push(`${pathname}?${params.toString()}`)
37 }
38
39 function clearAll() {
40 router.push(pathname)
41 }
42
43 return (
44 <aside className="w-64 space-y-6">
45 <div className="flex items-center justify-between">
46 <h3 className="font-semibold">Filters</h3>
47 <Button variant="ghost" size="sm" onClick={clearAll}>Clear all</Button>
48 </div>
49 {Object.entries(groups).map(([group, opts]) => (
50 <div key={group} className="space-y-2">
51 <h4 className="text-sm font-medium capitalize">{group}</h4>
52 {opts.map((opt) => (
53 <label key={opt.value} className="flex items-center gap-2 text-sm">
54 <Checkbox
55 checked={searchParams.getAll(group).includes(opt.value)}
56 onCheckedChange={() => toggleFilter(group, opt.value)}
57 />
58 {opt.label}
59 </label>
60 ))}
61 </div>
62 ))}
63 </aside>
64 )
65}

Pro tip: Use Design Mode (Option+D) to visually adjust the filter sidebar width, spacing, and typography at zero credit cost after V0 generates the component.

Expected result: A sidebar with checkbox groups for each filter category. Selecting 'Electronics' adds ?category=electronics to the URL. Multiple selections stack as separate URL params.

4

Build the sortable results table with active filter chips

Create the main search results page as a Server Component that reads searchParams, fetches filtered data from Supabase, and renders a sortable table. Active filters display as removable Badge chips above the results.

app/search/page.tsx
1import { createClient } from '@/lib/supabase/server'
2import { SearchBar } from '@/components/search-bar'
3import { FilterSidebar } from '@/components/filter-sidebar'
4import { Badge } from '@/components/ui/badge'
5import { Button } from '@/components/ui/button'
6import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
7import Link from 'next/link'
8
9export default async function SearchPage({
10 searchParams,
11}: {
12 searchParams: Promise<{ [key: string]: string | string[] | undefined }>
13}) {
14 const params = await searchParams
15 const q = typeof params.q === 'string' ? params.q : ''
16 const sort = typeof params.sort === 'string' ? params.sort : 'created_at'
17 const order = params.order === 'asc' ? true : false
18 const categories = Array.isArray(params.category)
19 ? params.category
20 : params.category
21 ? [params.category]
22 : []
23
24 const supabase = await createClient()
25
26 let query = supabase.from('items').select('*', { count: 'exact' })
27 if (q) query = query.textSearch('search_vector', q)
28 if (categories.length > 0) query = query.in('category', categories)
29 query = query.order(sort, { ascending: order }).limit(20)
30
31 const { data: items, count } = await query
32 const { data: filterOptions } = await supabase.from('filter_options').select('*')
33
34 return (
35 <div className="flex gap-8 p-6">
36 <FilterSidebar options={filterOptions ?? []} />
37 <main className="flex-1 space-y-4">
38 <SearchBar />
39 <div className="flex gap-2 flex-wrap">
40 {categories.map((cat) => (
41 <Badge key={cat} variant="secondary">{cat}</Badge>
42 ))}
43 </div>
44 <p className="text-sm text-muted-foreground">{count ?? 0} results</p>
45 <Table>
46 <TableHeader>
47 <TableRow>
48 <TableHead>
49 <Link href={`?${new URLSearchParams({ ...params as any, sort: 'title', order: sort === 'title' && !order ? 'asc' : 'desc' }).toString()}`}>
50 <Button variant="ghost" size="sm">Title</Button>
51 </Link>
52 </TableHead>
53 <TableHead>Category</TableHead>
54 <TableHead>
55 <Link href={`?${new URLSearchParams({ ...params as any, sort: 'price', order: sort === 'price' && !order ? 'asc' : 'desc' }).toString()}`}>
56 <Button variant="ghost" size="sm">Price</Button>
57 </Link>
58 </TableHead>
59 </TableRow>
60 </TableHeader>
61 <TableBody>
62 {items?.map((item) => (
63 <TableRow key={item.id}>
64 <TableCell className="font-medium">{item.title}</TableCell>
65 <TableCell><Badge variant="outline">{item.category}</Badge></TableCell>
66 <TableCell>${(item.price / 100).toFixed(2)}</TableCell>
67 </TableRow>
68 ))}
69 </TableBody>
70 </Table>
71 </main>
72 </div>
73 )
74}

Expected result: A full search page with a sidebar, search bar, filter chips, result count, and a sortable table. The URL reflects all state: /search?q=laptop&category=electronics&sort=price&order=asc.

5

Add the full-text search API route for complex queries

Create an API route that handles advanced full-text search with ts_rank scoring and pagination. This powers the search bar for more complex queries that need server-side ranking.

app/api/search/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import { createClient } from '@supabase/supabase-js'
3
4const supabase = createClient(
5 process.env.NEXT_PUBLIC_SUPABASE_URL!,
6 process.env.SUPABASE_SERVICE_ROLE_KEY!
7)
8
9export async function GET(req: NextRequest) {
10 const { searchParams } = new URL(req.url)
11 const q = searchParams.get('q') ?? ''
12 const page = parseInt(searchParams.get('page') ?? '1')
13 const limit = 20
14 const offset = (page - 1) * limit
15
16 if (!q) {
17 const { data, count } = await supabase
18 .from('items')
19 .select('*', { count: 'exact' })
20 .order('created_at', { ascending: false })
21 .range(offset, offset + limit - 1)
22 return NextResponse.json({ data, total: count })
23 }
24
25 const { data, error } = await supabase.rpc('search_items', {
26 search_query: q,
27 result_limit: limit,
28 result_offset: offset,
29 })
30
31 if (error) {
32 return NextResponse.json({ error: error.message }, { status: 500 })
33 }
34
35 return NextResponse.json({ data, total: data?.length ?? 0 })
36}

Pro tip: Create a Supabase RPC function 'search_items' that uses ts_rank for relevance scoring. This gives much better results than simple textSearch for multi-word queries.

6

Add a Server Action for saving search presets

Let users save their current filter combination as a named preset. This uses a Server Action to store the search params as a JSON object in Supabase, so users can reload their favorite searches later.

app/actions/search.ts
1'use server'
2
3import { createClient } from '@/lib/supabase/server'
4import { revalidatePath } from 'next/cache'
5
6export async function saveSearchPreset(
7 name: string,
8 params: Record<string, string | string[]>
9) {
10 const supabase = await createClient()
11 const { data: { user } } = await supabase.auth.getUser()
12
13 if (!user) {
14 return { error: 'Must be logged in to save presets' }
15 }
16
17 const { error } = await supabase.from('search_presets').insert({
18 user_id: user.id,
19 name,
20 params,
21 })
22
23 if (error) {
24 return { error: error.message }
25 }
26
27 revalidatePath('/search')
28 return { success: true }
29}

Expected result: Users can click 'Save this search' to store the current URL params as a named preset. Presets appear in a dropdown for quick access.

Complete code

app/search/page.tsx
1import { createClient } from '@/lib/supabase/server'
2import { SearchBar } from '@/components/search-bar'
3import { FilterSidebar } from '@/components/filter-sidebar'
4import { Badge } from '@/components/ui/badge'
5import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
6import { Button } from '@/components/ui/button'
7
8export default async function SearchPage({
9 searchParams,
10}: {
11 searchParams: Promise<{ [key: string]: string | string[] | undefined }>
12}) {
13 const params = await searchParams
14 const q = typeof params.q === 'string' ? params.q : ''
15 const sort = typeof params.sort === 'string' ? params.sort : 'created_at'
16 const order = params.order === 'asc'
17 const categories = Array.isArray(params.category)
18 ? params.category
19 : params.category ? [params.category] : []
20
21 const supabase = await createClient()
22 let query = supabase.from('items').select('*', { count: 'exact' })
23
24 if (q) query = query.textSearch('search_vector', q)
25 if (categories.length > 0) query = query.in('category', categories)
26 query = query.order(sort, { ascending: order }).limit(20)
27
28 const { data: items, count } = await query
29 const { data: filterOptions } = await supabase
30 .from('filter_options')
31 .select('*')
32
33 return (
34 <div className="flex gap-8 p-6">
35 <FilterSidebar options={filterOptions ?? []} />
36 <main className="flex-1 space-y-4">
37 <SearchBar />
38 <div className="flex gap-2 flex-wrap">
39 {categories.map((cat) => (
40 <Badge key={cat} variant="secondary">{cat}</Badge>
41 ))}
42 </div>
43 <p className="text-sm text-muted-foreground">
44 {count ?? 0} results found
45 </p>
46 <Table>
47 <TableHeader>
48 <TableRow>
49 <TableHead>Title</TableHead>
50 <TableHead>Category</TableHead>
51 <TableHead>Price</TableHead>
52 </TableRow>
53 </TableHeader>
54 <TableBody>
55 {items?.map((item) => (
56 <TableRow key={item.id}>
57 <TableCell>{item.title}</TableCell>
58 <TableCell>
59 <Badge variant="outline">{item.category}</Badge>
60 </TableCell>
61 <TableCell>${item.price?.toFixed(2)}</TableCell>
62 </TableRow>
63 ))}
64 </TableBody>
65 </Table>
66 </main>
67 </div>
68 )
69}

Customization ideas

Add price range slider

Add a min/max price range slider using shadcn/ui Slider component that filters results by price range and syncs to URL params like ?min_price=100&max_price=500.

Implement saved search alerts

Let users save searches and receive email notifications when new items match their criteria by storing search params and running a daily Vercel Cron job.

Add map view toggle

For location-based data, add a map view using react-leaflet that shows search results as pins, with clicking a pin showing the item details in a Card popup.

Enable voice search

Add a microphone button that uses the Web Speech API to convert voice input to text and populate the search bar, useful for mobile users.

Add search analytics dashboard

Track popular search queries, zero-result searches, and most-used filters in a Supabase table, then build an admin dashboard to surface content gaps.

Common pitfalls

Pitfall: Not debouncing the search input, causing a Supabase query on every keystroke

How to avoid: Use a 300ms debounce with useEffect and setTimeout. Only call router.push after the user stops typing for 300ms.

Pitfall: Storing filter state in React state instead of URL searchParams

How to avoid: Use useSearchParams and router.push to sync all filter state to URL params. The Server Component reads searchParams directly for SSR.

Pitfall: Missing GIN index on the tsvector column

How to avoid: Create a GIN index on the search_vector column: CREATE INDEX items_search_idx ON items USING GIN (search_vector). This is included in the schema setup step.

Pitfall: Using client-side filtering instead of server-side queries

How to avoid: Always filter and search in Supabase using .textSearch(), .in(), and .order(). Only the matching results are sent to the browser.

Best practices

  • Use URL searchParams as the single source of truth for all filter, sort, and search state — this gives you shareable URLs and SSR for free
  • Add Skeleton loading states from shadcn/ui for the results table during search transitions to prevent layout shift
  • Use V0's prompt queuing to build the search bar, filter sidebar, and results table as three separate prompts queued in sequence
  • Create GIN indexes on tsvector columns and btree indexes on frequently filtered columns (category, price) for fast queries
  • Use Design Mode (Option+D) to visually adjust filter sidebar width, Badge colors, and table row spacing at zero credit cost
  • Implement cursor-based pagination instead of offset pagination for large datasets to maintain consistent performance
  • Use Server Components for the search page to get server-side rendering — search results are indexable by search engines
  • Cache filter_options data using Next.js fetch with revalidate since filter definitions change rarely compared to search results

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a search and filtering system with Next.js App Router and Supabase. I need: 1) A Server Component page that reads searchParams for SSR-friendly filtering, 2) Full-text search using Supabase tsvector with GIN index, 3) Multi-facet checkbox filters that sync to URL params, 4) Sortable table columns. Help me design the Supabase schema with proper indexes and the component architecture.

Build Prompt

Create a reusable useFilterParams hook that manages URL search params for a faceted search system. The hook should support: adding/removing individual filter values, toggling sort columns with asc/desc direction, debounced text search, clearing all filters, and reading current state from URL. It should work with Next.js App Router useSearchParams and useRouter without full page reloads.

Frequently asked questions

Does Supabase full-text search work on the free tier?

Yes. Supabase free tier includes PostgreSQL full-text search with tsvector, GIN indexes, and ts_rank scoring. There are no extra costs for full-text search — it is a native PostgreSQL feature available on all Supabase plans.

How do I make search results shareable via URL?

Store all search, filter, and sort state in URL searchParams instead of React state. Use useSearchParams and router.push to update the URL on every change. The Server Component reads searchParams directly, so the URL /search?q=laptop&category=electronics renders the exact same results for anyone who opens it.

Can I use this with thousands of products without performance issues?

Yes, with proper indexing. Create a GIN index on the tsvector column for full-text search and btree indexes on columns you filter by (category, price). Supabase handles millions of rows efficiently with these indexes. Always paginate results (LIMIT 20) and never fetch all rows.

What V0 plan do I need for this project?

The free tier works. Search, filtering, and sorting only require Next.js Server Components, shadcn/ui components, and Supabase integration via the Connect panel — all available on V0 Free. Design Mode adjustments for visual polish are also free.

How do I deploy the search page to production?

Click Share in the top-right corner of V0, then Publish tab, then Publish to Production. Your search page deploys to Vercel in 30-60 seconds with the Supabase connection already configured. Alternatively, connect to GitHub via the Git panel for CI/CD deployments.

Can RapidDev help build a custom search and filtering system?

Yes. RapidDev has built 600+ apps including complex search systems with faceted filtering, geospatial search, and AI-powered semantic search. Book a free consultation to discuss your specific data structure and search requirements.

How do I add search to an existing V0 project?

Open your existing project in V0 and prompt: 'Add a search page at app/search/page.tsx with a search bar, filter sidebar, and results table using the existing items table in Supabase.' V0 will generate the components and integrate them with your existing database schema.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.