# How to Add a Community Forum to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

A community forum needs a forum_posts table with parent_post_id for threading, a votes table with a signed integer and a unique constraint per user, full-text search via Supabase to_tsvector with a GIN index, and a moderator role check in RLS for pin/lock/delete actions. With Lovable or v0 you can ship a working threaded forum in 5-10 hours for $0/mo on free tiers, then $25/mo on Supabase Pro at 1,000 users.

## What a Community Forum Actually Requires

A community forum is the structured discussion layer in a SaaS product, developer tool, or online community — the place where users ask questions, share knowledge, and debate ideas in threaded conversations. Unlike a real-time chat room, a forum persists indefinitely, is indexed by search engines, and gives moderators tools to curate quality. The architecture involves categorised thread lists, nested replies with parent-child relationships stored in a single table via parent_post_id, an upvote/downvote system with a unique-per-user constraint, full-text search using PostgreSQL's to_tsvector, and a role-based moderation system that gates pin, lock, and delete actions. Getting the recursive reply rendering and the moderator RLS right is where first builds fail — the flat rendering bug is the single most reported issue with AI-generated forums.

## Anatomy of the Feature

Seven components build the forum. AI tools generate the category list and thread view correctly on first prompt — recursive nested replies, the moderator role check in RLS, and the full-text search GIN index are where builds fail.

- **Category and channel list** (ui): A shadcn/ui Accordion or sidebar navigation listing all forum categories. Each category row shows its name, a post count badge, and a preview of the most recent post title. Categories are stored in a forum_categories table sorted by sort_order. Clicking a category navigates to the thread list for that category.
- **Thread list** (ui): A shadcn/ui DataTable or custom list showing threads in the selected category, sorted by last-activity DESC (the created_at of the most recent reply). Pinned threads appear at the top regardless of activity timestamp. Each row shows title, author avatar, reply count, score, and last-activity relative timestamp. Paginated via keyset cursor on last-activity timestamp.
- **Thread detail and replies** (ui): A recursive React component that renders nested replies. The root post renders at the top. Direct replies (parent_post_id = root post id) render at level 1. Replies to replies render at level 2. Depth is capped at 3 levels to prevent infinite nesting. Each node contains: author avatar, name, post count badge, relative timestamp, body text, score, and a Reply button that opens an inline reply composer.
- **Voting system** (data): A forum_votes table with a value column constrained to 1 or -1. A UNIQUE(post_id, user_id) constraint prevents multiple votes. Score is computed as SUM(value) in the posts query. On vote: INSERT with ON CONFLICT (post_id, user_id) DO UPDATE SET value = EXCLUDED.value — this handles vote flipping (upvote to downvote) without a separate DELETE+INSERT.
- **Thread search** (backend): Supabase full-text search using to_tsvector('english', title || ' ' || body) on the forum_posts table. A GIN index on the tsvector makes search fast. The search query uses to_tsquery to match the user's input. Results are sorted by ts_rank() for relevance. Algolia InstantSearch is the upgrade path for typo-tolerant search at scale.
- **Moderation actions** (backend): Pin, lock, and delete actions gated by a moderator role stored in the user's profiles table. RLS checks auth.jwt() ->> 'role' = 'moderator' for UPDATE (pin/lock) and DELETE operations on forum_posts. Locked threads show a 'This thread is locked' banner and the reply form is disabled client-side. Soft-delete sets deleted_at rather than hard-deleting rows.
- **Reply notification** (service): A Supabase Edge Function triggered by INSERT on forum_posts (where parent_post_id IS NOT NULL) looks up the root thread author and sends an email via the Resend API with a link back to the thread. Includes a guard: do not send if the commenter is the thread author (self-reply notification).

## Data model

Three tables form the forum schema: categories, posts (with self-referential parent for threading), and votes. Run this in the Supabase SQL editor.

