Skip to main content
Back to Blog
Tutorials
4 min read
November 23, 2024

How to Build an Activity Feed in Next.js

Create a real-time activity feed with event tracking, filtering, infinite scroll, and live updates in Next.js.

Ryel Banfield

Founder & Lead Developer

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.

activity feedtimelinereal-timeNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles