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
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
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.
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 price5// 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 definitions7// 5. Seed with 20 sample products across 4 categories with varied prices8// 6. Add RLS policies allowing public read access9// 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.
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.
1'use client'23import { useSearchParams, useRouter, usePathname } from 'next/navigation'4import { useCallback, useState, useEffect } from 'react'5import { Input } from '@/components/ui/input'67export function SearchBar() {8 const searchParams = useSearchParams()9 const router = useRouter()10 const pathname = usePathname()11 const [query, setQuery] = useState(searchParams.get('q') ?? '')1213 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 )2627 useEffect(() => {28 const timer = setTimeout(() => updateSearch(query), 300)29 return () => clearTimeout(timer)30 }, [query, updateSearch])3132 return (33 <Input34 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.
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.
1'use client'23import { 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'89interface FilterOption {10 filter_group: string11 label: string12 value: string13}1415export function FilterSidebar({ options }: { options: FilterOption[] }) {16 const searchParams = useSearchParams()17 const router = useRouter()18 const pathname = usePathname()1920 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 acc24 }, {} as Record<string, FilterOption[]>)2526 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 }3839 function clearAll() {40 router.push(pathname)41 }4243 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 <Checkbox55 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.
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.
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'89export default async function SearchPage({10 searchParams,11}: {12 searchParams: Promise<{ [key: string]: string | string[] | undefined }>13}) {14 const params = await searchParams15 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 : false18 const categories = Array.isArray(params.category)19 ? params.category20 : params.category21 ? [params.category]22 : []2324 const supabase = await createClient()2526 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)3031 const { data: items, count } = await query32 const { data: filterOptions } = await supabase.from('filter_options').select('*')3334 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.
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.
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.NEXT_PUBLIC_SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export 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 = 2014 const offset = (page - 1) * limit1516 if (!q) {17 const { data, count } = await supabase18 .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 }2425 const { data, error } = await supabase.rpc('search_items', {26 search_query: q,27 result_limit: limit,28 result_offset: offset,29 })3031 if (error) {32 return NextResponse.json({ error: error.message }, { status: 500 })33 }3435 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.
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.
1'use server'23import { createClient } from '@/lib/supabase/server'4import { revalidatePath } from 'next/cache'56export 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()1213 if (!user) {14 return { error: 'Must be logged in to save presets' }15 }1617 const { error } = await supabase.from('search_presets').insert({18 user_id: user.id,19 name,20 params,21 })2223 if (error) {24 return { error: error.message }25 }2627 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
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'78export default async function SearchPage({9 searchParams,10}: {11 searchParams: Promise<{ [key: string]: string | string[] | undefined }>12}) {13 const params = await searchParams14 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.category19 : params.category ? [params.category] : []2021 const supabase = await createClient()22 let query = supabase.from('items').select('*', { count: 'exact' })2324 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)2728 const { data: items, count } = await query29 const { data: filterOptions } = await supabase30 .from('filter_options')31 .select('*')3233 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 found45 </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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation