# How to Add Social Feed Filtering to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

Social feed filtering needs four pieces: filter chip UI that stacks onto the feed without navigating away, a dynamic query builder that chains Supabase PostgREST filters (.eq(), .in(), .gte()) based on active selections, URL query param sync so filtered views are shareable, and filter preferences persisted to a Supabase JSONB column so they survive page reloads. With Lovable or V0 you can ship this in 3–5 hours. Cost is $0/mo at small scale — Supabase free tier covers all reads.

## What Social Feed Filtering Actually Is

Feed filtering gives users control over what they see without leaving the feed — instead of navigating to a separate search page, they tap filter chips to narrow posts by category, content type, author, date range, or any other attribute. Done well, it's the difference between a feed that feels curated to the user's interests and one they abandon after scrolling past too much irrelevant content. The technical challenge is that filters must be composable (AND/OR logic), real-time (feed updates as chips are toggled), and stateful (preferences survive a reload) — three requirements that AI tools frequently implement incompletely.

## Anatomy of the Feature

Six components. The filter state manager and the empty state branch are the two places AI-generated builds consistently break. The pagination reset on filter change is the most common silent bug.

- **Filter chip bar** (ui): A horizontal scrollable row of shadcn/ui Badge + ToggleGroup components on web, or Flutter FilterChip widgets on mobile. Each chip shows a label and optionally a count of matching posts in parentheses. Active chips use a filled/primary style; inactive chips are outlined. A 'Clear all' button appears at the far right when any chip is active.
- **Filter state manager** (ui): A Zustand store (web) or Flutter Riverpod StateNotifier (mobile) that holds the active filter set as a structured object: { categories: string[], contentTypes: string[], authorId: string | null, dateRange: { from: Date, to: Date } | null }. On every state change, the store syncs to URL query params (web) or local storage (mobile) and triggers a React Query refetch.
- **Filtered query builder** (data): A Supabase PostgREST query builder that chains .eq(), .in(), .gte(), .lte(), and .textSearch() calls dynamically based on the active filter set. Only non-null filters are applied — if categories is empty, no .in() call is made. Returns results ordered by created_at DESC with cursor-based pagination.
- **Filter preference persistence** (data): A Supabase user_preferences table with a filter_config JSONB column storing the user's last-used filter set. On mount, the app fetches this row and initializes the Zustand store. After 2 seconds of filter inactivity (debounced), the current filter set is written back to Supabase — no save button needed.
- **Infinite scroll loader** (ui): TanStack Virtual (web) or Flutter CustomScrollView + SliverList (mobile) renders only the visible posts plus a buffer. Cursor-based pagination uses created_at as the cursor rather than OFFSET to prevent result drift when new posts are inserted between page loads.
- **Full-text search integration** (backend): Supabase full-text search via a posts.fts tsvector column (created with `to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))`). When the user types in a search-within-filter bar, .textSearch('fts', query) is added to the composable query builder alongside the active chip filters.

## Data model

The posts table needs indexed columns for each filterable attribute. The user_preferences table stores per-user filter config. Run this in the Supabase SQL Editor.

```sql
-- Posts table with filterable indexed columns
create table public.posts (
  id uuid primary key default gen_random_uuid(),
  author_id uuid references auth.users(id) on delete cascade not null,
  title text not null,
  body text not null,
  category text not null,
  content_type text not null check (content_type in ('article', 'video', 'image', 'audio', 'link')),
  tags text[] not null default '{}',
  is_published boolean not null default true,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  -- Full-text search vector
  fts tsvector generated always as (
    to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))
  ) stored
);

-- Indexes for filter performance
create index posts_category_created_idx on public.posts (category, created_at desc);
create index posts_content_type_idx on public.posts (content_type);
create index posts_author_id_idx on public.posts (author_id);
create index posts_created_at_idx on public.posts (created_at desc);
create index posts_tags_gin_idx on public.posts using gin (tags);
create index posts_fts_gin_idx on public.posts using gin (fts);

-- User preferences for filter persistence
create table public.user_preferences (
  user_id uuid primary key references auth.users(id) on delete cascade,
  filter_config jsonb not null default '{}',
  updated_at timestamptz not null default now()
);

-- RLS
alter table public.posts enable row level security;
alter table public.user_preferences enable row level security;

-- Posts readable by all authenticated users (adjust for private posts)
create policy "Published posts are readable"
  on public.posts for select
  using (is_published = true);

-- Authors can insert their own posts
create policy "Authors can insert posts"
  on public.posts for insert
  with check (author_id = auth.uid());

-- User preferences: each user manages their own
create policy "Users can read own preferences"
  on public.user_preferences for select
  using (user_id = auth.uid());

create policy "Users can upsert own preferences"
  on public.user_preferences for insert
  with check (user_id = auth.uid());

create policy "Users can update own preferences"
  on public.user_preferences for update
  using (user_id = auth.uid());
```

