# How to Build Search filtering and sorting with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Free
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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.

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

```
'use client'

import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { useCallback, useState, useEffect } from 'react'
import { Input } from '@/components/ui/input'

export function SearchBar() {
  const searchParams = useSearchParams()
  const router = useRouter()
  const pathname = usePathname()
  const [query, setQuery] = useState(searchParams.get('q') ?? '')

  const updateSearch = useCallback(
    (value: string) => {
      const params = new URLSearchParams(searchParams.toString())
      if (value) {
        params.set('q', value)
      } else {
        params.delete('q')
      }
      params.delete('page')
      router.push(`${pathname}?${params.toString()}`)
    },
    [searchParams, router, pathname]
  )

  useEffect(() => {
    const timer = setTimeout(() => updateSearch(query), 300)
    return () => clearTimeout(timer)
  }, [query, updateSearch])

  return (
    <Input
      placeholder="Search products..."
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      className="w-full max-w-md"
    />
  )
}
```

**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.

```
'use client'

import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'

interface FilterOption {
  filter_group: string
  label: string
  value: string
}

export function FilterSidebar({ options }: { options: FilterOption[] }) {
  const searchParams = useSearchParams()
  const router = useRouter()
  const pathname = usePathname()

  const groups = options.reduce((acc, opt) => {
    if (!acc[opt.filter_group]) acc[opt.filter_group] = []
    acc[opt.filter_group].push(opt)
    return acc
  }, {} as Record<string, FilterOption[]>)

  function toggleFilter(group: string, value: string) {
    const params = new URLSearchParams(searchParams.toString())
    const current = params.getAll(group)
    if (current.includes(value)) {
      params.delete(group)
      current.filter((v) => v !== value).forEach((v) => params.append(group, v))
    } else {
      params.append(group, value)
    }
    params.delete('page')
    router.push(`${pathname}?${params.toString()}`)
  }

  function clearAll() {
    router.push(pathname)
  }

  return (
    <aside className="w-64 space-y-6">
      <div className="flex items-center justify-between">
        <h3 className="font-semibold">Filters</h3>
        <Button variant="ghost" size="sm" onClick={clearAll}>Clear all</Button>
      </div>
      {Object.entries(groups).map(([group, opts]) => (
        <div key={group} className="space-y-2">
          <h4 className="text-sm font-medium capitalize">{group}</h4>
          {opts.map((opt) => (
            <label key={opt.value} className="flex items-center gap-2 text-sm">
              <Checkbox
                checked={searchParams.getAll(group).includes(opt.value)}
                onCheckedChange={() => toggleFilter(group, opt.value)}
              />
              {opt.label}
            </label>
          ))}
        </div>
      ))}
    </aside>
  )
}
```

> 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.

```
import { createClient } from '@/lib/supabase/server'
import { SearchBar } from '@/components/search-bar'
import { FilterSidebar } from '@/components/filter-sidebar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import Link from 'next/link'

export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
  const params = await searchParams
  const q = typeof params.q === 'string' ? params.q : ''
  const sort = typeof params.sort === 'string' ? params.sort : 'created_at'
  const order = params.order === 'asc' ? true : false
  const categories = Array.isArray(params.category)
    ? params.category
    : params.category
      ? [params.category]
      : []

  const supabase = await createClient()

  let query = supabase.from('items').select('*', { count: 'exact' })
  if (q) query = query.textSearch('search_vector', q)
  if (categories.length > 0) query = query.in('category', categories)
  query = query.order(sort, { ascending: order }).limit(20)

  const { data: items, count } = await query
  const { data: filterOptions } = await supabase.from('filter_options').select('*')

  return (
    <div className="flex gap-8 p-6">
      <FilterSidebar options={filterOptions ?? []} />
      <main className="flex-1 space-y-4">
        <SearchBar />
        <div className="flex gap-2 flex-wrap">
          {categories.map((cat) => (
            <Badge key={cat} variant="secondary">{cat}</Badge>
          ))}
        </div>
        <p className="text-sm text-muted-foreground">{count ?? 0} results</p>
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>
                <Link href={`?${new URLSearchParams({ ...params as any, sort: 'title', order: sort === 'title' && !order ? 'asc' : 'desc' }).toString()}`}>
                  <Button variant="ghost" size="sm">Title</Button>
                </Link>
              </TableHead>
              <TableHead>Category</TableHead>
              <TableHead>
                <Link href={`?${new URLSearchParams({ ...params as any, sort: 'price', order: sort === 'price' && !order ? 'asc' : 'desc' }).toString()}`}>
                  <Button variant="ghost" size="sm">Price</Button>
                </Link>
              </TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {items?.map((item) => (
              <TableRow key={item.id}>
                <TableCell className="font-medium">{item.title}</TableCell>
                <TableCell><Badge variant="outline">{item.category}</Badge></TableCell>
                <TableCell>${(item.price / 100).toFixed(2)}</TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </main>
    </div>
  )
}
```

