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
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
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.
1Create a community forum with Supabase. Set up these tables:23- user_roles: user_id (uuid references auth.users pk), role (text check in ('member','moderator','admin'), default 'member'), created_at4- 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_at5- 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_at6- 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_at7- 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)89RLS:10- posts: authenticated SELECT where deleted_at IS NULL, authenticated INSERT, author/moderator/admin UPDATE, moderator/admin soft-delete11- comments: same pattern as posts12- votes: authenticated SELECT, authenticated INSERT/UPDATE on their own votes (use upsert), authenticated DELETE on their own votes13- user_roles: users SELECT their own role, admin manages all14- categories: anon/authenticated SELECT, admin INSERT/UPDATE/DELETE1516Triggers:17- On votes INSERT/UPDATE/DELETE: recalculate vote_score on the target post or comment18- On comments INSERT: increment posts.comment_count19- On comments DELETE (or deleted_at set): decrement posts.comment_count20- On posts INSERT: increment categories.post_count, update categories.last_post_atPro 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.
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.
1// supabase/functions/get-comments/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const cors = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8 'Content-Type': 'application/json',9}1011interface FlatComment {12 id: string13 parent_id: string | null14 author_id: string15 author_name: string16 body: string17 vote_score: number18 deleted_at: string | null19 created_at: string20 depth: number21 replies?: FlatComment[]22}2324function 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 roots37}3839serve(async (req: Request) => {40 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })4142 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 })4546 const supabase = createClient(47 Deno.env.get('SUPABASE_URL') ?? '',48 Deno.env.get('SUPABASE_ANON_KEY') ?? ''49 )5051 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 })5354 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.
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.
1Build a category page at src/pages/Category.tsx. Route: /c/:slug.23Fetch 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 })45Post list items:6- Pinned posts appear first with a 'Pinned' Badge7- Each item shows: title (link to /post/:id), author avatar + name, created_at relative, vote_score (with up/down arrow Buttons), comment_count, category Badge8- The vote Buttons call handleVote(postId, +1 or -1)910voteScore local state:11- Store vote scores as a Map<postId, number> in component state12- Initialize from fetched post data13- On vote button click: optimistic update (increment/decrement local state immediately), then upsert to votes table1415Realtime 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 state18- Unsubscribe on unmount1920Moderation 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.
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.
1Build a post detail page at src/pages/PostDetail.tsx. Route: /post/:postId.23Post body rendering:41. Install marked (for markdown to HTML conversion, importable via esm.sh or npm)52. Install DOMPurify for XSS sanitization63. To render the post body: const html = DOMPurify.sanitize(marked.parse(post.body))74. Render: <div className="prose" dangerouslySetInnerHTML={{ __html: html }} />89Comment section:10- Call the get-comments Edge Function with postId11- Render the tree recursively with a CommentNode component12- CommentNode shows: avatar, author name, vote buttons, relative time, body (DOMPurify.sanitize(marked.parse(comment.body))), 'Reply' Button13- 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 info15- Indent nested comments with left border + padding (depth * 16px margin-left, capped at 5 levels)1617Reply form (new top-level comment at the bottom):18- Textarea with markdown support note19- Submit Button calling supabase.from('comments').insert({ post_id, author_id, body })2021Locked 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
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34const cors = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',7 'Content-Type': 'application/json',8}910interface FlatComment {11 id: string12 parent_id: string | null13 author_id: string14 author_name: string15 body: string16 vote_score: number17 deleted_at: string | null18 created_at: string19 depth: number20 replies?: FlatComment[]21}2223function 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 roots36}3738serve(async (req: Request) => {39 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })4041 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 }4647 const supabase = createClient(48 Deno.env.get('SUPABASE_URL') ?? '',49 Deno.env.get('SUPABASE_ANON_KEY') ?? ''50 )5152 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 }5657 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation