Skip to main content
RapidDev - Software Development Agency

How to Build a Community Forum with Lovable

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.

What you'll build

  • posts, comments (recursive parent_id), votes, and categories tables with role-based RLS
  • Threaded comment rendering using a recursive PostgreSQL CTE fetched via an Edge Function
  • Upvote/downvote voting with Supabase Realtime updating vote scores live without page reload
  • Markdown body rendering with DOMPurify XSS sanitization before HTML injection
  • User roles table supporting member, moderator, and admin with per-action RLS checks
  • Moderator dashboard for pinning posts, locking threads, and soft-deleting content
  • Category pages with post count, latest activity, and pinned posts at the top
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced13 min read4–5 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
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.

What you're building

A community forum has two technical challenges that most guides skip: recursive comments and safe markdown rendering. Recursive comments (replies to replies to replies) require either a parent_id with multiple round trips, or a single recursive query that fetches the entire tree at once. A PostgreSQL recursive CTE solves this in one database call.

Markdown rendering sounds simple — parse markdown to HTML and display it. The dangerous part is that user-controlled content can contain script tags, onerror attributes, and other XSS vectors if you inject the HTML directly. DOMPurify sanitizes the parsed HTML before it touches the DOM, stripping anything that could execute JavaScript.

Voting uses Supabase Realtime. Each post has a votes table with user_id, post_id, and vote_type (+1/-1). A Realtime subscription on the votes table triggers a score recalculation whenever any vote is cast. The score displayed next to each post updates in milliseconds across all connected browsers without any of them needing to refresh.

Moderation uses a user_roles table (user_id, role: member/moderator/admin). Moderators can pin posts (pin boolean on posts), lock threads (locked boolean), and soft-delete content (deleted_at timestamptz). Soft-deleted content shows as '[Deleted]' but preserves the thread structure so replies still make sense in context.

Final result

A fully threaded community forum with live voting, safe markdown, recursive comments, and a moderator control panel.

Tech stack

LovableFrontend forum UI
SupabaseDatabase, Auth, Realtime
Supabase Edge FunctionsThreaded comment tree (recursive CTE), Deno
Supabase RealtimeLive vote updates
shadcn/uiAvatar, Badge, Separator, DropdownMenu, Sheet components
DOMPurifyXSS sanitization for markdown output

Prerequisites

  • 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

Build steps

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.

prompt.txt
1Create a community forum with Supabase. Set up these tables:
2
3- user_roles: user_id (uuid references auth.users pk), role (text check in ('member','moderator','admin'), default 'member'), created_at
4- 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
5- 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
6- 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
7- 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)
8
9RLS:
10- posts: authenticated SELECT where deleted_at IS NULL, authenticated INSERT, author/moderator/admin UPDATE, moderator/admin soft-delete
11- comments: same pattern as posts
12- votes: authenticated SELECT, authenticated INSERT/UPDATE on their own votes (use upsert), authenticated DELETE on their own votes
13- user_roles: users SELECT their own role, admin manages all
14- categories: anon/authenticated SELECT, admin INSERT/UPDATE/DELETE
15
16Triggers:
17- On votes INSERT/UPDATE/DELETE: recalculate vote_score on the target post or comment
18- On comments INSERT: increment posts.comment_count
19- On comments DELETE (or deleted_at set): decrement posts.comment_count
20- 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
1// supabase/functions/get-comments/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4
5const cors = {
6 'Access-Control-Allow-Origin': '*',
7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
8 'Content-Type': 'application/json',
9}
10
11interface FlatComment {
12 id: string
13 parent_id: string | null
14 author_id: string
15 author_name: string
16 body: string
17 vote_score: number
18 deleted_at: string | null
19 created_at: string
20 depth: number
21 replies?: FlatComment[]
22}
23
24function buildTree(flat: FlatComment[]): FlatComment[] {
25 const map = new Map<string, FlatComment>()
26 flat.forEach((c) => { map.set(c.id, { ...c, replies: [] }) })
27 const roots: FlatComment[] = []
28 flat.forEach((c) => {
29 const node = map.get(c.id)!
30 if (c.parent_id && map.has(c.parent_id)) {
31 map.get(c.parent_id)!.replies!.push(node)
32 } else {
33 roots.push(node)
34 }
35 })
36 return roots
37}
38
39serve(async (req: Request) => {
40 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
41
42 const url = new URL(req.url)
43 const postId = url.searchParams.get('postId')
44 if (!postId) return new Response(JSON.stringify({ error: 'postId required' }), { status: 400, headers: cors })
45
46 const supabase = createClient(
47 Deno.env.get('SUPABASE_URL') ?? '',
48 Deno.env.get('SUPABASE_ANON_KEY') ?? ''
49 )
50
51 const { data, error } = await supabase.rpc('get_comment_tree', { p_post_id: postId })
52 if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })
53
54 const tree = buildTree(data as FlatComment[])
55 return new Response(JSON.stringify({ data: tree }), { headers: cors })
56})

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.