```sql
create table public.forum_categories (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  slug text not null unique,
  description text,
  sort_order int not null default 0,
  created_at timestamptz not null default now()
);

create table public.forum_posts (
  id uuid primary key default gen_random_uuid(),
  category_id uuid references public.forum_categories(id) on delete set null,
  author_id uuid references auth.users(id) on delete set null not null,
  parent_post_id uuid references public.forum_posts(id) on delete cascade,
  title text,
  body text not null,
  is_pinned bool not null default false,
  is_locked bool not null default false,
  deleted_at timestamptz,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  -- title is required only for root posts (parent_post_id is null)
  check (parent_post_id is not null or title is not null)
);

create table public.forum_votes (
  post_id uuid references public.forum_posts(id) on delete cascade not null,
  user_id uuid references auth.users(id) on delete cascade not null,
  value smallint not null check (value in (1, -1)),
  created_at timestamptz not null default now(),
  primary key (post_id, user_id)
);

alter table public.forum_categories enable row level security;
alter table public.forum_posts enable row level security;
alter table public.forum_votes enable row level security;

-- Categories are public
create policy "categories are public"
  on public.forum_categories for select
  using (true);

-- Posts are publicly readable (exclude deleted)
create policy "posts are public"
  on public.forum_posts for select
  using (deleted_at is null);

-- Authenticated users can create posts and replies
create policy "authenticated can post"
  on public.forum_posts for insert
  to authenticated
  with check (auth.uid() = author_id);

-- Authors can edit their own posts
create policy "author can update own"
  on public.forum_posts for update
  using (auth.uid() = author_id)
  with check (auth.uid() = author_id);

-- Moderators can pin, lock, or soft-delete any post
create policy "moderator can update any"
  on public.forum_posts for update
  using (auth.jwt() ->> 'role' = 'moderator');

create policy "moderator delete"
  on public.forum_posts for delete
  using (
    auth.jwt() ->> 'role' = 'moderator'
    or auth.uid() = author_id
  );

-- Votes are public
create policy "votes are public"
  on public.forum_votes for select
  using (true);

-- Authenticated users can vote
create policy "authenticated can vote"
  on public.forum_votes for insert
  to authenticated
  with check (auth.uid() = user_id);

-- Users can change their vote
create policy "authenticated can update vote"
  on public.forum_votes for update
  using (auth.uid() = user_id);

create policy "authenticated can delete vote"
  on public.forum_votes for delete
  using (auth.uid() = user_id);

-- Full-text search index (critical for search performance)
create index forum_posts_fts
  on public.forum_posts
  using gin (to_tsvector('english', coalesce(title, '') || ' ' || body))
  where deleted_at is null;

-- Thread list performance indexes
create index forum_posts_category_created_idx
  on public.forum_posts (category_id, created_at desc)
  where parent_post_id is null and deleted_at is null;

create index forum_posts_parent_idx
  on public.forum_posts (parent_post_id, created_at asc)
  where deleted_at is null;

create index forum_votes_post_idx
  on public.forum_votes (post_id);
```

The GIN index on to_tsvector is partial (WHERE deleted_at IS NULL) so deleted posts are excluded from search results automatically. The parent_idx on (parent_post_id, created_at ASC) makes loading replies for a thread fast as reply counts grow. The CHECK constraint on forum_posts enforces that root posts must have a title while replies do not.

## Build paths

### Lovable — fit 4/10, 5-8 hours

Good for scaffolding the category list, thread view, and reply composer with Supabase auto-wired. Nested reply rendering and the moderator role check often need correction on first pass.

1. Create a new Lovable project and connect Lovable Cloud so Supabase is provisioned with auth
2. Paste the prompt below in Agent Mode — Lovable generates the category sidebar, thread list, thread detail with nested replies, voting system, and search
3. Add RESEND_API_KEY to the Secrets panel (Cloud tab) to enable reply notification emails
4. Verify the recursive reply rendering: create a root post, reply to it, then reply to that reply — confirm 3 levels render correctly and the 4th level shows 'Continue thread'
5. Test moderator actions by setting your user's role to 'moderator' via the Supabase Auth admin dashboard and verifying pin, lock, and delete buttons appear

Starter prompt:

