Dashboard

How to Add an Activity Feed to an AI-Built App

A practical guide to building a user-facing activity feed with a simple events table, grouped rendering, and a clear line between activity feeds and audit logs.

Steve Jefferson
Steve Jefferson
Developer Advocate
31 August 20261 min read

An activity feed is a reverse-chronological list of the meaningful things that just happened in your product: Sarah updated the invoice, three new comments on Project X, a task got assigned to you. It is the feature that turns a product from "a database with a UI" into something that feels like a place other people are actively using. The practical build is simpler than it looks: one events table (actor, verb, target type and id, a JSON metadata blob, a timestamp), a write on every action worth surfacing, and a paginated, grouped query to render it. No message queue, no separate service, no vendor required to ship version one.

This post covers the schema, where to put the write calls, how to render the feed without a wall of noise, the difference between an activity feed and an audit log, and where a naive query starts to hurt.

Why an activity feed matters more than it looks like it should

A single-user app doesn't need one, state is enough. A multi-user app is different: the moment a second person can touch the same project or invoice, users start asking a question the UI probably doesn't answer, what changed since I last looked, and who did it? Without a feed that goes unanswered and users compensate by pinging each other on Slack. With one, the product self-narrates: open the app, glance at the feed, know what happened while you were away. That's the loop that gets people to come back tomorrow instead of forgetting the app exists.

If you're still early in building the product itself, the broader process is covered in this guide to building an app with AI; an activity feed gets added once users are already doing things to each other's data, not on day one.

The events table

You need one table to start. Resist designing separate tables per event type, a single generic events table with a JSON metadata column handles almost everything a small-to-mid-size product needs.

Field

Type

Purpose

id

uuid / bigint

Primary key

actor_id

uuid, nullable

Who did it. Nullable for system-generated events

actor_type

text

"user", "system", "api_key" - useful once automation exists

verb

text

"created", "updated", "commented", "assigned", "invited"

target_type

text

"invoice", "project", "task", "comment"

target_id

uuid

The row the event is about

workspace_id / account_id

uuid

Scopes the event for multi-tenant apps, critical for indexing

metadata

jsonb

Diff snippets, display fields, anything the renderer needs without a join

created_at

timestamptz

When it happened, indexed, this is your sort key

A realistic row: actor_id points to Sarah, verb is "updated", target_type is "invoice", target_id is the invoice's id, metadata holds {"field": "amount", "from": 1200, "to": 1450, "invoice_number": "INV-1042"}, created_at is now. Storing the invoice number on the event means the feed query never has to join back to the invoices table just to render a sentence, which matters once that table has millions of rows and the invoice may have been deleted or renamed since.

Keep verbs a small, controlled vocabulary. Let every developer (or AI coding session) invent a new verb per feature and you end up with "task_status_changed_to_done" next to "completed_task" meaning the same thing. Pick a short list up front, created, updated, deleted, commented, mentioned, assigned, invited, archived, and extend it deliberately.

Where to write the events

The event write belongs next to the action itself, inside the same code path (ideally the same database transaction) as the state change it describes. If invoice updates happen in one function, that function also inserts the event row. Don't derive events later from database triggers or change-data-capture unless you already run that infrastructure for other reasons, it adds a layer that makes debugging "why isn't this showing up" harder than it needs to be.

function updateInvoice(invoiceId, changes, actorId) { const updated = db.invoices.update(invoiceId, changes) db.events.insert({ actor_id: actorId, verb: 'updated', target_type: 'invoice', target_id: invoiceId, workspace_id: updated.workspace_id, metadata: { changed_fields: Object.keys(changes), invoice_number: updated.number } }) return updated }

If you're prompting an AI coding tool to add this, be specific about which actions matter. "Log an event whenever a user creates, updates, or deletes a record" gives the model enough context to wire it into existing mutation functions, but deciding which actions are worth logging is still on you. A user changing their own notification preferences doesn't need to show up in a shared feed; reassigning a task to someone else does.

Comment and mention actions are a common, high-value event type here. If that feature already exists, its events are exactly what feeds the activity stream, and the walkthrough on adding comments and mentions is worth pairing with this one since the two share plumbing.

Rendering the feed

The naive version: query the last 50 events for this workspace, order by created_at descending, render each row as a sentence built from actor, verb, target, and metadata. That's genuinely enough for a first version. Two things make it feel considerably more polished without much extra work.

Grouping. Five separate "commented on Task X" events from the same actor within a couple minutes should collapse into one line, "Sarah left 3 comments on Task X." Group by actor_id, target_id, and verb within a rolling time window (5 to 15 minutes is typical), either in the query or as a post-processing step on the fetched rows.