The fts column uses a generated column (PostgreSQL 12+) so full-text search stays automatically in sync with title and body changes — no trigger needed. The composite index on (category, created_at DESC) handles the most common filter: category filter with newest-first ordering.

## Build paths

### Lovable — fit 4/10, 3–5 hours

Generates the filter chip UI quickly with shadcn ToggleGroup and wires Supabase query chaining naturally. Complex AND/OR logic sometimes gets simplified to AND-only in the first pass.

1. Create a new Lovable project and connect Lovable Cloud to provision the Supabase database automatically
2. Run the posts and user_preferences SQL from the data model section in the Supabase SQL Editor — Lovable Cloud provisions the DB but you add the schema manually
3. Paste the prompt below in Agent Mode and let it generate the filter chip bar, feed component, and preference persistence
4. Test filter combinations in the preview — if the URL param sync is missing or filters don't persist after reload, add a follow-up prompt: 'Persist the active filter state to the user_preferences.filter_config JSONB column after 2 seconds of inactivity using a debounced Supabase upsert'
5. Publish and verify the shareable URL feature: apply filters, copy the URL, open it in an incognito window — the same filters should be pre-applied

Starter prompt:

```
Build a social feed filtering feature on top of a Supabase posts table (columns: id, author_id, title, body, category text, content_type text enum article/video/image/audio/link, tags text[], is_published bool, created_at timestamptz, fts tsvector). Filter chip bar above the feed using shadcn ToggleGroup: category chips (values from a distinct query on posts.category), content type chips (Article, Video, Image, Audio, Link), an author dropdown showing avatar + username, and a date range picker (This Week / This Month / Custom Range with a calendar). Active filters combine with AND logic. Each chip shows the count of matching posts next to the label — update counts when other filters change. Sync filter state to URL query params (?category=design&type=video) so filtered views are shareable — use the nuqs library for SSR-safe URL state in Next.js. When filters change, reset pagination cursor to null and refetch from page 1. Auto-save filter state to Supabase user_preferences.filter_config JSONB after 2 seconds of inactivity (debounced upsert). Clear All button removes all active filters and resets URL params. Empty state: when zero posts match, show which filters are active in a summary and a 'Clear all filters' button. Infinite scroll using cursor-based pagination on created_at — do not use OFFSET. When no search query is present, do not call .textSearch() — only add it when the search bar has input.
```

Limitations:

- Complex OR logic between filter types (show posts where category='design' OR author='alice') sometimes gets simplified to AND-only — add a follow-up prompt if OR logic is required
- V0 has stronger TypeScript type inference for complex filter state — prefer V0 if the filter schema will expand significantly over time

### V0 — fit 5/10, 3–5 hours

Strongest path for typed filter state — V0 generates Zod schemas for filters, nuqs URL param sync, and TanStack Query stale-while-revalidate automatically. Best choice when the feed will have many filter types.

1. Prompt V0 with the filter feature spec below — it will generate a typed filter state with Zod, URL param sync via nuqs, and a TanStack Query feed component
2. Add Supabase environment variables in V0's Vars panel: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
3. Run the SQL schema from this page in the Supabase SQL Editor
4. Deploy to Vercel — the nuqs URL sync requires the Next.js server for SSR to work correctly

Starter prompt:

```
Build a social feed filtering feature in Next.js App Router. Define a filter state type with Zod: FilterState { categories: z.array(z.string()), contentTypes: z.array(z.enum(['article', 'video', 'image', 'audio', 'link'])), authorId: z.string().uuid().nullable(), dateFrom: z.string().nullable(), dateTo: z.string().nullable() }. Sync this state to URL query params using nuqs (useQueryStates hook) — params: categories (comma-separated), types, author_id, date_from, date_to. Component: FilterChipBar — shadcn/ui ToggleGroup for category chips (fetched from Supabase distinct categories query), content type chips (Article/Video/Image/Audio/Link each with post count), author combobox showing avatar + username, date range dropdown (This Week / This Month / Custom Range using shadcn Calendar). All chips show count of matching posts from a separate count query. Feed component: uses TanStack Query with queryKey including the full filter state; fetches from Supabase with dynamic PostgREST chaining — buildFeedQuery(filters) returns a Supabase query that chains .eq() for single-value filters, .in() for multi-value arrays, .gte()/.lte() for date range, and .textSearch('fts', searchQuery) only when search is non-empty. Cursor-based pagination: last item's created_at as cursor, not OFFSET. Reset cursor to null when filter state changes. Auto-save filter state to user_preferences JSONB after 2 seconds inactivity (debounced Server Action). Empty state component: shows active filter summary and Clear All button. Wrap any localStorage access in typeof window !== 'undefined' guard.
```

