Feed API — Mobile Integration Guide
The personalized home feed: a scored, paginated blend of posts from people you follow, interest matches, and trending content. This doc is for the mobile (React Native) client. Feed internals: feed-algorithm.md.
Endpoint
GET /api/feed?cursor=<opaque>&limit=<1-50>
Authorization: Bearer <accessToken>
X-Client-Type: mobile
| Query | Default | Notes |
|---|---|---|
limit | 20 | Page size. Hard-capped at 50. |
cursor | — | Opaque continuation token from the previous response. Omit for page 1. |
Response 200
{
"success": true,
"data": {
"items": [ /* serialized posts, ranked */ ],
"nextCursor": "eyJ...==",
"mode": "warm"
}
}
nextCursor— pass back as?cursor=to load the next page.null= end of feed.mode— which strategy produced this feed. Informational (analytics / debug), safe to ignore for rendering:cold— new user (interest/trending only).warm— established user (follow + interest + trending blend).fallback:following|fallback:any-recent|fallback:own|fallback:none— sparse graph; the normal pools were empty and the feed degraded gracefully.
Item shape
Every item is a canonical serialized post — identical shape to
GET /api/posts/*, so reuse your post card component as-is. See
Post Data Structure for the full
field list (author, media, topics, isLiked, isSaved, isReposted,
interestStatus, likeCount, commentCount, CTA, repost original, …).
Feed items add two ranking fields:
score: number; // frozen rank score (debug/analytics)
pool: "follow" | "interest" | "trending"; // which pool sourced this item
Pagination — frozen snapshots
The ranked feed is computed once per page-1 request, frozen server-side, and paged by slicing that frozen list. This matters for the client:
- A no-cursor
GET /api/feedrebuilds — fresh ranking, fresh content, freshly applied exclusions and seen penalties. This is your pull-to-refresh. - Cursor pages are stable — no re-ranking between pages, so no duplicates
and no skips mid-scroll. Follow
nextCursoruntil it'snullfor infinite scroll. - Snapshots live ~5 minutes. An old/expired cursor doesn't error — the server rebuilds from the top. Rare, but don't treat a cursor as permanent.
Consumption model — what leaves the feed, and when
The feed separates learning (what you'll see next) from selection (what's a candidate now). Interacting with a post never re-ranks the post you just acted on.
Liked & commented posts are removed (next build)
A post you like or comment on is consumed — it is excluded from every future feed build for you, in every mode. It still lives on the author's profile, your liked list, search, notifications, and its direct link — just not the home feed. This kills the old "I liked it, refreshed, and it came back" bug.
Timing follows the snapshot rules above:
- Within the current frozen snapshot it will not resurface (dedup + freeze), so mid-scroll is already safe — you don't need to do anything.
- On the next no-cursor refresh it's gone from the list.
Seen posts are down-ranked automatically
Posts already served to you are recorded server-side and gently demoted on the next build (graded — seen once is a small nudge, seen repeatedly sinks lower), so refreshing rotates content instead of showing the same top posts. Nothing for the client to send — seen tracking is automatic today.
Future: viewport-accurate impression tracking (
POST /feed/impressionwith the ids ~70% visible) is planned but not built. Do not call it yet. Until then the server counts a post as seen when it's returned in a page.
Recommended client UX
- Pull-to-refresh →
GET /api/feedwith no cursor. Fresh snapshot with liked/commented removed and seen posts rotated down. - Infinite scroll → append items, follow
nextCursor, stop atnull. - On like / comment → update the card's
isLiked/ counts optimistically and leave the card in place for the current session (the frozen snapshot won't duplicate or bounce it). If your design prefers it gone immediately, optimistically remove it from the local list — safe, because the snapshot can't resurface it. No page-1 refetch is required just to hide a liked post. - On
not_interested→ see Post Interest (harder, permanent exclusion + immediate topic-weight shift). - Don't rebuild the feed on every interaction — refresh on explicit pull-to-refresh, screen re-focus, or when the list runs out.
Examples
TOKEN=... # from POST /api/auth/login (alice@example.com / Password123)
# Page 1
curl -s localhost:3001/api/feed -H "Authorization: Bearer $TOKEN" | jq '.data.mode, (.data.items | length), .data.nextCursor'
# Next page
curl -s "localhost:3001/api/feed?cursor=<NEXT_CURSOR>" -H "Authorization: Bearer $TOKEN" | jq '.data.items[].id'
# Verify a liked post is gone after refresh — index must be null
curl -X PUT localhost:3001/api/posts/<POST_ID>/like -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"liked":true}'
curl -s localhost:3001/api/feed -H "Authorization: Bearer $TOKEN" \
| jq '[.data.items[].id] | index("<POST_ID>")'
Swagger UI: http://localhost:3001/api/docs (Feed → GET /api/feed).