Discovery & Search
Explore, trending, search, games, sponsors, and creator links. All endpoints require authentication — there is no anonymous browsing.
Trending
| Method | Path | Returns |
|---|---|---|
GET | /trending/posts?cursor=<offset>&limit= | The Explore feed: feed-shaped posts in trending order |
GET | /trending/clips | Trending video clips |
GET | /trending/playpalz | Trending creators |
GET | /trending/coaches | Trending coaches |
GET | /trending/games | Games ranked by how many creators clipped them this week |
GET | /trending/searches | Popular search queries |
All but /trending/posts wrap their payload:
{ "success": true, "data": [ … ] }Trending posts (the Explore feed)
/trending/posts returns the same item shape as /feed, so the app renders it with the Home feed's PostItem:
{ "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:
cursoris 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.
Trending searches
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.
Search
| Method | Path | Returns |
|---|---|---|
GET | /search?q=<query> | Users, clips, and games in one response |
GET | /search/users?q=<query> | Users only |
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
| Method | Path | Purpose |
|---|---|---|
GET | /discover | The discover feed |
The discover query is a placeholder
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
| Method | Path | Purpose |
|---|---|---|
GET | /games | The 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/games | Set 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
| Method | Path | Purpose |
|---|---|---|
GET | /sponsor | The currently active sponsor |
Sponsored placements rendered in discovery surfaces.
Links
Creator external links — merch stores, socials — shown on the profile, similar to Instagram's link list.
| Method | Path | Purpose |
|---|---|---|
GET | /links | Your links |
GET | /links/:linkId | One link |
POST | /links | Create |
PUT | /links/:linkId | Update |
DELETE | /links/:linkId | Delete |
A Link is { name, url } scoped to a user. Zod schemas live in apps/api/src/schemas/link.ts.
Notifications
| Method | Path | Purpose |
|---|---|---|
GET | /notifications | Cursor-paginated inbox, ?unreadOnly=true |
GET | /notifications/unread-count | Badge count |
POST | /notifications/:id/read | Mark one read |
POST | /notifications/read-all | Mark all read |
The reference implementation for cursor pagination in this codebase. Details in Notifications.
