An activity feed tracks user actions and events across your application. Here is how to build one with filtering and real-time updates.
Step 1: Database Schema
// db/schema.ts
import { pgTable, text, timestamp, uuid, jsonb } from "drizzle-orm/pg-core";
export const activityEvents = pgTable("activity_events", {
id: uuid("id").defaultRandom().primaryKey(),
userId: text("user_id").notNull(),
action: text("action", {
enum: [
"created",
"updated",
"deleted",
"commented",
"assigned",
"completed",
"archived",
"invited",
"joined",
"left",
],
}).notNull(),
resourceType: text("resource_type").notNull(), // "project", "task", "comment"
resourceId: text("resource_id").notNull(),
resourceName: text("resource_name").notNull(),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
Step 2: Activity Logger
// lib/activity.ts
import { db } from "@/db";
import { activityEvents } from "@/db/schema";
interface LogActivityParams {
userId: string;
action: string;
resourceType: string;
resourceId: string;
resourceName: string;
metadata?: Record<string, unknown>;
}
export async function logActivity(params: LogActivityParams) {
await db.insert(activityEvents).values(params);
}
// Usage example:
// await logActivity({
// userId: user.id,
// action: "created",
// resourceType: "project",
// resourceId: project.id,
// resourceName: project.name,
// metadata: { description: project.description },
// });
Step 3: Feed API Route
// app/api/activity/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { activityEvents } from "@/db/schema";
import { desc, eq, and, lt, inArray } from "drizzle-orm";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const cursor = searchParams.get("cursor");
const action = searchParams.get("action");
const resourceType = searchParams.get("resourceType");
const limit = Math.min(Number(searchParams.get("limit") ?? 20), 50);
const conditions = [];
if (cursor) {
conditions.push(lt(activityEvents.createdAt, new Date(cursor)));
}
if (action) {
conditions.push(eq(activityEvents.action, action));
}
if (resourceType) {
conditions.push(eq(activityEvents.resourceType, resourceType));
}
const events = await db
.select()
.from(activityEvents)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(activityEvents.createdAt))
.limit(limit + 1);
const hasMore = events.length > limit;
const items = hasMore ? events.slice(0, limit) : events;
const nextCursor = hasMore
? items[items.length - 1].createdAt.toISOString()
: null;
return NextResponse.json({
items,
nextCursor,
hasMore,
});
}
Step 4: Activity Event Component
// components/activity/ActivityEvent.tsx
import { formatDistanceToNow } from "date-fns";
interface ActivityEventData {
id: string;
userId: string;
action: string;
resourceType: string;
resourceId: string;
resourceName: string;
metadata: Record<string, unknown> | null;
createdAt: string;
}
const actionLabels: Record<string, string> = {
created: "created",
updated: "updated",
deleted: "deleted",
commented: "commented on",
assigned: "was assigned to",
completed: "completed",
archived: "archived",
invited: "invited someone to",
joined: "joined",
left: "left",
};
const actionIcons: Record<string, string> = {
created: "+",
updated: "~",
deleted: "x",
commented: "#",
assigned: ">",
completed: "v",
archived: "[]",
invited: "@",
joined: "->",
left: "<-",
};
export function ActivityEvent({ event }: { event: ActivityEventData }) {
const label = actionLabels[event.action] ?? event.action;
const icon = actionIcons[event.action] ?? "?";
return (
<div className="flex gap-3 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-xs font-mono text-gray-600">
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm">
<span className="font-medium">{event.userId}</span>{" "}
{label}{" "}
<span className="font-medium">{event.resourceName}</span>
</p>
{event.metadata?.description && (
<p className="mt-1 text-xs text-gray-500 truncate">
{String(event.metadata.description)}
</p>
)}
<time className="text-xs text-gray-400">
{formatDistanceToNow(new Date(event.createdAt), { addSuffix: true })}
</time>
</div>
</div>
);
}
Step 5: Feed with Infinite Scroll
// components/activity/ActivityFeed.tsx
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { ActivityEvent } from "./ActivityEvent";
interface Event {
id: string;
userId: string;
action: string;
resourceType: string;
resourceId: string;
resourceName: string;
metadata: Record<string, unknown> | null;
createdAt: string;
}
interface Filters {
action?: string;
resourceType?: string;
}
export function ActivityFeed() {
const [events, setEvents] = useState<Event[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState<Filters>({});
const observerRef = useRef<HTMLDivElement>(null);
const fetchEvents = useCallback(
async (nextCursor?: string | null) => {
setLoading(true);
const params = new URLSearchParams();
if (nextCursor) params.set("cursor", nextCursor);
if (filters.action) params.set("action", filters.action);
if (filters.resourceType) params.set("resourceType", filters.resourceType);
const res = await fetch(`/api/activity?${params}`);
const data = await res.json();
setEvents((prev) =>
nextCursor ? [...prev, ...data.items] : data.items
);
setCursor(data.nextCursor);
setHasMore(data.hasMore);
setLoading(false);
},
[filters]
);
useEffect(() => {
fetchEvents();
}, [fetchEvents]);
// Infinite scroll observer
useEffect(() => {
const node = observerRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
fetchEvents(cursor);
}
},
{ threshold: 0.5 }
);
observer.observe(node);
return () => observer.disconnect();
}, [cursor, hasMore, loading, fetchEvents]);
return (
<div className="mx-auto max-w-2xl">
{/* Filters */}
<div className="mb-4 flex gap-2">
<select
value={filters.action ?? ""}
onChange={(e) =>
setFilters((f) => ({ ...f, action: e.target.value || undefined }))
}
className="rounded-lg border px-3 py-1.5 text-sm"
>
<option value="">All actions</option>
<option value="created">Created</option>
<option value="updated">Updated</option>
<option value="deleted">Deleted</option>
<option value="commented">Commented</option>
<option value="completed">Completed</option>
</select>
<select
value={filters.resourceType ?? ""}
onChange={(e) =>
setFilters((f) => ({
...f,
resourceType: e.target.value || undefined,
}))
}
className="rounded-lg border px-3 py-1.5 text-sm"
>
<option value="">All types</option>
<option value="project">Projects</option>
<option value="task">Tasks</option>
<option value="comment">Comments</option>
</select>
</div>
{/* Event list */}
<div className="divide-y">
{events.map((event) => (
<ActivityEvent key={event.id} event={event} />
))}
</div>
{/* Load more trigger */}
<div ref={observerRef} className="py-4 text-center">
{loading && <p className="text-sm text-gray-500">Loading...</p>}
{!hasMore && events.length > 0 && (
<p className="text-sm text-gray-400">No more activity</p>
)}
{!loading && events.length === 0 && (
<p className="text-sm text-gray-400">No activity yet</p>
)}
</div>
</div>
);
}
Step 6: Grouped by Date
// components/activity/GroupedFeed.tsx
function groupByDate(events: Event[]): Map<string, Event[]> {
const groups = new Map<string, Event[]>();
for (const event of events) {
const date = new Date(event.createdAt).toLocaleDateString("en", {
year: "numeric",
month: "long",
day: "numeric",
});
const existing = groups.get(date) ?? [];
existing.push(event);
groups.set(date, existing);
}
return groups;
}
export function GroupedFeed({ events }: { events: Event[] }) {
const grouped = groupByDate(events);
return (
<div className="space-y-6">
{Array.from(grouped).map(([date, items]) => (
<div key={date}>
<h3 className="mb-2 text-sm font-medium text-gray-500">{date}</h3>
<div className="divide-y rounded-lg border">
{items.map((event) => (
<div key={event.id} className="px-4">
<ActivityEvent event={event} />
</div>
))}
</div>
</div>
))}
</div>
);
}
Need Custom Dashboard Features?
We build activity feeds, analytics dashboards, and admin panels for SaaS products. Contact us to discuss your project.