How to Add Comments and Mentions to an AI-Built App
Comments look like a weekend feature until you hit the third problem: what a mention should do when the person mentioned cannot see the thing.
To add comments and mentions to an AI-built app you need four pieces: a comments table with a stable thread key, a mention parser that stores resolved user IDs rather than raw text, a permission check at notification time rather than at write time, and a read model that loads a whole thread in one query. Ask an AI builder for "comments" and you will get the first piece and a rough version of the fourth. The two in the middle are where the feature quietly rots, and they are worth specifying up front.
Here is the whole thing, in the order I would build it.
The schema, and the one column people leave out
create table comments (
id uuid primary key default gen_random_uuid(),
subject_type text not null, -- 'task', 'document', 'invoice'
subject_id uuid not null,
parent_id uuid references comments(id) on delete cascade,
author_id uuid not null references users(id),
body text not null,
mentions uuid[] not null default '{}',
created_at timestamptz not null default now(),
edited_at timestamptz,
deleted_at timestamptz
);
create index comments_subject_idx on comments (subject_type, subject_id, created_at);
create index comments_mentions_idx on comments using gin (mentions);The pair subject_type plus subject_id is what lets one table serve comments on tasks, documents and invoices without three near-identical tables. If you only ever comment on one kind of thing, a plain foreign key is cleaner, but that is a rarer situation than it looks at the start.
parent_id gives you one level of replies. Resist arbitrary nesting. Every product that allowed unlimited depth eventually capped it, because at depth four the indentation eats the screen and nobody can follow the conversation. One level of replies covers what people actually do.
deleted_at rather than a hard delete. A deleted comment in the middle of a thread still needs to hold its position so the replies underneath it make sense. Show a tombstone. The mechanics are the same ones in soft delete and undo.
The column most implementations skip is mentions. That is the important one.
Store resolved IDs, not text to re-parse
The obvious implementation of mentions is to keep the raw body and scan it for @something whenever you need to know who was mentioned. It works on the day you build it and breaks in four predictable ways.
Someone changes their username, and every historical mention of them either points at nobody or, worse, at whoever claimed the handle next. Two people share a display name and the parser has to guess. Someone writes an email address in a comment and the parser fires on the domain. Someone types @ in a code snippet.
Resolving once at write time removes all four. When a comment is saved, the client already knows exactly who was picked, because the picker made them choose from a list. Send those IDs alongside the text:
{
"body": "@Dana can you check the tax rule before Friday?",
"mentions": ["9f1c...a2", "4b77...e1"]
}Then validate on the server rather than trusting the client: confirm each ID exists, is a real user, and appears in the body as a mention. The stored array becomes the source of truth for notifications, and the text is only ever used for display. Rendering can match the ID back to the current display name, so a rename updates every old comment automatically instead of breaking it.
This is the sort of decision an AI builder will not make unprompted, because the naive version satisfies the request. Say it explicitly in the prompt:
Mentions are stored as a uuid[] column of resolved user IDs, set at
write time from the mention picker and validated server-side. Never
re-parse the comment body to determine who was mentioned. Render
mentions by looking up the current display name from the stored ID.Check permissions at fan-out, not at write
Here is the failure that turns a nice feature into an incident.
A user mentions a colleague on a document. The colleague does not have access to that document. The notification goes out with a preview of the comment, and now someone has read a line of a document they were never permitted to open. The comment itself was written by someone with legitimate access, so a write-time check passes cleanly. The leak happens on the way out.
The rule: every recipient is authorised individually, at the moment the notification is built, against the subject the comment is attached to.
def notify_mentions(comment):
recipients = []
for user_id in comment.mentions:
if user_id == comment.author_id:
continue # no self-notifications
if not can_view(user_id, comment.subject_type, comment.subject_id):
recipients.append((user_id, "no_access"))
continue
recipients.append((user_id, "notify"))
return recipientsWhat to do with the no_access cases is a product decision, and both answers are defensible. You can drop the notification silently, which is safest and confuses the author. Or you can tell the author "Dana was mentioned but cannot see this document" and offer to share it, which is more useful and reveals that Dana exists. Pick deliberately. Do not let it be decided by whichever branch the code fell into.
Same check applies to thread participants if you notify them too. Access changes over time, and a user who could see the document in March may not in September.
Loading a thread without N+1 queries
The read path is where comment features get slow, because the obvious implementation fetches comments and then fetches each author separately.
select c.id, c.parent_id, c.body, c.mentions, c.created_at, c.edited_at,
c.deleted_at, u.id as author_id, u.display_name, u.avatar_url
from comments c
join users u on u.id = c.author_id
where c.subject_type = $1 and c.subject_id = $2
order by coalesce(c.parent_id, c.id), c.created_at;One query, authors joined, ordered so that replies already sit under their parents. Build the tree in memory from the flat list rather than making the database do it. For the mention display names, collect the distinct IDs across the whole result and fetch them in a single second query.
That is two queries for a full thread regardless of size. If you are asking an AI builder to write this, say "one query for comments joined to authors, one batched query for mentioned users, no per-comment lookups". Without that, the default output loops. The wider pattern is covered in why an AI-built app is slow.
The front-end details that matter
The picker triggers on `@` and filters as you type. Only offer people who can actually see the subject. Filtering the list to the permitted set is a much better experience than the permission check catching it later, and it makes the no_access branch rare.
Escape the body on render. Comments are user input rendered to other users, which is the textbook stored cross-site-scripting surface. If you support formatting, use a restricted subset and sanitise server-side, never client-side only.
Optimistic insert with a rollback. Show the comment immediately, reconcile when the server responds, remove it and restore the draft text if the write fails. Losing a typed comment to a network blip is the fastest way to make people stop using the feature.
Give every comment a permalink. ?comment=<id> that scrolls to and highlights it. Notifications need somewhere to point, and without it the email says "someone mentioned you" and drops the reader at the top of a hundred-comment page.
Wiring it to notifications
Mentions are worth almost nothing without delivery. The comment write should emit an event, not send an email inline, so the response is not waiting on your mail provider.
Sensible defaults, learned the hard way by everyone who has shipped this: mentions notify immediately, replies to your own comment notify immediately, and everything else on a thread you are watching batches into a digest. Always let people mute a thread. A busy document with no mute button trains users to filter your entire domain to spam.
If you already have a notification centre, route mentions into it and let email be the fallback for people who are not currently in the app. That post covers the in-app inbox side, and it is also where the fan-out belongs: run it off the request path so a slow mail provider never blocks a comment write.
The order to add comments and mentions to an AI-built app
If you want this shipped rather than perfect, the order is: flat comments with authors, then permalinks, then mentions with resolved IDs, then notifications, then replies. Each step is independently useful, and the two-level thread is the one people ask for last despite it being the feature everyone starts with.
Getting the schema right on day one matters more than any of the rest, because the schema is what you cannot cheaply change once real comments exist. If you are letting AI draft it, prompt it to design the schema explicitly rather than accepting whatever arrives alongside the UI, and check the indexes yourself. The wider build sequence is in how to build an app with AI.
FAQ
Should comments be their own table or a column on the parent record?
Their own table, always. A JSON column of comments on the parent row cannot be indexed usefully, cannot be paginated, and turns every comment write into a rewrite of the whole parent record with the concurrency problems that brings.
How do I stop mention notifications becoming spam?
Rate limit mentions per author per hour, ignore repeat mentions of the same person in the same thread within a short window, and never notify someone for a comment they wrote. Those three rules remove most of the noise.
Do I need real-time updates for comments?
Not to launch. Polling every fifteen to thirty seconds while a thread is open is nearly indistinguishable from live for a comment feature, and it costs a fraction of the complexity. Add real-time chat infrastructure when you have a use case that genuinely needs sub-second delivery.
How should editing work?
Allow it, store edited_at, show an edited marker, and keep the original in a revision table if the comments carry any weight in disputes. Silent editing of a comment other people have already replied to causes arguments.
What about mentioning a group or team?
Expand the group to individual user IDs at write time and store those. Storing a group ID means the notification set changes as membership changes, so a comment from six months ago starts notifying people who were not there. Expanding at write time keeps the record accurate.
How did this land?
About the author

Developer Advocate
Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.