prompt.txt
1Build a category page at src/pages/Category.tsx. Route: /c/:slug.
2
3Fetch 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 })
4
5Post list items:
6- Pinned posts appear first with a 'Pinned' Badge
7- 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
8- The vote Buttons call handleVote(postId, +1 or -1)
9
10voteScore local state:
11- Store vote scores as a Map<postId, number> in component state
12- Initialize from fetched post data
13- On vote button click: optimistic update (increment/decrement local state immediately), then upsert to votes table
14
15Realtime vote updates:
16- Subscribe to supabase.channel('forum-votes').on('postgres_changes', { event: '*', schema: 'public', table: 'votes' }, (payload) => { refetchPostScore(payload.new.target_id) })
17- On each vote event, fetch the updated vote_score for the affected post and update local state
18- Unsubscribe on unmount
19
20Moderation controls (only visible to moderators/admins):
21- 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.

prompt.txt
1Build a post detail page at src/pages/PostDetail.tsx. Route: /post/:postId.
2
3Post body rendering:
41. Install marked (for markdown to HTML conversion, importable via esm.sh or npm)
52. Install DOMPurify for XSS sanitization
63. To render the post body: const html = DOMPurify.sanitize(marked.parse(post.body))
74. Render: <div className="prose" dangerouslySetInnerHTML={{ __html: html }} />
8
9Comment section:
10- Call the get-comments Edge Function with postId
11- Render the tree recursively with a CommentNode component
12- CommentNode shows: avatar, author name, vote buttons, relative time, body (DOMPurify.sanitize(marked.parse(comment.body))), 'Reply' Button
13- Clicking 'Reply' on a CommentNode shows an inline Textarea below it. On submit, insert to comments with parent_id = comment.id.
14- Soft-deleted comments show: '[This comment was deleted]' instead of body, no author info
15- Indent nested comments with left border + padding (depth * 16px margin-left, capped at 5 levels)
16
17Reply form (new top-level comment at the bottom):
18- Textarea with markdown support note
19- Submit Button calling supabase.from('comments').insert({ post_id, author_id, body })
20
21Locked 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