```
Build a community forum using Supabase. Create three tables: forum_categories (id uuid pk, name, slug unique, description, sort_order, created_at), forum_posts (id uuid pk, category_id references forum_categories, author_id references auth.users, parent_post_id references forum_posts nullable, title text nullable, body text not null, is_pinned bool default false, is_locked bool default false, deleted_at timestamptz, created_at, updated_at; CHECK that parent_post_id IS NOT NULL OR title IS NOT NULL), forum_votes (post_id, user_id, value smallint CHECK value IN (1,-1), PRIMARY KEY (post_id, user_id)). Enable RLS: posts and categories SELECT is public; authenticated INSERT where author_id = auth.uid(); moderators (JWT role = 'moderator') can UPDATE is_pinned/is_locked and DELETE any post; authors can soft-delete own posts. UI: Category sidebar with post count per category. Thread list for selected category: sorted by created_at DESC with pinned threads always first; keyset pagination 20 threads per page; show score (SUM of forum_votes.value). Thread detail: recursive React component rendering nested replies from parent_post_id — cap depth at 3 levels, show 'Continue thread' link at level 4. Reply composer inline below each post (disabled if is_locked = true; show 'Thread is locked' banner). Voting: upvote/downvote buttons; INSERT on forum_votes with ON CONFLICT (post_id, user_id) DO UPDATE SET value = EXCLUDED.value to handle vote flipping. Moderator controls (visible when JWT role = 'moderator'): pin button, lock button, delete button. Full-text search input using Supabase to_tsvector('english', title || ' ' || body) with GIN index. Add GIN index: CREATE INDEX forum_posts_fts ON forum_posts USING GIN(to_tsvector('english', coalesce(title,'') || ' ' || body)) WHERE deleted_at IS NULL. Reply notification: Supabase Edge Function on posts INSERT where parent_post_id IS NOT NULL — look up root thread author, send Resend email with link to thread; skip if commenter = thread author. Empty state for new categories. Soft-deleted posts show 'Post removed' placeholder.
```

Limitations:

- Recursive nested reply rendering is frequently generated flat (all replies at the same level) on first pass — verify by creating a reply-to-a-reply and checking the indented rendering
- Moderator role check is sometimes added to the frontend only, not to RLS — verify the DELETE and UPDATE policies exist in the Supabase dashboard
- Full-text search GIN index must be explicitly requested; Lovable does not add it automatically

### V0 — fit 4/10, 6-10 hours

Excellent for the UI layer: shadcn/ui components for thread list, nested reply tree, and vote buttons. Next.js Server Components for the initial category and thread load. Full-text search and moderation logic require additional prompting.

1. Prompt v0 with the spec below to generate ForumCategoryList, ThreadList, ThreadDetail, ReplyTree, and VoteButton components
2. Open the Vars panel and add NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and RESEND_API_KEY as a server-only variable
3. Run the SQL schema from this page in the Supabase SQL editor — pay special attention to the GIN index for search
4. Deploy to Vercel and test the nested reply rendering with 3 levels of replies to confirm the recursive component renders correctly
5. Add the moderator role to a test user via the Supabase Auth admin panel and verify the pin/lock/delete controls appear and actually work server-side

Starter prompt:

```
Build a community forum for a Next.js 14 App Router app using Supabase. ForumCategoryList (server component): fetch forum_categories ordered by sort_order; show name, description, post count, and latest post preview. ThreadList (server component): fetch forum_posts WHERE category_id = :id AND parent_post_id IS NULL AND deleted_at IS NULL; sort by is_pinned DESC, created_at DESC; join forum_votes to compute score via SUM(value); keyset pagination 20 per page. ThreadDetail (server component): fetch root post + all replies recursively. ReplyTree (client component): recursive render of replies — filter children where parent_post_id === node.id, indent by level, cap at 3 levels with 'View more replies' at level 4; each node shows author avatar, display name, body, relative timestamp, score, reply button, and moderator controls if JWT role = 'moderator'. VoteButton (client component): show current user's vote from forum_votes; on click call supabase.from('forum_votes').upsert({ post_id, user_id, value }) with onConflict: 'post_id,user_id' to handle vote flipping. ReplyComposer (client component): inline form using react-hook-form; disabled if is_locked = true with locked banner. SearchInput (client component): debounced 300ms; queries Supabase using .textSearch('fts', query) on forum_posts. Moderator controls (rendered if auth.jwt() role = 'moderator'): pin toggle, lock toggle, soft-delete button that sets deleted_at. Notification: Server Action that calls Resend API after reply INSERT, skips if author = commenter. Include TypeScript types for ForumPost, ForumCategory, ForumVote.
```

Limitations:

