Feature spec
BeginnerCategory
communication-social
Build with AI
2–4 hours with Lovable or V0
Custom build
2–4 days custom dev
Running cost
$0/mo up to 100 users · $25/mo at 1K users
Works on
Everything it takes to ship Commenting — parts, prompts, and real costs.
A comment section needs five pieces: a recursive React component for threaded replies, Supabase Realtime for live updates, a comments table with parent_id threading and reaction toggle, an RLS policy that separates read from write access, and a moderation Edge Function for flagged content. With Lovable or V0 you can ship a working comment section in 2–4 hours for $0/month at 100 users, rising to $25/month at 1,000 as Supabase Pro becomes necessary for Realtime connection pooling.
What a Comment Section Actually Is
Commenting is one of the highest-leverage engagement features you can add: it turns passive content into conversations. A production comment section is more than a textarea and a list — it needs threading (parent_id linking replies to parents), real-time delivery (Supabase Realtime so new comments appear without polling), reaction toggles with a UNIQUE database constraint to prevent double-likes, soft delete so moderated comments leave a placeholder, and an @mention system that autocompletes from the users table. Each of these is a separate data and UI decision, and AI tools generate all of them if you're specific in your prompt.
What users consider table stakes in 2026
- Threaded replies up to 3 levels deep with indentation that makes the conversation hierarchy immediately clear
- New comments appear in real time without the user refreshing the page — a 'New comment' indicator at the top for large threads
- Like or react button on each comment with an immediate optimistic toggle so the count updates before the server responds
- Edit and delete controls visible on the author's own comments, with the edit window enforced server-side (not just client-side)
- Report or flag button on every comment opening a brief confirmation before sending to the moderation queue
- @username mention autocomplete in the composer that resolves to a clickable profile link in the rendered comment
Anatomy of the Feature
Six components across four layers. The recursive component and the Realtime subscription are where AI-generated builds most often need a second prompt to get right.
Comment thread component
UIA recursive React component CommentNode that renders a comment and, if replies exist, maps over them calling itself with depth + 1. Stops recursing at depth >= 3 and renders a 'View more replies' link instead. Uses shadcn/ui Avatar for author portraits and Button for actions. React Query handles cache invalidation when a Realtime INSERT event fires.
Note: Depth guard is mandatory — AI tools frequently generate the recursion without the max-depth check, causing stack overflows on deeply nested data.
Comment composer
UIA shadcn/ui Textarea with a 1,000-character counter below it. The @mention trigger detects the '@' character with a regex, opens a shadcn Popover listing matching usernames fetched from the users table (ILIKE prefix search, limit 10), and inserts the selected username at the cursor. Parent comment ID is tracked in local state when the user clicks 'Reply'.
Note: Fetch mentions with a parametrized search: SELECT id, username, avatar_url FROM users WHERE username ILIKE $1 || '%' LIMIT 10. Never SELECT * — the users table is large and the response is unnecessary.
Comment CRUD API
BackendSupabase Server Actions or Next.js Route Handlers handling insert (new comment or reply with parent_id), update (body text within edit window), and soft delete (set status='deleted', never remove the row). RLS policies enforce: authenticated write, public read on public content, author-only update and delete.
Note: Enforce the 10-minute edit window in a Supabase RLS UPDATE policy — not just client-side. Policy: auth.uid() = user_id AND created_at > NOW() - INTERVAL '10 minutes'.
Real-time subscription
ServiceSupabase Realtime channel subscribed to INSERT events on the comments table filtered by content_id. When a new comment arrives, it is appended to local React Query cache state without a full refetch. On component unmount, channel.unsubscribe() is called in the useEffect cleanup to prevent memory leaks and duplicate event firing in React StrictMode.
Note: In React StrictMode, components mount twice in development, creating two subscriptions and delivering each event twice. Track subscription state in a ref to guard against double-subscribe.
Reaction system
DataA comment_reactions table with a UNIQUE constraint on (comment_id, user_id) that makes the database enforce toggle behavior — attempting to insert a duplicate reaction returns a conflict, which the app interprets as 'already liked, now unlike' by deleting instead. Reaction counts are aggregated in a Supabase view to avoid a COUNT(*) query per comment on every render.
Note: Use UPSERT or check-and-delete logic at the application layer: try INSERT ON CONFLICT DO NOTHING, then check rows affected — if 0, DELETE. This avoids a round-trip query to check current state.
Moderation queue
BackendA Supabase Edge Function triggered on comment insert checks body text against a blocked-words list and sets status='pending' on matches. An admin dashboard table shows pending comments with approve or delete actions. Optional: Akismet API integration for spam scoring on text content.
Note: Soft-delete is critical for moderation — setting status='deleted' preserves the row for audit logs and keeps the thread structure intact, while the UI renders 'This comment was removed' in place of the body.
The data model
Three database objects needed: the comments table with threading and status, a reactions table with a unique toggle constraint, and an index for fast per-content queries. Run this in the Supabase SQL Editor:
1create type public.comment_status as enum ('active', 'pending', 'deleted');23create table public.comments (4 id uuid primary key default gen_random_uuid(),5 content_id uuid not null,6 content_type text not null default 'post',7 user_id uuid references auth.users(id) on delete set null,8 parent_id uuid references public.comments(id) on delete cascade,9 body text not null check (char_length(body) <= 1000),10 status public.comment_status not null default 'active',11 created_at timestamptz not null default now(),12 updated_at timestamptz not null default now()13);1415alter table public.comments enable row level security;1617-- Public content comments are readable by all18create policy "Anyone can read active comments"19 on public.comments for select20 using (status = 'active');2122-- Authenticated users can insert comments23create policy "Authenticated users can insert comments"24 on public.comments for insert25 with check (auth.uid() = user_id);2627-- Authors can update their own comments within 10 minutes28create policy "Authors can update own comments within 10 minutes"29 on public.comments for update30 using (auth.uid() = user_id and created_at > now() - interval '10 minutes');3132-- Authors can soft-delete their own comments33create policy "Authors can delete own comments"34 on public.comments for delete35 using (auth.uid() = user_id);3637create index comments_content_created_idx38 on public.comments (content_id, created_at desc);3940create index comments_parent_id_idx41 on public.comments (parent_id)42 where parent_id is not null;4344-- Reactions with toggle constraint45create table public.comment_reactions (46 comment_id uuid references public.comments(id) on delete cascade not null,47 user_id uuid references auth.users(id) on delete cascade not null,48 reaction_type text not null default 'like',49 created_at timestamptz not null default now(),50 constraint comment_reactions_unique unique (comment_id, user_id)51);5253alter table public.comment_reactions enable row level security;5455create policy "Authenticated users can manage own reactions"56 on public.comment_reactions for all57 using (auth.uid() = user_id)58 with check (auth.uid() = user_id);5960-- View: reaction counts per comment61create view public.comment_reaction_counts as62 select comment_id, count(*) as like_count63 from public.comment_reactions64 where reaction_type = 'like'65 group by comment_id;Heads up: The parent_id partial index on non-null values keeps reply-fetch queries fast. The UNIQUE constraint on comment_reactions is the database-enforced toggle — no application-level deduplication logic needed.
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.
Best path — Lovable generates comment CRUD, the Supabase Realtime subscription, and RLS policies in one Agent Mode session. The recursive thread rendering and @mention autocomplete need explicit prompting but Lovable handles them well.
Step by step
- 1Create a new Lovable project and connect Lovable Cloud to provision Supabase Auth and the database
- 2Run the SQL schema from this page in Lovable Cloud → Database → SQL Editor to create comments and comment_reactions tables
- 3Paste the prompt below in Agent Mode and let it build the comment thread, composer, and reaction system
- 4After the initial build, send a follow-up prompt: 'Add @mention autocomplete to the comment composer that searches the users table and inserts the username at the cursor'
- 5Test the Realtime subscription by opening two browser tabs on the published URL and posting a comment in one — it should appear in the other within 1–2 seconds
Build a full comment section feature. Data: use Supabase with a 'comments' table (id, content_id, content_type, user_id, parent_id uuid nullable, body text max 1000 chars, status enum active/pending/deleted, created_at, updated_at) and a 'comment_reactions' table (comment_id, user_id, reaction_type, UNIQUE on comment_id+user_id). UI components: 1) CommentThread — recursive CommentNode component that renders each comment with author avatar, body, timestamp, Like button, Reply button, Edit button (author only), Delete button (author only, soft-delete sets status='deleted'). Stop recursing at depth 3 and show 'View N more replies' instead. Soft-deleted comments show 'This comment was removed' placeholder. 2) CommentComposer — shadcn Textarea with character counter (1000 max), submit button disabled when empty. 3) Real-time: Supabase Realtime channel subscribed to INSERT on comments filtered by content_id — append new comments to local state; call channel.unsubscribe() in useEffect cleanup. 4) Optimistic like toggle: insert to comment_reactions on click, revert on error; if insert conflicts (UNIQUE violation) delete instead. Apply RLS: authenticated write, public read for active comments, author-only update within 10 minutes (enforce server-side with RLS policy), author soft-delete. Show 'Be the first to comment' empty state when thread is empty.
Where this path bites
- @mention autocomplete with user search requires a separate follow-up prompt — Lovable sometimes omits it from the first build
- Deep threading (3+ levels) occasionally gets rendered as a flat list in the first build — inspect and prompt 'make replies indent recursively up to 3 levels' if needed
- Moderation queue UI (admin dashboard) is not generated by default; add it in a second session
Third-party services you'll need
Commenting is primarily a database and Realtime feature — third-party services are optional quality-of-life additions:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | PostgreSQL database, Realtime subscriptions, Auth, and RLS policy enforcement | 2 projects, 500MB DB, 200 concurrent Realtime connections | $25/mo (Pro) |
| Akismet | Spam detection on comment body text — optional but effective for public-facing communities | Free for personal/non-commercial sites | $10/mo commercial (approx) |
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 covers the database, Realtime (200 concurrent connections), and Auth at this scale. No third-party moderation needed unless the community is public.
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.
Realtime subscription fires duplicate events in React StrictMode
Symptom: React StrictMode (active in development) mounts components twice to surface side effects. This creates two Supabase Realtime subscriptions for the same channel, so each INSERT event fires twice and new comments appear duplicated in the UI.
Fix: Track the subscription in a useRef: const subscriptionRef = useRef(null). Only create the channel if subscriptionRef.current is null. Call channel.unsubscribe() in the useEffect cleanup and set subscriptionRef.current = null. This ensures exactly one active subscription regardless of StrictMode mounting behavior.
Recursive CommentNode causes stack overflow on deep threads
Symptom: AI tools generate the recursive CommentNode without a max-depth guard. If your database has orphaned parent_ids or a reply chain 10+ levels deep (from data migration or a bug), the component recurses without stopping and throws a Maximum call stack size exceeded error.
Fix: Pass a depth prop (default 0) to CommentNode. At depth >= 3, render a 'View N more replies' button that loads deeper replies on click rather than recursing. Test with a seeded 5-level-deep thread in your dev database to confirm the guard works.
Edit window not enforced server-side
Symptom: AI builds the 10-minute edit check by hiding the Edit button after 10 minutes in the UI. But the Supabase RLS UPDATE policy doesn't include a time check, so any user can edit their comment hours later by calling the Supabase client directly from the browser console.
Fix: Add a time-bound RLS UPDATE policy: CREATE POLICY 'Authors can update own comments within 10 minutes' ON public.comments FOR UPDATE USING (auth.uid() = user_id AND created_at > NOW() - INTERVAL '10 minutes'). This makes the database reject late edits regardless of how the client calls it.
Comment count on content card drifts out of sync
Symptom: The comment count badge on content cards is typically a client-side counter that increments on successful insert. After Realtime updates from other users, page refreshes, or failed inserts that were optimistically counted, the displayed count diverges from the real database count.
Fix: Maintain a trigger-updated counter column on your content table: CREATE TRIGGER update_comment_count AFTER INSERT OR DELETE ON comments FOR EACH ROW EXECUTE FUNCTION update_content_comment_count(). Always read comment_count from the content row — never track it in component state.
@mention autocomplete fetches the entire users table
Symptom: AI tools generate the @mention autocomplete by fetching SELECT * FROM users on every keystroke with no WHERE clause. On a database with 10,000+ users this returns megabytes of data, slows the composer, and exposes user data that should not be client-accessible.
Fix: Parametrize the search and scope the columns: SELECT id, username, avatar_url FROM users WHERE username ILIKE $1 || '%' LIMIT 10. Debounce the query 300ms after the last keystroke so it doesn't fire on every character.
Best practices
Enforce the comment edit window in a Supabase RLS UPDATE policy, not just the UI — client-side guards are trivially bypassed
Soft-delete comments (set status='deleted') rather than removing rows — this preserves thread structure, maintains audit trails, and prevents orphaned replies from losing context
Use a database trigger-maintained counter column for comment counts on content cards — never COUNT(*) per-request and never rely on client state
Call channel.unsubscribe() in the useEffect cleanup function to prevent the Realtime subscription from leaking across navigation events
Debounce @mention autocomplete queries 300ms and limit results to 10 users — full-table username scans are one of the most common performance mistakes in comment implementations
Show an optimistic comment immediately on submit, then replace it with the server-confirmed row on success or remove it with an error toast on failure — this makes the UI feel instant
Implement a depth guard in CommentNode (stop recursion at depth 3, show 'View more replies') to prevent stack overflows on unexpectedly deep thread data
When You Need Custom Development
AI tools cover the standard comment section well. These requirements exceed what a first-build prompt session produces reliably:
- AI-powered comment moderation is required — toxicity detection via Google Perspective API or sentiment scoring via OpenAI, not just a keyword blocklist
- Comment email digests are needed — a daily summary of all replies to your comments via Resend or Postmark, with per-user frequency preferences
- Nested threading beyond 3 levels is a core feature — this requires a closure table or materialized path schema instead of the simple parent_id approach
- Multi-language comment translation is required — DeepL or Google Translate API integration with per-user language settings and cached translations
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 add threaded replies to comments?
Add a parent_id column (uuid, nullable) to your comments table that references the same table. When a user clicks Reply, capture the parent comment's id and pass it as parent_id on insert. In your React component, build a recursive CommentNode that renders children whose parent_id matches the current comment's id. Add a depth guard at 3 levels — render a 'View more replies' link instead of recursing deeper.
How do I prevent spam in my comment section?
A layered approach works best: 1) Require authentication to comment (Supabase RLS enforces this). 2) Add a blocked-words list check in a Supabase Edge Function that sets status='pending' on matches. 3) For public communities, add Akismet API spam scoring on comment insert — it's $10/month commercial (approx) and catches most automated spam. AI-powered toxicity scoring via Google Perspective API is effective for hate speech but adds latency.
How do I show new comments in real time without refreshing?
Use a Supabase Realtime channel subscribed to INSERT events on the comments table filtered by content_id. In React, set up the channel in a useEffect and append each new comment row to your local state. Call channel.unsubscribe() in the cleanup function. This pattern works on the Supabase free tier for up to 200 concurrent connections — upgrade to Pro for higher concurrent user counts.
How do I implement a 10-minute comment edit window?
Two layers are required. Client-side: hide the Edit button when created_at is more than 10 minutes ago. Server-side: add a Supabase RLS UPDATE policy — USING (auth.uid() = user_id AND created_at > NOW() - INTERVAL '10 minutes'). The client-side guard is UX; the RLS policy is security. Without the RLS policy, any user can edit their comment via a direct database call regardless of what the UI shows.
How do I add emoji reactions to comments?
Create a comment_reactions table with columns comment_id, user_id, and reaction_type (text — store the emoji character or a slug like 'heart'). Add a UNIQUE constraint on (comment_id, user_id) to enforce one reaction per user. Toggle behavior: try INSERT on click; if it conflicts (user already reacted), DELETE the existing row instead. Aggregate reaction counts in a Supabase view to avoid per-comment COUNT queries.
How do I notify users when someone replies to their comment?
On comment insert, check if parent_id is set. If so, fetch the parent comment's user_id and create a notification row in a notifications table. Combine with your push notification or in-app messaging system to deliver it. For email notifications, trigger a Supabase Edge Function that calls Resend with the reply context. Batch multiple rapid replies into a single email to avoid notification flooding.
How do I handle soft-delete vs hard-delete for comments?
Soft-delete (set status='deleted', keep the row) is almost always the right choice for comment systems. It preserves thread structure so replies don't become orphans, maintains audit trails for moderation decisions, and lets you show 'This comment was removed' placeholders rather than confusing gaps in the thread. Hard-delete the row only for GDPR right-to-erasure requests, and even then replace the body with 'Content removed' rather than deleting the row if there are replies.
Can I add comments without requiring login?
Yes — remove the auth.uid() = user_id check from your RLS INSERT policy and allow anonymous inserts. Store a nullable user_id plus a session_id or IP fingerprint for anonymous commenters. The tradeoff is spam: without authentication, your blocked-words list and Akismet integration become essential. Most production apps require login for commenting because it dramatically reduces spam and enables edit/delete attribution.
Need this feature production-ready?
RapidDev builds commenting into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.