Data Components

Updates Feed

A chronological stream of items delivered by Exa Monitors. Each item is a deduped result from a recurring search, tagged by monitor, with source and timestamp. The right surface for buying signals, competitor moves, and account watchlists.

Demo

Three demo monitors are running: AI funding, Cursor activity, and sales tech launches. Hit Refresh to fan out one search per monitor and merge results into a single sorted feed.

AI FundingCursorSales Tech
Hit Refresh to fan out the demo monitors.

How it works

Each row in the feed is a deduplicated event surfaced by an Exa Monitor. Each monitor runs the same search on a schedule and POSTs its results to your webhook.

The outputSchema asks Exa to synthesize up to 3 distinct events per run with headline, summary, sourceUrl, and publishedDate, not a dump of every matching article.

Refresh calls exa.monitors.trigger on each monitor to fire a fresh run.

Events from every monitor merge into one sorted stream. Each carries a monitorId so the UI can color-code its source.

Source code

"use client";

import { useEffect, useState } from "react";
import {
  DEMO_MONITORS,
  type FeedItem,
  type Monitor,
} from "@/lib/feed-monitors";

export function FeedExample() {
  const [items, setItems] = useState<FeedItem[]>([]);
  const [loading, setLoading] = useState(false);
  const [refreshing, setRefreshing] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [lastRefreshed, setLastRefreshed] = useState<number | null>(null);
  const [readIds, setReadIds] = useState<Set<string>>(new Set());

  // Initial load: read the most recent completed run for each
  // monitor. Fast (no trigger, no polling).
  useEffect(() => {
    void load(false);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  async function load(refresh: boolean) {
    if (loading || refreshing) return;
    if (refresh) setRefreshing(true);
    else setLoading(true);
    setError(null);
    try {
      const res = await fetch("/api/feed", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ monitors: DEMO_MONITORS, refresh }),
      });
      const json = await res.json();
      if (!res.ok || json.error) {
        setError(json.error || `Request failed (${res.status})`);
        return;
      }
      setItems(json.items ?? []);
      if (refresh) setLastRefreshed(Date.now());
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      setLoading(false);
      setRefreshing(false);
    }
  }

  function refresh() {
    void load(true);
  }

  function markRead(id: string) {
    setReadIds((prev) => {
      if (prev.has(id)) return prev;
      const next = new Set(prev);
      next.add(id);
      return next;
    });
  }

  const hasItems = items.length > 0;

  return (
    <div className="border border-[#e5e5e5] bg-white overflow-hidden">
      <div className="flex items-center justify-between border-b border-[#e5e5e5] bg-[#fafafa] px-3 py-2 gap-2">
        <div className="flex flex-wrap items-center gap-1.5 min-w-0">
          {DEMO_MONITORS.map((m) => (
            <MonitorPill key={m.id} monitor={m} />
          ))}
        </div>
        <div className="flex items-center gap-2 shrink-0">
          {lastRefreshed && (
            <span className="text-[11px] text-[#9ca3af]">
              Refreshed {formatRelative(lastRefreshed)}
            </span>
          )}
          <button
            type="button"
            onClick={refresh}
            disabled={loading || refreshing}
            className="bg-[var(--color-brand)] text-white text-[12px] font-medium px-3 py-1 disabled:opacity-50"
          >
            {refreshing ? "Refreshing..." : "Refresh"}
          </button>
        </div>
      </div>
      <div className="max-h-[520px] overflow-y-auto">
        {error && (
          <div className="px-4 py-3 text-[13px] text-[#b91c1c]">{error}</div>
        )}
        {!error && !hasItems && !loading && !refreshing && (
          <div className="px-4 py-12 text-center text-[13px] text-[#9ca3af]">
            Hit Refresh to fan out the demo monitors.
          </div>
        )}
        {!error && !hasItems && (loading || refreshing) && <FeedSkeleton />}
        {items.map((item) => (
          <FeedRow
            key={item.id}
            item={item}
            read={readIds.has(item.id)}
            onOpen={() => markRead(item.id)}
          />
        ))}
      </div>
    </div>
  );
}

function MonitorPill({ monitor }: { monitor: Monitor }) {
  return (
    <span
      className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${monitor.color}`}
    >
      {monitor.name}
    </span>
  );
}

function FeedRow({
  item,
  read,
  onOpen,
}: {
  item: FeedItem;
  read: boolean;
  onOpen: () => void;
}) {
  const monitor = DEMO_MONITORS.find((m) => m.id === item.monitorId);
  return (
    <a
      href={item.url}
      target="_blank"
      rel="noopener noreferrer"
      onClick={onOpen}
      className={`block px-4 py-3 border-b border-[#e5e5e5] last:border-b-0 transition-colors hover:bg-[#fafafa] ${read ? "opacity-60" : ""}`}
    >
      <div className="flex items-center gap-2 mb-1.5">
        {monitor && <MonitorPill monitor={monitor} />}
      </div>
      <div className="text-[14px] font-medium text-[#111827] leading-[1.4] mb-1">
        {item.title}
      </div>
      {item.blurb && (
        <div className="text-[12px] text-[#6b7280] leading-[1.55] mb-1.5 line-clamp-2">
          {item.blurb}
        </div>
      )}
      <div className="flex items-center gap-1.5 text-[11px] text-[#9ca3af]">
        <img
          src={`https://www.google.com/s2/favicons?domain=${item.source}&sz=32`}
          alt=""
          className="h-3 w-3"
          loading="lazy"
        />
        <span>{item.source}</span>
        <span>·</span>
        <span>{formatItemDate(item.publishedDate)}</span>
      </div>
    </a>
  );
}

function FeedSkeleton() {
  return (
    <div className="px-4 py-3 space-y-4">
      {[0, 1, 2, 3].map((i) => (
        <div key={i} className="space-y-1.5">
          <div className="h-3 w-20 rounded bg-[#e5e7eb] animate-pulse" />
          <div className="h-4 w-2/3 rounded bg-[#e5e7eb] animate-pulse" />
          <div className="h-3 w-full rounded bg-[#e5e7eb] animate-pulse" />
        </div>
      ))}
    </div>
  );
}

function formatItemDate(publishedDate: string | undefined): string {
  if (!publishedDate) return "Today";
  const ts = Date.parse(publishedDate);
  if (isNaN(ts)) return "Today";
  const diffDays = Math.floor((Date.now() - ts) / (1000 * 60 * 60 * 24));
  if (diffDays <= 0) return "Today";
  return formatRelative(ts);
}

function formatRelative(ts: number): string {
  const diff = Date.now() - ts;
  if (isNaN(diff)) return "";
  const sec = Math.floor(diff / 1000);
  if (sec < 60) return `${Math.max(1, sec)}s ago`;
  const min = Math.floor(sec / 60);
  if (min < 60) return `${min}m ago`;
  const hr = Math.floor(min / 60);
  if (hr < 24) return `${hr}h ago`;
  const day = Math.floor(hr / 24);
  if (day < 30) return `${day}d ago`;
  const mo = Math.floor(day / 30);
  if (mo < 12) return `${mo}mo ago`;
  return `${Math.floor(mo / 12)}y ago`;
}