Limitations:

- Supabase setup is manual — V0 does not auto-provision the database
- V0 generates localStorage persistence that crashes on SSR if typeof window guard is omitted — the prompt above includes the guard but verify in the generated code
- Mobile-specific filter UX (bottom sheet filter panel) requires a follow-up prompt

### Flutterflow — fit 3/10, 1–2 days

Flutter FilterChip widgets are natively supported; Supabase or Firestore query conditions handle simple filters. Complex OR logic and URL-based filter sharing require custom Dart Actions.

1. Add a Row of FilterChip widgets above the feed ListView; bind each chip's selected state to a Riverpod or AppState boolean variable for that filter
2. Wire the feed ListView's Backend Query (Supabase or Firestore) with conditional filter conditions: add each active filter as a where clause in the query builder
3. Add a 'Clear All' button that resets all filter state variables to false/null and triggers a query refresh
4. For filter preference persistence, add a custom Dart Action that upserts the current filter state as a JSON string to Supabase user_preferences on filter change (debounced 2 seconds)
5. Handle the empty state by checking if the ListView is empty and showing an overlay widget with the active filter summary

Limitations:

- Combining 3 or more filters with OR logic requires a custom Dart Action — FlutterFlow's built-in Backend Query conditions use AND logic only
- URL-based filter sharing has no equivalent on mobile — use local storage or Riverpod StateNotifier for cross-session filter persistence
- Date range picker requires a third-party Flutter package (table_calendar or date_range_picker); FlutterFlow's built-in date picker handles single dates only

### Custom — fit 4/10, 3–5 days

Full control over faceted filtering with live counts per facet, Elasticsearch or Algolia full-text search with typo tolerance, and saved filter presets that users can name and share.

1. Build a facet count API: a Supabase RPC (stored procedure) that returns { category: string, count: number }[] for the current filter state — needed for real-time count badges on chips
2. Implement saved filter presets: a named_filters table (user_id, name, filter_config JSONB) with UI to save the current filter set as a preset and recall it with one click
3. Add real-time feed updates while filters are active: Supabase Realtime subscription on the posts table filtered by category — new posts matching the active filters appear at the top of the feed without a manual refresh
4. For Algolia or Elasticsearch: index posts on create/update via a Supabase Edge Function trigger; route search queries through the search provider for typo-tolerant full-text search with ranking

Limitations:

- 3–5 day timeline for proper implementation including facet counts and saved presets
- Elasticsearch or Algolia integration adds $50–300/mo in service costs depending on search volume — only worthwhile for apps with complex full-text search requirements

## Gotchas

- **Filters don't reset pagination — users see stale results from old page positions** — When a user applies a filter while on page 5 of the unfiltered feed, the cursor stays at the old position. The filtered query starts fetching results from midway through the unfiltered set — posts that match the filter but came before the cursor are silently skipped. Fix: Reset the pagination cursor to null in the same state update that changes the active filter set. In TanStack Query, invalidate the query key and start a fresh fetch from page 1. Make this an invariant in your filter state manager: filter change always resets cursor.
- **URL param filter state is lost on mobile deep link** — The nuqs library syncs filter state to the URL query string — perfect for web browsers but irrelevant on mobile apps where there is no URL bar concept. A shared filtered URL from a web user opens the mobile app without the filter state applied. Fix: On mobile, persist filter state in Riverpod StateNotifier (in-memory, survives navigation) and SharedPreferences (survives app restart). For cross-session sharing on mobile, encode the filter state as a deep link parameter and parse it in the link handler.
- **Full-text search and category filter conflict on index usage** — Calling both .textSearch('fts', query) and .eq('category', value) in the same Supabase query can result in a slow sequential scan if PostgreSQL can't use both indexes in one plan. On a large posts table, combined filter+search queries can take several seconds. Fix: Create a composite index on (category, created_at DESC) and a separate GIN index on fts — as shown in the data model SQL above. Run EXPLAIN ANALYZE on your slowest filter+search queries in the Supabase SQL Editor and add additional composite indexes based on the actual query plans.
- **V0 generates localStorage filter persistence that crashes on SSR** — V0 sometimes generates filter state persistence using localStorage.getItem() at module load time. In Next.js App Router, this runs server-side during the first render — localStorage is undefined server-side, throwing a ReferenceError that crashes the page. Fix: Wrap all localStorage access in a typeof window !== 'undefined' guard, or use nuqs which is built for SSR-safe URL state management in Next.js. The prompt in the V0 build path above includes the guard — verify it appears in the generated code before deploying.
- **Empty state is missing for filter combinations with zero results** — AI-generated feed components typically return null or render an empty list when a filter combination matches zero posts. Users see a blank feed with no explanation — they don't know if the filter is broken or if there's genuinely no content. Fix: Always include an explicit empty state branch: check `if (posts.length === 0 && hasActiveFilters)` and render a component showing the active filter summary ('Showing: Design + Video') with a 'Clear all filters' button. This is the second most common reason users abandon filtered feeds.

