Feature spec
IntermediateCategory
communication-social
Build with AI
3–5 hours with Lovable or V0
Custom build
3–5 days custom dev
Running cost
$0/mo up to 1K users · $25–50/mo at scale
Works on
Everything it takes to ship Social Feed Filtering — parts, prompts, and real costs.
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.
What users consider table stakes in 2026
- Filter chips or toggle pills appear above the feed without a page navigation — always visible, horizontally scrollable on mobile
- Applied filters persist across sessions, saved to the user's Supabase preference row and loaded on auth
- Multiple filters combine with AND logic by default — selecting 'Design' and 'Video' shows design videos, not all designs plus all videos
- Feed updates in real time as each chip is toggled — no full page reload, no spinner for the entire feed
- A 'Clear all' button and per-chip X dismiss both visible at all times when any filter is active
- Empty state when a filter combination returns zero posts — shows which filters are active and suggests widening them
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
UIA 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.
Note: On desktop, make the chip bar sticky at the top of the feed column using `position: sticky; top: 0` so it stays visible while scrolling through a long feed.
Filter state manager
UIA 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.
Note: Use the nuqs library for Next.js URL param sync — it is SSR-safe and handles serialization for all filter types. Do not use localStorage for URL sync on web — it is not accessible server-side.
Filtered query builder
DataA 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.
Note: Build the query in a single composable function (buildFeedQuery(filters)) rather than inline conditions — this makes testing and extending filters much cleaner.
Filter preference persistence
DataA 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.
Note: The 2-second debounce prevents a Supabase write on every chip toggle. Validate the loaded JSONB against your filter schema before applying — old filter configs may reference categories that no longer exist.
Infinite scroll loader
UITanStack 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.
Note: Reset the cursor to null and refetch from the first page whenever the active filter set changes — this is the most common pagination bug in AI-generated feed filters.
Full-text search integration
BackendSupabase 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.
Note: Full-text search and category filter can coexist in the same query — they use separate columns and separate indexes. The composite index on (category, created_at DESC) does not conflict with the GIN index on fts.
The 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.
1-- Posts table with filterable indexed columns2create table public.posts (3 id uuid primary key default gen_random_uuid(),4 author_id uuid references auth.users(id) on delete cascade not null,5 title text not null,6 body text not null,7 category text not null,8 content_type text not null check (content_type in ('article', 'video', 'image', 'audio', 'link')),9 tags text[] not null default '{}',10 is_published boolean not null default true,11 created_at timestamptz not null default now(),12 updated_at timestamptz not null default now(),13 -- Full-text search vector14 fts tsvector generated always as (15 to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))16 ) stored17);1819-- Indexes for filter performance20create index posts_category_created_idx on public.posts (category, created_at desc);21create index posts_content_type_idx on public.posts (content_type);22create index posts_author_id_idx on public.posts (author_id);23create index posts_created_at_idx on public.posts (created_at desc);24create index posts_tags_gin_idx on public.posts using gin (tags);25create index posts_fts_gin_idx on public.posts using gin (fts);2627-- User preferences for filter persistence28create table public.user_preferences (29 user_id uuid primary key references auth.users(id) on delete cascade,30 filter_config jsonb not null default '{}',31 updated_at timestamptz not null default now()32);3334-- RLS35alter table public.posts enable row level security;36alter table public.user_preferences enable row level security;3738-- Posts readable by all authenticated users (adjust for private posts)39create policy "Published posts are readable"40 on public.posts for select41 using (is_published = true);4243-- Authors can insert their own posts44create policy "Authors can insert posts"45 on public.posts for insert46 with check (author_id = auth.uid());4748-- User preferences: each user manages their own49create policy "Users can read own preferences"50 on public.user_preferences for select51 using (user_id = auth.uid());5253create policy "Users can upsert own preferences"54 on public.user_preferences for insert55 with check (user_id = auth.uid());5657create policy "Users can update own preferences"58 on public.user_preferences for update59 using (user_id = auth.uid());Heads up: 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 it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
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.
Step by step
- 1Prompt 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
- 2Add Supabase environment variables in V0's Vars panel: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
- 3Run the SQL schema from this page in the Supabase SQL Editor
- 4Deploy to Vercel — the nuqs URL sync requires the Next.js server for SSR to work correctly
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.Where this path bites
- 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
Third-party services you'll need
This feature runs primarily on Supabase. The nuqs library is free. No paid third-party services are required for the core functionality.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | PostgreSQL with PostgREST for dynamic filter queries, full-text search via tsvector, user_preferences table for filter persistence | Free: 2 projects, 500MB DB, unlimited reads | Pro: $25/mo (8GB DB, connection pooling for concurrent filter loads) |
| nuqs | URL query param state management for Next.js — syncs filter state to the URL so filtered views are shareable and survive browser back navigation | Free, open source (npm) | $0 |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier handles all filter queries at this scale — filter reads are lightweight PostgREST calls with indexed columns.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Filters don't reset pagination — users see stale results from old page positions
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
When You Need Custom Development
AI tools handle chip-based AND-filter feeds well. These requirements exceed what a prompt session reliably delivers:
- Faceted filtering with live counts per facet — showing '(47)' next to each category chip, updated as other filters change, requires a server-side aggregation query per filter change
- Saved filter presets that users can name, save, and share with other users as a link
- Elasticsearch or Algolia integration for full-text search with typo tolerance, synonyms, and relevance ranking — PostgREST full-text search is exact-match only
- Real-time feed updates while filters are active — new posts that match the current filter set should appear at the top without a manual refresh, requiring WebSocket subscription combined with filter re-application
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
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.
Need this feature production-ready?
RapidDev builds social feed filtering into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.