Dashboard

How to Add a Tagging System to an AI-Built App

A tagging feature looks simple until someone tries to filter or merge a tag. The right data model from day one avoids the rewrite.

Steve Jefferson
Steve Jefferson
Developer Advocate
16 September 20261 min read

How to Add a Tagging System to an AI-Built App

Adding a tagging system to an AI-built app means deciding your data model up front, a join table between tags and the records they describe, before you ask an AI coding agent to build the feature, because the wrong shortcut here (a comma-separated string column) works in a demo and breaks the moment someone tries to filter, rename, or merge a tag.

The data model that actually scales

Three tables, not one column:

sql
create table tags (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  workspace_id uuid not null references workspaces(id),
  unique (workspace_id, lower(name))
);

create table taggable_tags (
  tag_id uuid not null references tags(id) on delete cascade,
  taggable_type text not null,   -- 'project', 'task', 'contact', etc.
  taggable_id uuid not null,
  created_at timestamptz default now(),
  primary key (tag_id, taggable_type, taggable_id)
);

create index on taggable_tags (taggable_type, taggable_id);

The `taggable_type` plus `taggable_id` pair is a polymorphic reference, letting one tags table serve tasks, contacts, projects, or anything else in your app without a separate tags table per entity. If your app only ever tags one kind of record, skip the polymorphism and use a plain foreign key, simpler is correct until you actually need the flexibility.

The `unique (workspace_id, lower(name))` constraint matters more than it looks. Without it, a tagging feature accumulates "Urgent," "urgent," and "URGENT" as three separate tags within a week, and no UI polish fixes a data model that allows that.

Why the comma-separated-string shortcut fails

Asked for "a quick tagging feature," an AI coding agent will sometimes suggest a `tags text[]` array column or a comma-separated string directly on the record. This looks like less work, and for a single-user demo it is. It fails in production for reasons that show up exactly when the feature starts mattering:

  • Renaming a tag means rewriting every row that contains it, a full table scan and update instead of one row change in a tags table.

  • Filtering "show me everything tagged X" becomes a slow string match (`LIKE '%urgent%'`) instead of an indexed join, and gets slower as the table grows.

  • Merging two tags that turned out to mean the same thing has no clean operation, you are back to rewriting every affected row.

  • There is no natural place to store anything about a tag itself, a color, a description, an owner, once you inevitably want one.

The prompt that gets this right the first time

text
Add a tagging feature using a normalized data model: a tags table
scoped to [workspace/user], and a join table linking tags to the
records they describe. Do not use an array or comma-separated
column on the record itself. Enforce case-insensitive uniqueness
on tag name within its scope. Support: creating a tag inline while
tagging a record (don't require a separate "create tag" step first),
filtering records by one or more tags, and renaming/deleting a tag
without needing to touch the records that reference it.

Naming the join-table pattern explicitly in the prompt, rather than describing the feature and hoping the agent picks the right model, is what prevents the array-column shortcut. Agents default to the simplest thing that satisfies the literal request, and a comma-separated list does satisfy "let me tag things," right up until it does not.

The UI details that separate a usable tagging feature from a technically-working one

  • **Inline tag creation.** The tag input should let a user type a new tag and hit enter, creating it on the spot, rather than forcing a trip to a separate "manage tags" screen first. This single decision determines whether people actually use the feature.

  • **Autocomplete against existing tags**, ranked by recent or frequent use within the current scope, to steer people toward reusing "urgent" instead of typing a near-duplicate.

  • **A tag management view**, even a simple one, listing every tag with a usage count and a rename/delete action, becomes necessary the moment a workspace accumulates more than a screenful of tags.

  • **Bulk tagging**, letting a user select several records and apply or remove a tag at once, is a small addition to the same join-table model and saves real time once a workspace has any volume of records.

Filtering by tag, done efficiently

Once records and tags share a join table, filtering by one tag is a straightforward join. Filtering by multiple tags with "must have all of these" semantics needs a small amount of extra care to avoid returning partial matches:

sql
select r.*
from records r
join taggable_tags tt on tt.taggable_id = r.id and tt.taggable_type = 'record'
where tt.tag_id in ($1, $2, $3)
group by r.id
having count(distinct tt.tag_id) = 3;

The `having count(distinct tt.tag_id) = 3` clause is what enforces "all three tags," not "any of the three." Ask an AI coding agent for "filter by multiple tags" without specifying and-versus-or semantics, and it will often default to the easier-to-write "or" version, which returns more results than a user asking for "urgent AND overdue" actually wants.

Where tagging connects to features you may already have

A tagging system pairs naturally with search: tags are effectively a fast, structured pre-filter that narrows what full-text search has to look through. It also solves part of the problem covered in handling duplicate records, since a shared tag across near-duplicate entries is often how a team first notices the duplication exists. If you are already logging user actions for an activity feed, tag additions and removals are worth including as feed events, they are exactly the kind of lightweight, frequent action an activity feed is meant to surface.

Frequently asked questions

Should tags be scoped per user or shared across a workspace?

Shared across a workspace in almost every B2B case, since the value of tagging comes from a team converging on shared vocabulary. Per-user tags make sense only for genuinely personal organization, like a private bookmarking feature, where different users tagging the same thing differently is expected and fine.

How do I handle tag colors without over-engineering it?

Add a nullable `color` column to the tags table, store a hex value, and let the UI assign a default from a fixed palette when a tag is created without one. Resist building a full color-picker and tag-icon system until users actually ask for it, most tagging features never need more than a color swatch.

What is the difference between tags and categories?

Categories are typically single-select and mutually exclusive, a record has one category. Tags are multi-select by design, a record can carry several. If you are building a feature where a record should have exactly one of a fixed set of values, that is a category (a plain enum or foreign key column), not a tag, and modeling it as tags adds join-table overhead a simple column would have handled better.

Will an AI coding agent get the join table right without me specifying the schema?

Sometimes, but not reliably enough to skip specifying it. The failure mode is not usually a broken join table, it is skipping the join table altogether in favor of the simpler array-column approach when a prompt does not explicitly rule that out. Naming the pattern in the prompt, as shown above, removes the ambiguity that produces the shortcut.

How did this land?

About the author

Steve Jefferson
Steve Jefferson

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.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.