# How to Add Commenting to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

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.

## 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** (ui): A 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.
- **Comment composer** (ui): A 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'.
- **Comment CRUD API** (backend): Supabase 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.
- **Real-time subscription** (service): Supabase 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.
- **Reaction system** (data): A 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.
- **Moderation queue** (backend): A 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.

## 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:

```sql
create type public.comment_status as enum ('active', 'pending', 'deleted');

create table public.comments (
  id uuid primary key default gen_random_uuid(),
  content_id uuid not null,
  content_type text not null default 'post',
  user_id uuid references auth.users(id) on delete set null,
  parent_id uuid references public.comments(id) on delete cascade,
  body text not null check (char_length(body) <= 1000),
  status public.comment_status not null default 'active',
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

alter table public.comments enable row level security;

-- Public content comments are readable by all
create policy "Anyone can read active comments"
  on public.comments for select
  using (status = 'active');

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

-- Authors can update their own comments within 10 minutes
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');

-- Authors can soft-delete their own comments
create policy "Authors can delete own comments"
  on public.comments for delete
  using (auth.uid() = user_id);

create index comments_content_created_idx
  on public.comments (content_id, created_at desc);

create index comments_parent_id_idx
  on public.comments (parent_id)
  where parent_id is not null;

-- Reactions with toggle constraint
create table public.comment_reactions (
  comment_id uuid references public.comments(id) on delete cascade not null,
  user_id uuid references auth.users(id) on delete cascade not null,
  reaction_type text not null default 'like',
  created_at timestamptz not null default now(),
  constraint comment_reactions_unique unique (comment_id, user_id)
);

alter table public.comment_reactions enable row level security;

create policy "Authenticated users can manage own reactions"
  on public.comment_reactions for all
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

-- View: reaction counts per comment
create view public.comment_reaction_counts as
  select comment_id, count(*) as like_count
  from public.comment_reactions
  where reaction_type = 'like'
  group by comment_id;
```

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 paths

### Lovable — fit 5/10, 2–3 hours

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.

1. Create a new Lovable project and connect Lovable Cloud to provision Supabase Auth and the database
2. Run the SQL schema from this page in Lovable Cloud → Database → SQL Editor to create comments and comment_reactions tables
3. Paste the prompt below in Agent Mode and let it build the comment thread, composer, and reaction system
4. After 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'
5. Test 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

Starter prompt:

```
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.
```

Limitations:

- @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

### V0 — fit 4/10, 2–4 hours

Strong TypeScript output with proper type inference for the recursive comment tree. Best when the comment section is part of a larger Next.js app and you want clean components you'll extend later.

1. Prompt V0 with the spec below to generate the CommentThread, CommentNode, CommentComposer, and reaction toggle
2. Add Supabase environment variables in the Vars panel: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
3. Run the SQL schema from this page in the Supabase SQL Editor
4. Add an explicit follow-up prompt to wire Supabase Realtime: 'Add a Supabase Realtime subscription in CommentThread that appends new comment rows to local state on INSERT events filtered by content_id'
5. Deploy to Vercel; test the Realtime subscription with two browser windows

Starter prompt:

```
Create a Next.js 14 App Router comment section. Types: Comment { id, content_id, content_type, user_id, parent_id: string | null, body, status: 'active' | 'pending' | 'deleted', created_at, updated_at }. CommentReaction { comment_id, user_id, reaction_type }. Components: 1) CommentNode (client component) — renders one comment with: author Avatar from shadcn/ui, relative timestamp, body text (or 'This comment was removed' if status=deleted), a Like button showing like_count with optimistic toggle using Supabase INSERT ON CONFLICT DO NOTHING / DELETE logic, a Reply button that sets replyingTo state in parent, Edit and Delete buttons visible only when auth.uid() matches user_id. Recursively renders reply CommentNodes indented by 16px; stops at depth=3 with a 'View more replies' link. 2) CommentComposer (client component) — shadcn Textarea with max 1000 chars, character counter, submit via Supabase insert with content_id and parent_id from props, disabled during submission with loading state. 3) CommentThread (client component) — fetches top-level comments (parent_id IS NULL) for content_id from Supabase with reaction counts joined via comment_reaction_counts view; shows 'Be the first to comment' empty state. Server action insertComment validates body length and calls Supabase insert. RLS enforces auth. Include a Supabase Realtime subscription appending new comments on INSERT; unsubscribe on unmount.
```

Limitations:

- Supabase Realtime subscription is not always generated in the first V0 output — you may need the explicit follow-up prompt
- No auto-provisioned database — set up the schema manually using the SQL from this page
- Occasional shadcn/ui registry mismatches (missing Avatar or Textarea imports) require a 'fix the imports' follow-up

### Custom — fit 4/10, 2–4 days

Full control over moderation pipeline (Akismet spam scoring, Perspective API toxicity detection), comment email digests, nested threading beyond 3 levels using a closure table, and multi-language translation. Choose this when comments are central to your product's value.

1. Implement a closure table for infinite-depth threading — store all ancestor-descendant pairs so any subtree can be fetched in a single query without recursive CTEs
2. Add Akismet API spam scoring on comment insert in the moderation Edge Function for automated spam filtering
3. Set up daily email digest via Resend: aggregate replies to your comments in the past 24 hours and send a single digest email
4. Add DeepL or Google Translate API integration so users can click 'Translate' on any comment in another language

Limitations:

- Closure table threading requires a more complex schema and query pattern — not appropriate for MVP builds
- Akismet, translation APIs, and email digests each add external service dependencies and cost

## Gotchas

- **Realtime subscription fires duplicate events in React StrictMode** — 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** — 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** — 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** — 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** — 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

## 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.

---

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