Sentence templates keyed on verb and target_type: a small mapping from ("commented", "task") to a template like "{actor} commented on {target}", filled from metadata. Adding a new event type later then means adding a template, not a schema migration.

Paginate with a cursor on created_at, not offset-based pagination. Offset pagination re-scans everything before the offset on every page, which gets slow and can skip or duplicate rows if new events are inserted while someone scrolls. A cursor, "give me events older than this timestamp," avoids both problems.

Activity feed vs. audit log: these are not the same feature

This gets conflated constantly, including by AI coding assistants that will happily build a single "logging" table and call it done for both. It shouldn't be.

An audit log exists for compliance and forensics. It's immutable, complete (every field change, not just the interesting ones), retained for a fixed period, and usually never shown to end users at all, only to admins or auditors after the fact. Rows don't get summarized, and nothing gets skipped for clutter, since nobody is casually viewing it.

An activity feed exists to be read by end users, right now, as a way of understanding what's happening in the product. It's curated: you choose which actions are worth showing, you group and summarize, and it's fine if it doesn't capture every field-level change. Nobody wants "updated field last_seen_at" in their feed.

You can build both off the same events table if you're careful: log everything, but filter and group at the rendering layer for the user-facing feed while a separate admin view or export reads the raw rows for audit purposes. If your product has real compliance requirements (finance, healthcare, anything with data-access rules), treat the audit log as the primary system and build the feed as a curated view on top of it, not the other way around.

It's also worth being explicit about how this differs from notifications, since the two get built together and then confused. An activity feed is a broad, shared stream of what's happening across a workspace; a notification tells one specific person something relevant happened to them. The notification center post covers the alerting side. A subset of feed events, someone assigned you a task, someone mentioned you, should also fire a notification for that user, while the feed itself stays visible to everyone with access.

When this gets slow, and what to do about it

At small scale (thousands to low millions of events, one workspace's worth of activity per page load), a straightforward indexed query is fine. Two indexes matter most: (workspace_id, created_at desc) for the standard "recent activity" query, and (target_type, target_id, created_at desc) if you also render a feed scoped to a single object, like the history for one project.

Once a workspace has been active a year or two and the table holds tens of millions of rows across all tenants, indexed queries carry more I/O, and any query not scoped by workspace_id ends up scanning across tenants and slowing down badly.

Fixes, roughly in order of effort: keep workspace_id in every WHERE clause and index, use cursor-based pagination, and add a retention policy that archives events older than some window (90 days, a year) from the hot table while keeping them in cold storage or a separate audit table if compliance requires it. Materialized summary tables and denormalized feed caches exist for feeds at real scale, but that's a problem for when you have it, not something to design for on day one.

How long this actually takes

For a product that already has users, actors, and a handful of core objects, adding a basic activity feed (the table, writes in the 5 to 10 places that matter, a grouped and paginated render) is a small feature, typically a day or two of focused work, less with an AI coding tool doing the scaffolding once you're clear about which actions to log. For realistic expectations on scoping a feature like this against a broader timeline, see this breakdown of how long it takes to build an app with AI.

The part that takes judgment isn't the code, it's deciding what belongs in the feed. Log too little and it feels dead; log every field change on every model and it turns into noise nobody reads. Start narrow (creates, status changes, comments, assignments), ship it, and add verbs as users ask "did anyone see that I did X."

Frequently Asked Questions

What's the difference between an activity feed and an audit log?

An activity feed is user-facing and curated: a filtered, sometimes grouped stream of actions so users understand what's happening in the product. An audit log is compliance-facing and comprehensive: it records every relevant action for forensic or regulatory purposes and is usually seen only by admins. They can share an underlying events table, but the feed layer filters and summarizes while the audit layer does not.

Do I need a separate events table for every feature, or one shared table?

One shared table. A generic schema with actor, verb, target_type, target_id, metadata, and created_at covers activity from tasks, comments, and invoices without a proliferation of feature-specific log tables. The metadata jsonb column absorbs feature-specific detail without changing the schema.

How do I stop the activity feed from becoming a wall of noise?

Two levers: be selective about which actions generate events in the first place, skip low-value internal state changes, and group related events at render time, collapsing several rapid actions by the same actor on the same target into one summarized line.

Should activity feed events trigger notifications too?

Some should, most shouldn't. Treat notifications as a separate decision layered on top of specific events, typically ones where a user is directly named (assigned, mentioned, invited), rather than every event that hits the feed. Building the two as genuinely separate features, sharing the same event source, keeps both simpler.

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.