- Supabase is not auto-provisioned — create the schema manually including the GIN index for full-text search
- The recursive ReplyTree component must use memo to avoid deep re-render on every parent state change — verify performance with 50+ replies before launch
- Moderator JWT role check requires setting a custom claim in Supabase Auth; document this setup step for your team

### Custom — fit 3/10, 3-6 weeks

Justified when you need Algolia for typo-tolerant search across hundreds of thousands of posts, multi-tier moderation with appeals and ban management, or enterprise SSO (Okta, SAML).

1. Algolia integration: sync forum_posts to Algolia on INSERT/UPDATE via a Supabase webhook or Edge Function; replace to_tsvector search with Algolia InstantSearch UI components
2. Multi-tier moderation: separate moderator and admin roles; ban management table tracking user bans with duration and reason; appeal form that routes to admin review queue
3. Enterprise SSO: integrate Okta or SAML via Supabase Auth's SAML 2.0 support; map IdP groups to moderator roles automatically
4. AI duplicate detection: on new thread INSERT, call an embedding API (Anthropic or OpenAI) and compare vector similarity to existing threads above a threshold — flag for author review

Limitations:

- Algolia and multi-tier moderation add 2-4 weeks of backend work on top of the base forum — only justified when search quality and moderation scale are business-critical requirements

## Gotchas

