Skip to content

Discovery & Search ​

Explore, trending, search, games, sponsors, and creator links. All endpoints require authentication — there is no anonymous browsing.

MethodPathReturns
GET/trending/posts?cursor=<offset>&limit=The Explore feed: feed-shaped posts in trending order
GET/trending/clipsTrending video clips
GET/trending/playpalzTrending creators
GET/trending/coachesTrending coaches
GET/trending/gamesGames ranked by how many creators clipped them this week
GET/trending/searchesPopular search queries

All but /trending/posts wrap their payload:

json
{ "success": true, "data": [ … ] }

/trending/posts returns the same item shape as /feed, so the app renders it with the Home feed's PostItem:

json
{ "items": [ … ], "nextCursor": "10", "hasMore": true }
  • Ranking: engagement per view (smoothed toward the global rate), times a sub-linear volume term, decayed by age. Every 4th slot is a discovery slot for fresh posts from small or new accounts. Full algorithm: Trending.
  • Eligibility: public, published posts from the last 7 days by active authors. VIP, followers-only and private posts never appear.
  • Paging: cursor is an offset into the stored ranking (trending:posts), which changes only when the hourly build runs. Blocked authors are removed per viewer, and each page is re-read fresh, so deletes and edits show immediately.

Clip thumbnails (/trending/clips, search clips) are the media's posterUrl, falling back to the Mux thumbnail. Never the HLS originalUrl.

Precomputed, not cached ​

No trending endpoint computes anything. Background jobs build every list (trending-daily at 05:00 UTC, trending-rising hourly) and store it in Redis. Requests read the stored list and apply the viewer's blocks and follows. Before the first build in a fresh environment the lists are empty. Run pnpm --filter @playpals/api trending:build locally. See Trending → Jobs.

PlayPalz vs coaches ​

Both are creators; the distinction is by badge. The PlayPal badge is assigned automatically to creators, and the coach badge is assigned manually. /trending/coaches filters on the coach badge. Both lists rank by follower growth relative to size, not by follower count. See Trending → Creators. See Domain Models.

Backed by the SearchLog model — every query is logged with query and createdAt, both indexed. The daily build trims and lower-cases queries and ranks them by how many distinct people searched them this week.

MethodPathReturns
GET/search?q=<query>Users, clips, and games in one response
GET/search/users?q=<query>Users only
json
GET /search?q=valorant
{
  "success": true,
  "data": { "users": [ … ], "clips": [ … ], "games": [ … ] }
}

An empty or whitespace-only q short-circuits to empty results without touching the database.

Game results are a way into clips: only games with at least one public, published clip are returned, sorted by clipCount. Tapping one opens the game's clip feed (below).

Note the inconsistency: /search uses the { success, data } envelope while /search/users returns a bare array.

Discover ​

MethodPathPurpose
GET/discoverThe discover feed

The discover query is a placeholder

ts
export const fetchDiscoverFeed = async (authenticatedUserId, limit?, cursor?) => {
  return prisma.user.findMany({ where: { NOT: { id: authenticatedUserId } } });
};

It selects every user in the database except you — no ranking, no filtering, no pagination, and the limit and cursor parameters are accepted and ignored. It works at demo scale and will fall over as the user base grows.

A real implementation should filter to creators (userType = "playpal"), exclude blocked users, rank by something meaningful (recency, engagement, shared game interests), and honour the pagination parameters it already accepts. Partial

The /trending/* endpoints are considerably more developed and are what the explore screen actually uses.

Games ​

MethodPathPurpose
GET/gamesThe catalog
GET/games/:id{ id, title, imageUrl, genres, clipCount }
GET/games/:id/posts?cursorId=&cursorCreatedAt=The game's public clips, newest first, in feed shape
PUT/user/gamesSet your favourite games

clipCount and the clip feed use the same filter: type = "clip", visibility = "public", published. Blocked authors are excluded from the feed per viewer.

Populated by igdb-heartbeat — roughly 300 published games at the time of writing, refreshed daily. Favourites are chosen during onboarding and feed discovery.

Sponsors ​

MethodPathPurpose
GET/sponsorThe currently active sponsor

Sponsored placements rendered in discovery surfaces.

Creator external links — merch stores, socials — shown on the profile, similar to Instagram's link list.

MethodPathPurpose
GET/linksYour links
GET/links/:linkIdOne link
POST/linksCreate
PUT/links/:linkIdUpdate
DELETE/links/:linkIdDelete

A Link is { name, url } scoped to a user. Zod schemas live in apps/api/src/schemas/link.ts.

Notifications ​

MethodPathPurpose
GET/notificationsCursor-paginated inbox, ?unreadOnly=true
GET/notifications/unread-countBadge count
POST/notifications/:id/readMark one read
POST/notifications/read-allMark all read

The reference implementation for cursor pagination in this codebase. Details in Notifications.

Internal documentation — PlayPalz platform