Tutorial

Rendering an Activity Feed in Phoenix LiveView

phoenix liveview activity-feed streams cursor-pagination

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 — a DateTime for the next page
  • record — filter events for a specific struct
  • users — 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.

Related Tutorials

NEW
Published 09 Apr

Infinite Scroll in Phoenix LiveView

Traditional pagination works fine, but sometimes you want something smoother. Infinite scroll keeps the user in the flow — no clicking "next", no pa..

NEW
Published 11 Apr

Activity Tracking in Phoenix LiveView

Most SaaS apps need some form of activity tracking — who did what and when. In this tutorial, we'll build an automatic activity tracking system that..