- **Nested replies render flat — all at the same indentation** — AI stores replies in the forum_posts table with a parent_post_id column but renders all posts at the same level in JSX by iterating the full flat array. The thread looks like a flat comment list regardless of the nesting structure in the database. The parent_post_id is in the data but ignored in the render. Fix: Build a recursive React component that accepts a post and filters its children: const children = allPosts.filter(p => p.parent_post_id === post.id). Render each child recursively with increased left margin (e.g., pl-6 per level). Cap at 3 levels and show a 'Continue thread' link at level 4 to prevent infinite nesting.
- **Users can vote multiple times on the same post** — AI generates an INSERT on the forum_votes table without a unique constraint on (post_id, user_id). A user who clicks upvote 5 times inserts 5 rows with value = 1, inflating the score by 5. The score compounds with each page refresh. Fix: Add PRIMARY KEY (post_id, user_id) on the forum_votes table and use INSERT ... ON CONFLICT (post_id, user_id) DO UPDATE SET value = EXCLUDED.value. This prevents duplicate votes and handles vote flipping (changing from up to down) in a single upsert.
- **Moderator cannot delete posts — RLS blocks the server-side call** — The moderator button appears in the UI because the client-side check passes (the user's JWT shows role = 'moderator'), but when the DELETE request reaches Supabase, the RLS policy only allows the author to delete their own posts. The moderator's DELETE is blocked at the database level with a silent 0 rows affected response. Fix: Add the moderator DELETE policy: CREATE POLICY "moderator delete" ON forum_posts FOR DELETE USING (auth.jwt() ->> 'role' = 'moderator' OR author_id = auth.uid()). Verify in the Supabase dashboard that this policy exists and is enabled. Test by logging in as a moderator and deleting someone else's post.
- **Full-text search returns nothing or is very slow** — The to_tsvector search query runs but returns no results or times out on large post tables. Usually two causes: the GIN index was not created (every search does a sequential full-table scan), or the tsvector expression in the query does not match the one in the index (e.g., coalesce(title,'') in the query but title alone in the index). Fix: Create the GIN index explicitly: CREATE INDEX forum_posts_fts ON forum_posts USING GIN(to_tsvector('english', coalesce(title,'') || ' ' || body)) WHERE deleted_at IS NULL. Make sure the to_tsvector expression in the query matches the index expression exactly. Check the Supabase query plan in the SQL editor with EXPLAIN ANALYZE to confirm the index is being used.
- **Reply notification emails sent to the author of their own reply** — The Supabase Edge Function fires on every INSERT on forum_posts where parent_post_id is not null. It looks up the root thread author and sends an email. When the thread author themselves posts a reply in their own thread, they receive a notification about their own reply — confusing and spammy. Fix: Add a guard at the start of the Edge Function: const newReply = payload.record; if (newReply.author_id === rootPost.author_id) return new Response('skipped: self reply', { status: 200 }). Always short-circuit before calling the Resend API when the commenter and the thread author are the same user.

## Best practices

- Always build the recursive ReplyTree component with parent_post_id filtering — generating a flat comment list with no threading is the most common first-pass failure
- Add the UNIQUE(post_id, user_id) constraint on forum_votes and use an upsert for vote flipping — two separate INSERT and DELETE calls create race conditions
- Enforce moderator actions in RLS, not just in the client — a moderator check that only hides buttons can be bypassed with direct API calls
- Create the GIN index on to_tsvector explicitly after schema creation — Supabase does not add it automatically and search is unusable without it at scale
- Sort the thread list by last-activity timestamp (the created_at of the most recent reply) not thread created_at — active threads should surface to the top
- Add the self-reply guard to the notification Edge Function before launch — it will fire on the first test if omitted
- Cap nested reply depth at 3 levels in the render component — deeper nesting is unreadable on mobile and encourages low-quality sub-thread rabbit holes

## Frequently asked questions

### How do I build a community forum in Lovable?

One Lovable prompt covers the full forum: specify parent_post_id for nested threading, upvote/downvote with signed integer score, moderator role controls, full-text search with to_tsvector and a GIN index, and reply email notifications via Resend. The total build time is 5-8 hours. The one thing to verify manually is that the recursive reply component uses parent_post_id filtering — Lovable sometimes generates a flat list on the first pass.

### Can I add threaded replies to a forum?

Yes — add a parent_post_id column (nullable, references the same posts table) to your forum_posts table. Root threads have parent_post_id = null; replies store the ID of their parent. Build a recursive React component that fetches all posts for a thread, then filters children where parent_post_id = parentPost.id at each render depth. Cap depth at 3 levels in the component to prevent infinite nesting.

### How do I implement upvoting in a forum?

Create a forum_votes table with a composite PRIMARY KEY on (post_id, user_id) and a value column constrained to 1 or -1. Use INSERT ... ON CONFLICT (post_id, user_id) DO UPDATE SET value = EXCLUDED.value so a user can switch from upvote to downvote without a separate delete. Compute the post score with SELECT SUM(value) FROM forum_votes WHERE post_id = :id.

### How do I add moderator controls to a forum?

Store a moderator role in the user's profile or as a custom JWT claim in Supabase Auth. Show pin, lock, and delete buttons only when the current user's role is 'moderator'. Critically, also add RLS policies that enforce the restriction server-side: CREATE POLICY "moderator can update" ON forum_posts FOR UPDATE USING (auth.jwt() ->> 'role' = 'moderator'). Client-side hiding alone can be bypassed.

### What is the best way to add search to a community forum?

Supabase full-text search with to_tsvector is the right first choice — it is built into PostgreSQL, requires no extra service, and handles exact-word search well. Add a GIN index on to_tsvector('english', coalesce(title,'') || ' ' || body) with a WHERE deleted_at IS NULL filter. When your post count exceeds 100,000 or users need typo-tolerant search, migrate to Algolia or Typesense and sync posts via a webhook.

### How do I notify users by email when someone replies to their post?

Create a Supabase Edge Function triggered by INSERT on forum_posts where parent_post_id IS NOT NULL. In the function, fetch the root thread to get the thread author's ID. Look up their email in auth.users using the service role key. Call the Resend API to send a notification email with a link back to the thread. Add a guard: skip the email if the commenter's author_id equals the thread author's ID to avoid self-notifications.

### Can I build a forum without a separate forum platform like Discourse?

Yes — Discourse is a full standalone application with its own hosting requirements, authentication, and admin panel. Building your forum directly in Supabase means it is part of your existing app, shares your auth system, and does not require embedding an external iframe or managing a separate service. The trade-off is you build moderation tooling yourself, but Lovable can scaffold the basics in a single session.

### How many users can a Supabase-backed forum handle?

Supabase Pro ($25/mo) comfortably supports a forum with 10,000 registered users and hundreds of thousands of posts. The critical optimisations are the GIN index for search and the composite indexes on (category_id, created_at) for thread lists. Past 100,000 posts and 50,000 active users, consider adding read replicas (Supabase Team plan) and moving search to Algolia.

---

Source: https://www.rapidevelopers.com/app-features/community-forum
© RapidDev — https://www.rapidevelopers.com/app-features/community-forum