**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.

```
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const q = searchParams.get('q') ?? ''
  const page = parseInt(searchParams.get('page') ?? '1')
  const limit = 20
  const offset = (page - 1) * limit

  if (!q) {
    const { data, count } = await supabase
      .from('items')
      .select('*', { count: 'exact' })
      .order('created_at', { ascending: false })
      .range(offset, offset + limit - 1)
    return NextResponse.json({ data, total: count })
  }

  const { data, error } = await supabase.rpc('search_items', {
    search_query: q,
    result_limit: limit,
    result_offset: offset,
  })

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json({ data, total: data?.length ?? 0 })
}
```

> 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.

```
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function saveSearchPreset(
  name: string,
  params: Record<string, string | string[]>
) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    return { error: 'Must be logged in to save presets' }
  }

  const { error } = await supabase.from('search_presets').insert({
    user_id: user.id,
    name,
    params,
  })

  if (error) {
    return { error: error.message }
  }

  revalidatePath('/search')
  return { success: true }
}
```

**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 example

File: `app/search/page.tsx`

```typescript
import { createClient } from '@/lib/supabase/server'
import { SearchBar } from '@/components/search-bar'
import { FilterSidebar } from '@/components/filter-sidebar'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'

export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
  const params = await searchParams
  const q = typeof params.q === 'string' ? params.q : ''
  const sort = typeof params.sort === 'string' ? params.sort : 'created_at'
  const order = params.order === 'asc'
  const categories = Array.isArray(params.category)
    ? params.category
    : params.category ? [params.category] : []

  const supabase = await createClient()
  let query = supabase.from('items').select('*', { count: 'exact' })

  if (q) query = query.textSearch('search_vector', q)
  if (categories.length > 0) query = query.in('category', categories)
  query = query.order(sort, { ascending: order }).limit(20)

  const { data: items, count } = await query
  const { data: filterOptions } = await supabase
    .from('filter_options')
    .select('*')

  return (
    <div className="flex gap-8 p-6">
      <FilterSidebar options={filterOptions ?? []} />
      <main className="flex-1 space-y-4">
        <SearchBar />
        <div className="flex gap-2 flex-wrap">
          {categories.map((cat) => (
            <Badge key={cat} variant="secondary">{cat}</Badge>
          ))}
        </div>
        <p className="text-sm text-muted-foreground">
          {count ?? 0} results found
        </p>
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>Title</TableHead>
              <TableHead>Category</TableHead>
              <TableHead>Price</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {items?.map((item) => (
              <TableRow key={item.id}>
                <TableCell>{item.title}</TableCell>
                <TableCell>
                  <Badge variant="outline">{item.category}</Badge>
                </TableCell>
                <TableCell>${item.price?.toFixed(2)}</TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </main>
    </div>
  )
}
```

## Common mistakes

- **Not debouncing the search input, causing a Supabase query on every keystroke** — Without debounce, typing 'laptop' triggers 6 separate database queries (l, la, lap, lapt, lapto, laptop), burning through Supabase free tier limits quickly. Fix: Use a 300ms debounce with useEffect and setTimeout. Only call router.push after the user stops typing for 300ms.
- **Storing filter state in React state instead of URL searchParams** — React state is lost on page refresh and cannot be shared via URL. Users cannot bookmark or share filtered views. Fix: Use useSearchParams and router.push to sync all filter state to URL params. The Server Component reads searchParams directly for SSR.
- **Missing GIN index on the tsvector column** — Without a GIN index, full-text search scans every row in the table. Performance degrades from milliseconds to seconds as data grows. Fix: 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.
- **Using client-side filtering instead of server-side queries** — Fetching all items and filtering in the browser works for 50 items but fails completely with thousands of rows, causing slow load times and high bandwidth usage. Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/search-filtering-and-sorting
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/search-filtering-and-sorting