supabase/functions/get-comments/index.ts
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
3
4const cors = {
5 'Access-Control-Allow-Origin': '*',
6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
7 'Content-Type': 'application/json',
8}
9
10interface FlatComment {
11 id: string
12 parent_id: string | null
13 author_id: string
14 author_name: string
15 body: string
16 vote_score: number
17 deleted_at: string | null
18 created_at: string
19 depth: number
20 replies?: FlatComment[]
21}
22
23function buildTree(flat: FlatComment[]): FlatComment[] {
24 const map = new Map<string, FlatComment>()
25 flat.forEach((c) => map.set(c.id, { ...c, replies: [] }))
26 const roots: FlatComment[] = []
27 flat.forEach((c) => {
28 const node = map.get(c.id)!
29 if (c.parent_id && map.has(c.parent_id)) {
30 map.get(c.parent_id)!.replies!.push(node)
31 } else {
32 roots.push(node)
33 }
34 })
35 return roots
36}
37
38serve(async (req: Request) => {
39 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
40
41 const url = new URL(req.url)
42 const postId = url.searchParams.get('postId')
43 if (!postId) {
44 return new Response(JSON.stringify({ error: 'postId required' }), { status: 400, headers: cors })
45 }
46
47 const supabase = createClient(
48 Deno.env.get('SUPABASE_URL') ?? '',
49 Deno.env.get('SUPABASE_ANON_KEY') ?? ''
50 )
51
52 const { data, error } = await supabase.rpc('get_comment_tree', { p_post_id: postId })
53 if (error) {
54 return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })
55 }
56
57 const tree = buildTree(data as FlatComment[])
58 return new Response(JSON.stringify({ data: tree, count: (data as FlatComment[]).length }), { headers: cors })
59})

Customization ideas

User reputation system

Add a reputation int column to user_profiles, calculated from: +10 per post with net positive votes, +2 per comment with net positive votes, -5 per post with net negative votes. A Supabase trigger updates reputation when votes change. High-reputation users unlock moderator features automatically.

Notification system for replies

When a comment is posted, a database trigger inserts a notification row for the parent comment's author (and the original post author for top-level comments). The notification includes the post title, comment excerpt, and a link. Show unread notification count in the header using Supabase Realtime.

Post flairs and tags

Add a flair text and a tags text array to posts. The category page shows flair Badges next to post titles. Admins define available flairs per category. Users select a flair when creating a post. Add tag-based filtering on the category page.

Best answer highlighting

For Q&A-style categories, add an accepted_comment_id to posts. The post author (or moderators) can mark one comment as the accepted answer. The accepted comment gets a green checkmark Badge and floats to the top of the comment list regardless of vote score.

Cross-post sharing

Allow users to repost content from other categories by creating a shared_post_id reference in posts. The shared post renders the original content inline with a 'Cross-posted from /c/category' note. This creates cross-community discovery without duplicating content.

Common pitfalls

Pitfall: Rendering user-submitted markdown without XSS sanitization

How to avoid: 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.'

Pitfall: Fetching comment trees with N+1 queries

How to avoid: 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.

Pitfall: Not capping recursive comment indent depth in the UI

How to avoid: 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.

Pitfall: Hard-deleting comments instead of soft-deleting

How to avoid: 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.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a community forum with Supabase. My comments table has id, parent_id (self-referential), post_id, author_id, body, vote_score, and deleted_at. I need a PostgreSQL recursive CTE function that returns all comments for a post_id, ordered so that parent comments always appear before their children, with a depth column. Join with auth.users to get the email as author_name. Return deleted comments as body = '[Deleted]' and null author data. Write the complete CREATE OR REPLACE FUNCTION get_comment_tree(p_post_id uuid) SQL.

Lovable Prompt

Add a user profile page at /u/:username. Fetch user_profiles and the user's post and comment history. Show: display name, avatar, member since date, post count, comment count. Below the stats, show two Tabs: Posts (list of their post titles with vote scores and comment counts, linked to the post) and Comments (their comment bodies truncated to 200 chars with a link to the parent post). Add a 'Report User' Button that inserts a row into a user_reports table.

Build Prompt

In Supabase, create a database function handle_vote(p_target_type text, p_target_id uuid, p_vote_type int) that handles vote upsert logic atomically. The function should: 1) Upsert into votes (user_id=auth.uid(), target_type, target_id, vote_type) using ON CONFLICT DO UPDATE. 2) Recalculate the vote_score on the target post or comment using SUM(vote_type) from votes. 3) Return the new vote_score. This replaces two separate client-side calls with one server-side atomic operation.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.