# How to Build a Community Forum with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a threaded community forum in Lovable with recursive comments, categories, upvote/downvote voting, moderation roles, and markdown rendering with XSS sanitization. Supabase Realtime delivers vote count updates live. A recursive CTE powers unlimited nesting depth — all without a third-party forum service.

## Before you start

- Lovable Pro account (recursive CTE Edge Function + Realtime + multiple pages requires significant credits)
- Supabase project with URL, anon key, and service role key saved to Cloud tab → Secrets
- Supabase Auth with email/password signup
- Basic understanding of how Supabase Realtime channels work
- Familiarity with markdown formatting that your users will use

## Step-by-step guide

### 1. Create the forum schema with roles and RLS

The schema must support recursive comments, voting, soft deletes, and moderation roles. Get this foundation right because it is difficult to restructure later.

```
Create a community forum with Supabase. Set up these tables:

- user_roles: user_id (uuid references auth.users pk), role (text check in ('member','moderator','admin'), default 'member'), created_at
- categories: id (uuid pk), name (text unique not null), slug (text unique), description (text), sort_order (int), post_count (int default 0), last_post_at (timestamptz), created_at
- posts: id (uuid pk), author_id (uuid references auth.users), category_id (uuid references categories), title (text not null), body (text, markdown), vote_score (int default 0), comment_count (int default 0), is_pinned (bool default false), is_locked (bool default false), deleted_at (timestamptz, null = not deleted), created_at, updated_at
- comments: id (uuid pk), post_id (uuid references posts on delete cascade), author_id (uuid references auth.users), parent_id (uuid references comments null, null = top-level), body (text, markdown), vote_score (int default 0), deleted_at (timestamptz), created_at, updated_at
- votes: id (uuid pk), user_id (uuid references auth.users), target_type (text check in ('post','comment')), target_id (uuid), vote_type (int check in (1,-1)), UNIQUE(user_id, target_type, target_id)

RLS:
- posts: authenticated SELECT where deleted_at IS NULL, authenticated INSERT, author/moderator/admin UPDATE, moderator/admin soft-delete
- comments: same pattern as posts
- votes: authenticated SELECT, authenticated INSERT/UPDATE on their own votes (use upsert), authenticated DELETE on their own votes
- user_roles: users SELECT their own role, admin manages all
- categories: anon/authenticated SELECT, admin INSERT/UPDATE/DELETE

Triggers:
- On votes INSERT/UPDATE/DELETE: recalculate vote_score on the target post or comment
- On comments INSERT: increment posts.comment_count
- On comments DELETE (or deleted_at set): decrement posts.comment_count
- On posts INSERT: increment categories.post_count, update categories.last_post_at
```

> Pro tip: Ask Lovable to create a Supabase function is_moderator_or_admin() that returns true if auth.uid() has role moderator or admin in user_roles. Use this function in RLS policies instead of repeating the role check in every policy.

**Expected result:** All tables are created. The vote_score trigger fires correctly on vote changes. The comment_count and category.post_count denormalizations work. TypeScript types are generated.

### 2. Build the threaded comment Edge Function

A recursive CTE in PostgreSQL fetches the entire comment tree for a post in a single query. The Edge Function returns this as a nested JSON structure that React renders recursively.

```
// supabase/functions/get-comments/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

interface FlatComment {
  id: string
  parent_id: string | null
  author_id: string
  author_name: string
  body: string
  vote_score: number
  deleted_at: string | null
  created_at: string
  depth: number
  replies?: FlatComment[]
}

function buildTree(flat: FlatComment[]): FlatComment[] {
  const map = new Map<string, FlatComment>()
  flat.forEach((c) => { map.set(c.id, { ...c, replies: [] }) })
  const roots: FlatComment[] = []
  flat.forEach((c) => {
    const node = map.get(c.id)!
    if (c.parent_id && map.has(c.parent_id)) {
      map.get(c.parent_id)!.replies!.push(node)
    } else {
      roots.push(node)
    }
  })
  return roots
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  const url = new URL(req.url)
  const postId = url.searchParams.get('postId')
  if (!postId) return new Response(JSON.stringify({ error: 'postId required' }), { status: 400, headers: cors })

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? ''
  )

  const { data, error } = await supabase.rpc('get_comment_tree', { p_post_id: postId })
  if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })

  const tree = buildTree(data as FlatComment[])
  return new Response(JSON.stringify({ data: tree }), { headers: cors })
})
```

> Pro tip: Ask Lovable to also create the get_comment_tree Supabase function using a recursive CTE: WITH RECURSIVE comment_tree AS (SELECT c.*, 0 AS depth FROM comments c WHERE c.post_id = p_post_id AND c.parent_id IS NULL UNION ALL SELECT c.*, ct.depth + 1 FROM comments c JOIN comment_tree ct ON c.parent_id = ct.id) SELECT ct.*, p.email as author_name FROM comment_tree ct LEFT JOIN auth.users p ON ct.author_id = p.id ORDER BY ct.depth, ct.created_at.