## Best practices

- Show the count of matching posts next to each filter chip — users should know before toggling whether a filter narrows the feed to 3 posts or 300
- Reset pagination to page 1 on every filter change — make this an invariant in your filter state manager, not an afterthought
- Use cursor-based pagination (created_at as cursor) instead of OFFSET — new posts inserted between page loads shift OFFSET results and cause duplicates
- Debounce filter preference saves to Supabase by 2 seconds — users often toggle chips rapidly before settling on a combination, and you don't want a write per toggle
- Validate loaded user_preferences JSONB against your current filter schema before applying — old configs may reference categories that were renamed or removed
- Make 'Clear all filters' visible at all times when any filter is active — in a separate sticky bar element, not buried inside the filter UI
- On mobile, keep the filter chip bar single-line with horizontal scroll rather than wrapping — wrapping chips push the feed down and disorient users

## Frequently asked questions

### How do I show how many posts match each filter option?

For each chip, run a COUNT query with the same filter constraints as the main feed query, minus that specific chip's constraint. In Supabase: `supabase.from('posts').select('*', { count: 'exact', head: true }).eq('category', chip.value)`. Cache these counts with React Query (stale time 30 seconds) so they don't re-fetch on every render. Update them when other filters change to show the narrowed count.

### Can users save their filter preferences as named presets?

Yes — add a named_filters table (user_id, name text, filter_config JSONB, created_at). A 'Save this view' button opens a modal where the user names the current filter combination. Presets appear in a dropdown above the filter chips. This is standard in analytics dashboards but less common in consumer apps — worth building if your power users regularly return to the same filter combinations.

### How do I make filter URLs shareable?

Use the nuqs library (Next.js) to sync filter state to URL query params — e.g., ?category=design&type=video. When another user opens that URL, nuqs reads the params on mount and initializes the filter state. The filtered view loads server-side with the correct query, so the page is shareable and SEO-indexable. On mobile, encode filter state as base64 JSON in a deep link parameter instead.

### What's the difference between client-side and server-side filtering?

Client-side filtering loads all posts to the browser and uses JavaScript to hide non-matching items — fast for small datasets but breaks with 1,000+ posts. Server-side filtering (the Supabase PostgREST approach above) sends only matching posts to the browser — slower initial query but scales correctly and doesn't expose all posts to every user. Always filter server-side for any real app.

### How do I add a date range filter to my feed?

Add a date range picker component (shadcn/ui Calendar with range mode on web, or a Flutter date_range_picker package on mobile). Map the selection to two Supabase filter calls: `.gte('created_at', dateFrom.toISOString()).lte('created_at', dateTo.toISOString())`. Include preset ranges (This Week, This Month) as quick-select buttons alongside the custom range calendar.

### Can I filter by multiple categories at once?

Yes — use .in('category', activeCategories) in the Supabase query when multiple category chips are selected. This returns posts where category matches any value in the array (OR within the category filter), while still applying AND logic with other filter types. Ensure your filter chip bar uses a multi-select ToggleGroup, not a single-select radio button.

### How do I prevent filters from clearing when the user navigates away?

Two mechanisms: URL params (nuqs on web) preserve filter state across back/forward navigation and page refresh. Supabase user_preferences persistence restores the last-used filter set across browser sessions. On mobile, Riverpod StateNotifier keeps filters active within the session; SharedPreferences restores them on app restart.

### How do I add a search bar that works alongside filters?

Add a text input above the filter chip bar that populates a searchQuery state variable. When searchQuery is non-empty, add .textSearch('fts', searchQuery, { config: 'english' }) to the Supabase query alongside the active chip filters — both apply simultaneously. When searchQuery is empty, omit the textSearch call entirely to avoid an expensive GIN index scan on an empty string. Debounce the search input by 300ms to prevent a query on every keystroke.

---

Source: https://www.rapidevelopers.com/app-features/social-feed-filtering
© RapidDev — https://www.rapidevelopers.com/app-features/social-feed-filtering
