Mobile Search — Integration Guide
One query box, one endpoint, three result sections: people, posts, and posts filed under a matching topic. This is the contract for the React Native client — what to call, what comes back, how to page each section, and the handful of behaviours that will look like bugs if you don't know about them.
- API base URL (local):
http://localhost:3001 - Auth:
Authorization: Bearer <accessToken>on every request. - Send
X-Client-Type: mobileon every request.
What's new
- 2026-09-10 — First version. The search screen is a single call,
GET /api/search?q=…, which returns page 1 of all three sections at once. Two things to build around: you cannot page the combined call — re-request one section with?type=<section>&cursor=<its own nextCursor>— and queries shorter than 3 characters cannot search post text, so thepostssection comes back withunavailable: "MIN_QUERY_LEN"while people and topics still work. Nothing here is new backend behaviour; it was just never written down.
Short version — just the calls
GET /api/search?q=music → page 1 of users + posts + topics
GET /api/search?q=music&type=posts&cursor=… → page 2+ of ONE section
GET /api/search?q=music&type=users&limit=30 → one section, bigger page
- One endpoint powers the whole screen. Don't fan out to
/api/users/searchfor the People tab — this call already returns it. cursoris only valid together withtype. The combined call is page-1-only.- Rate limit: 30 requests / 10 seconds, shared across every
type. Debounce.
1 — The request
GET /api/search?q=music&type=posts&cursor=eyJ...&limit=20
Authorization: Bearer <accessToken>
X-Client-Type: mobile
| Param | Type | Required | Notes |
|---|---|---|---|
q | string | yes | 1–80 chars. Trimmed server-side; % characters are stripped before searching. |
type | enum | no | users | posts | topics. Omit for the combined (page 1) call. |
cursor | string | no | Opaque cursor from that section's nextCursor. Requires type. |
limit | int | no | 1–50, default 20. Applied per section — the combined call can return 3×. |
Rate limit: 30 requests / 10 seconds per user, one shared bucket for every
type. A paging call and a keystroke call spend from the same budget.
1.1 — Two modes, derived from q.length
The response tells you which one you got, in data.mode:
mode | When | People match by | Post text search | Topics |
|---|---|---|---|---|
prefix | 1–2 chars | username prefix only | unavailable | works |
full | 3+ chars | username or display name substring | works | works |
Post text search below 3 characters would be a full table scan, so it's turned off rather than made slow. You get one of two things:
- Combined call →
postsis present but empty, with a marker:Render "Keep typing to search posts", not "No results"."posts": { "items": [], "nextCursor": null, "unavailable": "MIN_QUERY_LEN" } ?type=postswith a short query → 400QUERY_TOO_SHORTcarryingminQueryLength: 3. An empty 200 would be a lie for a call that explicitly asked for posts.
Display-name search is also off in prefix mode — ?q=al finds alice by
handle, but not a user whose display name is "Alan" with a different username.
It starts working at 3 characters.
2 — The response
Combined call, GET /api/search?q=al&limit=2 (real body, trimmed):
{
"success": true,
"data": {
"query": "al", // what was actually searched, after trim + % stripping
"mode": "prefix",
"users": {
"items": [
{
"id": "cml805q0u0000iaqhjdfk6z2y",
"username": "alice",
"createdAt": "2026-02-04T12:27:15.068Z",
"profile": {
"displayName": "Alice Johnson",
"avatarUrl": "https://…/avatars/…/display.webp"
}
}
],
"nextCursor": null
},
"posts": { "items": [], "nextCursor": null, "unavailable": "MIN_QUERY_LEN" },
"topics": {
"matched": [
{ "id": "019f…", "slug": "football", "name": "football",
"category": "Sports & Fitness", "icon": null }
],
"items": [ /* POSTS tagged with a matched topic */ ],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2…"
}
}
}
A section you didn't ask for is absent, not empty. With ?type=users the
body carries data.users and nothing else — no posts key at all. So:
if (!data.posts) { /* not requested — leave the tab as it was */ }
else if (data.posts.items.length === 0) { /* requested, no hits — empty state */ }
"users" in data === false and data.users.items.length === 0 mean different
things. Don't collapse them.
2.1 — users.items
Exactly the fields shown above: id, username, createdAt, and a nullable
profile { displayName, avatarUrl }. Note what is not there:
- No follow state. No
youFollow/followsYou/isPrivate. If your row needs a Follow button, it takes a second call — eitherGET /api/users/:usernameon tap, or drive the button from the profile screen you navigate to. (The mention picker,GET /api/users/mention-search, does return follow state — it's a different endpoint for a different job. See Mentions.) - No counts. No follower/post counts.
Ordering is recency (newest account first), with two relevance nudges applied to the page you're given:
- An exact username match is hoisted to position 1 — page 1 only.
- Within the page: exact handle, then username-prefix, then display-name-prefix, then everything else. Recency order survives inside each group.
There is no cross-page relevance score. A perfect match that sits on page 3 by recency stays on page 3 (except the exact-handle hoist, which is a separate lookup). Don't sort client-side — you'd only undo this.
Never returned: deactivated / mid-deletion accounts, accounts hidden by moderation, and anyone on either side of a block (they blocked you, or you blocked them). No client-side filtering needed.
2.2 — posts.items
Canonical serialized posts — the identical shape to GET /api/posts/* and
the feed, so reuse your post card component unchanged. Field list:
Post Data Structure
(author, media, topics, isLiked, isSaved, isReposted,
interestStatus, cta, repost original, …).
Matched against post content or title, case-insensitive substring.
Visibility is fully applied server-side: private authors you don't follow,
blocked users (including a repost's original author) and hidden posts never appear.
2.3 — topics — the section that trips people up
"topics": {
"matched": [ /* TOPIC objects — the chip row */ ],
"items": [ /* POSTS tagged with any of them */ ],
"nextCursor": "…"
}
matchedis the chip row. Every topic whosenameorslugmatched. Complete and uncapped — not paginated, no cursor.?q=musiccurrently returns 20 chips. Render them scrollable and expect double digits.itemsare POSTS, not topics. Same post shape as §2.2.nextCursorpagesitemsonly. The chips never change while you page.- Works in both modes — it filters on topic id, never on post text.
?q=AIreturns AI-topic posts even thoughpostsis unavailable at 2 characters. - Matching is substring, so short queries are noisy.
?q=almatchesfootballandlocal-travel. Expected, not a bug — but it's a reason to render the chip row below the People results on a short query.
posts and topics.items overlap on purpose and are not de-duplicated. They
answer different questions — your words appear in this post vs this post is
filed under a topic you named — and they page independently. Keep them in
separate tabs/sections; don't merge the two arrays.
3 — Paging
Each section has its own keyset cursor. One cursor cannot address three sections, so the API rejects the ambiguous call rather than guessing:
GET /api/search?q=music&cursor=… → 400 CURSOR_REQUIRES_TYPE
GET /api/search?q=music&type=posts&cursor=… → ✅ page 2 of posts
The flow for a tabbed search screen:
- Query changes → one combined call, no
type. Fill all three tabs from it. - User scrolls a tab to the bottom → re-request that section only:
?q=<same q>&type=<section>&cursor=<that section's nextCursor>&limit=<same limit>. nextCursor === null→ end of that section. Stop.- Query changes again → throw all three cursors away and go back to step 1.
Keep q byte-identical across a section's pages. Changing q mid-page invalidates
nothing server-side — it just silently pages a different search.
Malformed or hand-built cursors are 400 INVALID_CURSOR. Treat a cursor as
opaque; never parse, cache across queries, or reuse one section's cursor for another.
4 — Typing behaviour
The endpoint is keystroke-driven; the budget is 30 requests / 10 seconds shared across all three sections. Same debounce shape as the mention picker:
let abortController: AbortController | null = null;
async function search(q: string, limit = 20) {
abortController?.abort();
abortController = new AbortController();
await sleep(250); // debounce
if (abortController.signal.aborted) return;
const res = await api.get(`/search?q=${encodeURIComponent(q)}&limit=${limit}`, {
signal: abortController.signal,
});
return res.data.data;
}
- Debounce 250ms and cancel the in-flight request on the next keystroke.
- Don't fire on an empty box.
qis required; an empty one is a wasted 400. - Fire from 1 character — people and topics work there. The
poststab shows "Keep typing to search posts" untilq.length >= 3. - A paging request costs from the same bucket. If a user scrolls while typing, cancel the paging call first.
5 — Errors
| Status | code | Trigger | What to show |
|---|---|---|---|
| 400 | — (Validation Error) | q missing or >80 chars, bad type, non-numeric limit | Bug on the client — don't fire the call |
| 400 | QUERY_REQUIRED | q was only whitespace / % characters | Idle state, no error toast |
| 400 | QUERY_TOO_SHORT | ?type=posts with fewer than 3 chars (minQueryLength: 3) | "Keep typing to search posts" |
| 400 | CURSOR_REQUIRES_TYPE | cursor sent without type | Bug — see §3 |
| 400 | INVALID_CURSOR | Malformed / stale cursor | Drop the cursor, reload the section from page 1 |
| 401 | — | Missing or expired token | Refresh, then retry |
| 422 | CONTENT_BLOCKED | The query itself hit the blocked-word filter | Show error under the box; don't show empty results |
| 429 | — | >30 requests / 10s | Back off; retry after Retry-After |
Error envelope for the coded 400s:
{ "success": false, "error": "Query must be at least 3 characters to search posts",
"code": "QUERY_TOO_SHORT", "minQueryLength": 3 }
5.1 — A blocked search query
The search box goes through the same app-wide filter as every other text input:
{ "success": false, "error": "Please use kinder words.",
"code": "CONTENT_BLOCKED", "reason": "PROFANITY", "field": "q" }
field: "q" points at the search input. This is the shared 422 you already
handle everywhere else — see
Content Moderation. A blocked
query is an error, not an empty result set; rendering "No results" here tells
the child their word was fine and nothing matched.
6 — Which search endpoint
| Screen | Endpoint | Why not /api/search |
|---|---|---|
| The search screen | GET /api/search | — |
@ autocomplete in a composer | GET /api/users/mention-search | Context-boosted, returns follow state, capped at 10 |
| "New message" / chat search | GET /api/chat/search | Searches your threads + startable contacts |
| Events | GET /api/events/search | /api/search does not cover events at all |
| School picker | GET /api/schools?q=… | Different catalog entirely |
| Topic / interest picker | GET /api/users/topics | Returns topics to pick, not posts |
Notably: there is no separate People-tab endpoint to call. /api/users/search
exists but is the older, narrower version of the same thing — build the People tab
from data.users.
7 — Gotchas reference
| Case | Behaviour |
|---|---|
?q=%25 (a literal %) | Stripped before searching. All-% query → 400 QUERY_REQUIRED |
?q=%20%20al%20%20 (padded) | Trimmed. data.query echoes what was actually searched — render that, not your raw input |
limit=20 on a combined call | Up to 60 items total, 20 per section |
Same post in posts and topics.items | By design, not de-duplicated (§2.3) |
topics.matched is huge on a 2-char query | Substring match; expected. Scroll the chip row |
users.items is empty but you know the handle | Blocked either direction, deactivated, or moderation-hidden. Nothing to retry |
Paging topics with the posts cursor | Silently pages the wrong thing or 400s. Keep cursors per section |
| Two identical searches, different order | Ordering is recency; a new signup or a new post shifts the page. Expected |
8 — Implementation checklist
- One debounced call (250ms +
AbortController) onqchange, notype. - Three tabs off one response — People / Posts / Topics. Missing key ≠ empty array.
- Posts tab honours
unavailable: "MIN_QUERY_LEN"— "keep typing", not "no results". - Topic chips from
topics.matched, topic posts fromtopics.items— chips are not paginated, posts are. - Per-section paging with
?type=+ that section'snextCursor; reset all three cursors wheneverqchanges. - Reuse the post card —
posts.itemsandtopics.itemsare canonical serialized posts. - Person rows carry no follow state — either fetch the profile on tap or drop the inline Follow button.
- Handle 422
CONTENT_BLOCKED/field: "q"with the shared moderation handler, and 429 with a back-off.