Tutorial
Rendering an Activity Feed in Phoenix LiveView
In the previous tutorial, we built an automatic activity tracking system that records events whenever a tracked schema is inserted, updated, or deleted. Now let's build the actual feed — a LiveView that renders those events as a proper activity timeline.
We'll use cursor-based pagination (not offset-based) because it works well with append-style feeds where new events are constantly being added. And we'll use LiveView streams to keep the DOM efficient.
Step 1: The Feed Query
First, let's add a to_feed/1 function to our ActivityTracking context. This is the query layer for the feed:
# Add to lib/my_app/activity_tracking/activity_tracking.ex
def to_feed(opts \\ []) do
limit = Keyword.get(opts, :limit, 20)
cursor = Keyword.get(opts, :cursor)
record = Keyword.get(opts, :record)
users = Keyword.get(opts, :users, Keyword.get(opts, :user))
query =
from e in ActivityEvent,
join: a in assoc(e, :actor),
join: u in assoc(a, :user),
order_by: [desc: e.inserted_at],
limit: ^limit,
preload: [actor: {a, user: u}]
query = if cursor, do: where(query, [e], e.inserted_at < ^cursor), else: query
query = if record, do: where_record(query, record), else: query
query = if users, do: where_users(query, users), else: query
events = Repo.all(query)
next_cursor = next_cursor(events, limit)
{events, next_cursor}
end
defp next_cursor(events, limit) do
if length(events) == limit do
events |> List.last() |> Map.get(:inserted_at)
else
nil
end
end
A few things worth explaining:
Cursor-based pagination works by remembering the inserted_at timestamp of the last event we loaded. The next page fetches everything older than that timestamp. This is better than offset-based pagination for feeds because new events being added at the top don't shift the results.
The cursor is a DateTime, not a string. We pass it directly to the query. When there are fewer results than the limit, we return nil as the cursor, which means we've reached the end.
We preload the actor and user in one query using a join. This avoids N+1 queries when rendering the feed — we need the user's name or email for each event.
- The function accepts several filter options:
limit— how many events to fetch (default 20)cursor— aDateTimefor the next pagerecord— filter events for a specific structusers— filter events by one or more users
Step 2: The LiveView
Now let's build the LiveView that renders the feed. We'll use streams to manage the list of events:
# lib/my_app_web/live/activity_feed_live.ex
defmodule MyAppWeb.ActivityFeedLive do
use MyAppWeb, :live_view
alias MyApp.ActivityTracking
@per_page 20
def mount(_params, _session, socket) do
{events, cursor} = ActivityTracking.to_feed(limit: @per_page)
{:ok,
socket
|> assign(cursor: cursor, per_page: @per_page)
|> stream(:events, events)}
end
def handle_event("load-more", _, socket) do
%{cursor: cursor, per_page: per_page} = socket.assigns
{events, next_cursor} = ActivityTracking.to_feed(limit: per_page, cursor: cursor)
{:noreply,
socket
|> assign(cursor: next_cursor)
|> stream(:events, events)}
end
end
The mount fetches the first page and streams it. When the user clicks "load more", we fetch the next page using the cursor and append those events to the stream. When cursor becomes nil, we know there are no more events to load.
Step 3: The Template
<Layouts.app flash={@flash}>
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Activity</h1>
<div id="activity-feed" phx-update="stream" class="space-y-1">
<div :for={{id, event} <- @streams.events} id={id} class="flex items-start gap-3 py-3">
<div class={[
"flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm",
action_color(event.action)
]}>
{action_icon(event.action)}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm">
<span class="font-medium">{event.actor.user.email}</span>
<span class="text-base-content/60">
{action_verb(event.action)} {event.subject}
</span>
</p>
<p class="text-xs text-base-content/40 mt-0.5">
{relative_time(event.inserted_at)}
</p>
</div>
</div>
</div>
<div :if={@cursor} class="mt-6 text-center">
<button phx-click="load-more" class="btn btn-ghost btn-sm">
Load more
</button>
</div>
<div :if={is_nil(@cursor)} class="mt-6 text-center text-sm text-base-content/40">
No more activity.
</div>
</div>
</Layouts.app>
The @cursor assign doubles as our "has more?" flag. When it's nil, we show the end-of-feed message and hide the button. No extra boolean needed.
Step 4: Helper Functions
The template uses a few helper functions for formatting. Add these to the LiveView module:
defp action_verb(:insert), do: "created"
defp action_verb(:update), do: "updated"
defp action_verb(:delete), do: "deleted"
defp action_icon(:insert), do: "+"
defp action_icon(:update), do: "~"
defp action_icon(:delete), do: "-"
defp action_color(:insert), do: "bg-success/20 text-success"
defp action_color(:update), do: "bg-info/20 text-info"
defp action_color(:delete), do: "bg-error/20 text-error"
defp relative_time(datetime) do
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
diff < 60 -> "just now"
diff < 3600 -> "#{div(diff, 60)}m ago"
diff < 86400 -> "#{div(diff, 3600)}h ago"
diff < 604_800 -> "#{div(diff, 86400)}d ago"
true -> Calendar.strftime(datetime, "%b %d, %Y")
end
end
The relative_time function gives us human-friendly timestamps without pulling in a dependency. Events older than a week just show the full date.
Step 5: Scoping the Feed
One of the nice things about the to_feed function is that it supports filtering. You can easily scope the feed to a specific user or record.
User-scoped feed
Show only the current user's activity:
def mount(_params, _session, socket) do
{events, cursor} =
ActivityTracking.to_feed(
limit: @per_page,
user: socket.assigns.current_user
)
{:ok,
socket
|> assign(cursor: cursor, per_page: @per_page)
|> stream(:events, events)}
end
Record-scoped feed
Show all activity for a specific record — useful on detail pages:
# In a PostLive.Show module:
def mount(%{"id" => id}, _session, socket) do
post = Blog.get_post!(id)
{events, cursor} =
ActivityTracking.to_feed(
limit: @per_page,
record: post
)
{:ok,
socket
|> assign(post: post, cursor: cursor, per_page: @per_page)
|> stream(:events, events)}
end
This queries the JSONB data field using the GIN index we set up in the migration, so it stays fast even with a large events table.
Step 6: Adding the Route
Add the feed to your router:
# lib/my_app_web/router.ex
scope "/", MyAppWeb do
pipe_through [:browser, :require_authenticated_user]
live "/activity", ActivityFeedLive
end
Step 7: A Feed Component for Reuse
Since you'll likely want to embed the feed on different pages (dashboard, user profile, record detail), it makes sense to extract the rendering into a function component:
# lib/my_app_web/components/activity_components.ex
defmodule MyAppWeb.ActivityComponents do
use Phoenix.Component
attr :events, :list, required: true
attr :cursor, :any, default: nil
def activity_feed(assigns) do
~H"""
<div id="activity-feed" phx-update="stream" class="space-y-1">
<div :for={{id, event} <- @events} id={id} class="flex items-start gap-3 py-3">
<div class={[
"flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm",
action_color(event.action)
]}>
{action_icon(event.action)}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm">
<span class="font-medium">{event.actor.user.email}</span>
<span class="text-base-content/60">
{action_verb(event.action)} {event.subject}
</span>
</p>
<p class="text-xs text-base-content/40 mt-0.5">
{relative_time(event.inserted_at)}
</p>
</div>
</div>
</div>
<div :if={@cursor} class="mt-4 text-center">
<button phx-click="load-more" class="btn btn-ghost btn-sm">
Load more
</button>
</div>
"""
end
defp action_verb(:insert), do: "created"
defp action_verb(:update), do: "updated"
defp action_verb(:delete), do: "deleted"
defp action_icon(:insert), do: "+"
defp action_icon(:update), do: "~"
defp action_icon(:delete), do: "-"
defp action_color(:insert), do: "bg-success/20 text-success"
defp action_color(:update), do: "bg-info/20 text-info"
defp action_color(:delete), do: "bg-error/20 text-error"
defp relative_time(datetime) do
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
diff < 60 -> "just now"
diff < 3600 -> "#{div(diff, 60)}m ago"
diff < 86400 -> "#{div(diff, 3600)}h ago"
diff < 604_800 -> "#{div(diff, 86400)}d ago"
true -> Calendar.strftime(datetime, "%b %d, %Y")
end
end
end
Import it in your my_app_web.ex inside html_helpers:
defp html_helpers do
quote do
# ... existing imports
import MyAppWeb.ActivityComponents
end
end
Then use it anywhere:
<.activity_feed events={@streams.events} cursor={@cursor} />
The parent LiveView still handles the "load-more" event and the stream — the component just handles rendering.
Wrapping Up
Combined with the tracking system from part one, we now have a complete activity feed that:
- Records events automatically via the Repo override
- Uses cursor-based pagination for efficient loading
- Renders with LiveView streams to keep the DOM lean
- Can be scoped to a user, a record, or the whole app
- Is extracted into a reusable component for embedding anywhere
The cursor-based approach scales well — even with millions of events, each page load is a simple WHERE inserted_at < cursor ORDER BY inserted_at DESC LIMIT 20 query hitting the timestamp index. No offsets, no counting, no performance degradation as the table grows.