**Expected result:** The Edge Function calls the recursive CTE SQL function and returns nested JSON. The buildTree function converts the flat list into a tree structure.

### 3. Build the post list with Realtime vote scores

Create the category post list and the global feed. Supabase Realtime updates vote scores across all clients when any user votes.

```
Build a category page at src/pages/Category.tsx. Route: /c/:slug.

Fetch posts for this category: supabase.from('posts').select('*, user_profiles(display_name, avatar_url)').eq('category_id', categoryId).is('deleted_at', null).order('is_pinned', { ascending: false }).order('created_at', { ascending: false })

Post list items:
- Pinned posts appear first with a 'Pinned' Badge
- Each item shows: title (link to /post/:id), author avatar + name, created_at relative, vote_score (with up/down arrow Buttons), comment_count, category Badge
- The vote Buttons call handleVote(postId, +1 or -1)

voteScore local state:
- Store vote scores as a Map<postId, number> in component state
- Initialize from fetched post data
- On vote button click: optimistic update (increment/decrement local state immediately), then upsert to votes table

Realtime vote updates:
- Subscribe to supabase.channel('forum-votes').on('postgres_changes', { event: '*', schema: 'public', table: 'votes' }, (payload) => { refetchPostScore(payload.new.target_id) })
- On each vote event, fetch the updated vote_score for the affected post and update local state
- Unsubscribe on unmount

Moderation controls (only visible to moderators/admins):
- DropdownMenu per post: Pin/Unpin, Lock/Unlock, Delete (sets deleted_at = now())
```

**Expected result:** The category page shows posts with vote buttons. Voting updates the score optimistically. The Realtime subscription reflects votes from other users without page reload.

### 4. Build the post detail page with markdown rendering

Create the post detail page with the full post body rendered from markdown, XSS-sanitized, and the recursive comment thread loaded from the Edge Function.

```
Build a post detail page at src/pages/PostDetail.tsx. Route: /post/:postId.

Post body rendering:
1. Install marked (for markdown to HTML conversion, importable via esm.sh or npm)
2. Install DOMPurify for XSS sanitization
3. To render the post body: const html = DOMPurify.sanitize(marked.parse(post.body))
4. Render: <div className="prose" dangerouslySetInnerHTML={{ __html: html }} />

Comment section:
- Call the get-comments Edge Function with postId
- Render the tree recursively with a CommentNode component
- CommentNode shows: avatar, author name, vote buttons, relative time, body (DOMPurify.sanitize(marked.parse(comment.body))), 'Reply' Button
- Clicking 'Reply' on a CommentNode shows an inline Textarea below it. On submit, insert to comments with parent_id = comment.id.
- Soft-deleted comments show: '[This comment was deleted]' instead of body, no author info
- Indent nested comments with left border + padding (depth * 16px margin-left, capped at 5 levels)

Reply form (new top-level comment at the bottom):
- Textarea with markdown support note
- Submit Button calling supabase.from('comments').insert({ post_id, author_id, body })

Locked post: if post.is_locked, show a 'Locked — this thread is closed' Alert and hide all reply forms.
```

**Expected result:** The post body renders markdown correctly. XSS attempts in the markdown (like <script> tags) are stripped by DOMPurify. The recursive comment tree renders with proper indentation.

## Complete code example

File: `supabase/functions/get-comments/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

interface FlatComment {
  id: string
  parent_id: string | null
  author_id: string
  author_name: string
  body: string
  vote_score: number
  deleted_at: string | null
  created_at: string
  depth: number
  replies?: FlatComment[]
}

function buildTree(flat: FlatComment[]): FlatComment[] {
  const map = new Map<string, FlatComment>()
  flat.forEach((c) => map.set(c.id, { ...c, replies: [] }))
  const roots: FlatComment[] = []
  flat.forEach((c) => {
    const node = map.get(c.id)!
    if (c.parent_id && map.has(c.parent_id)) {
      map.get(c.parent_id)!.replies!.push(node)
    } else {
      roots.push(node)
    }
  })
  return roots
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  const url = new URL(req.url)
  const postId = url.searchParams.get('postId')
  if (!postId) {
    return new Response(JSON.stringify({ error: 'postId required' }), { status: 400, headers: cors })
  }

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? ''
  )

  const { data, error } = await supabase.rpc('get_comment_tree', { p_post_id: postId })
  if (error) {
    return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })
  }

  const tree = buildTree(data as FlatComment[])
  return new Response(JSON.stringify({ data: tree, count: (data as FlatComment[]).length }), { headers: cors })
})
```

## Common mistakes

- **Rendering user-submitted markdown without XSS sanitization** — marked.parse() converts markdown to HTML, but if the markdown contains <script> tags or onerror attributes, they execute in the browser. A malicious user could steal session tokens from any visitor who views the post. Fix: Always pipe markdown output through DOMPurify.sanitize() before passing to dangerouslySetInnerHTML. Ask Lovable: 'Use DOMPurify to sanitize all rendered markdown HTML before displaying it. Import DOMPurify and call sanitize on every marked.parse() result.'
- **Fetching comment trees with N+1 queries** — Fetching top-level comments then looping to fetch replies for each creates dozens of database round trips per post. A post with 50 comments becomes 51 queries. Fix: Use the recursive CTE get_comment_tree Supabase function called once via the Edge Function. This fetches the entire tree in a single database call regardless of nesting depth.
- **Not capping recursive comment indent depth in the UI** — Deeply nested comments (10+ levels) render with extreme left indentation, pushing the actual text off screen on mobile devices. Fix: In the CommentNode component, cap the visual depth: marginLeft: Math.min(depth, 5) * 16. Comments beyond depth 5 render at the same indentation as depth 5. The tree structure is preserved in data — only the visual representation is capped.
- **Hard-deleting comments instead of soft-deleting** — Hard-deleting a comment that has replies removes the parent reference, breaking the thread tree. Replies become orphans with no parent context. Fix: Soft-delete by setting deleted_at = now(). The comment row remains. Render it as '[This comment was deleted]' with no author info. The reply tree structure is preserved.

## Best practices

- Always sanitize markdown output with DOMPurify before rendering. This is not optional — user-generated markdown is an XSS attack surface.
- Use soft deletes for posts and comments. Hard deletes break thread context and make moderation audit trails impossible.
- Denormalize vote_score and comment_count into the posts table via triggers. Never compute these with aggregate queries on the post list — it makes pagination expensive.
- Cap comment nesting depth at 5 levels visually (not in the data). Unlimited visual nesting destroys mobile usability.
- Set a rate limit on post and comment creation per user per hour. A single spammer can flood a forum in minutes without rate limiting. Implement the check in an Edge Function or RLS WITH CHECK.
- Subscribe to Realtime channel changes on votes filtered by the current page's post IDs, not the entire votes table. A global subscription on all votes creates unnecessary event noise.
- Seed categories from an admin-only interface rather than hardcoding them in migrations. Forum categories evolve and admins need to add, rename, or merge them without code deploys.

## Frequently asked questions

### How deep can comments be nested?

The database has no depth limit — the recursive CTE follows parent_id chains to any depth. In practice, cap visual rendering at 5-6 levels deep to maintain readability on mobile. The data structure remains intact at any depth, so you can change the visual cap without a database migration.

### How does the voting system prevent multiple votes from the same user?

The votes table has a UNIQUE(user_id, target_type, target_id) constraint. A second vote from the same user on the same post triggers a constraint violation. Use upsert with ON CONFLICT DO UPDATE SET vote_type = excluded.vote_type to handle vote changes (upvote to downvote). To remove a vote, DELETE the row where user_id = auth.uid() and target_id matches.

### Can I use the same comment system for the social media feed?

Yes, with minor modifications. The posts table maps to social feed posts. The comments table maps to comments on feed posts. Remove the is_pinned and is_locked columns since those are forum-specific. The Realtime vote subscription and DOMPurify markdown rendering are directly reusable.

### What is the performance impact of the recursive CTE on large threads?

PostgreSQL's recursive CTE is efficient for typical forum threads (under 1,000 comments). For threads with thousands of comments (viral posts), add pagination: limit the top-level comments and load replies on demand by clicking a 'Show replies' button. This defers loading deep subtrees until requested.

### How does Realtime vote updating work across browsers?

Each browser tab subscribes to Supabase Realtime changes on the votes table for the current post. When any user votes, Supabase broadcasts a postgres_changes event to all subscribers. The event payload includes the new vote row. Your handler fetches the updated vote_score for the affected post and updates local state — no page reload needed.

### How do I handle very long markdown posts?

Add a MAX character limit in the post creation form (e.g. 10,000 characters). Show a char count warning at 80% capacity. In the post detail view, if post.body exceeds 3,000 characters, show a truncated preview with a 'Read more' button that expands the full content. This keeps the post list page fast even with verbose posts.

### Can RapidDev help add real-time notifications and advanced moderation?

RapidDev builds full-featured community platforms on Lovable including notification pipelines, AI-assisted content moderation, user reputation systems, and custom moderation tooling. Reach out if your forum needs features beyond the threading and voting pattern in this guide.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/community-forum
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/community-forum
