Search Creators
Public creator search across username, displayName, and bio. PostgreSQL full-text search with ILIKE fallback. Cursor-based pagination, CDN-cached 5min/10min SWR.
GET /api/v1/discover/search โ ๐ Public ยท Rate limit: 60 req / minute ยท Kill-switched
Public creator search. Uses PostgreSQL FTS (to_tsvector over username + displayName + bio, ranked by ts_rank) when available, falls back to a substring ILIKE query when the FTS engine fails or the parsed query is invalid. Active-creator filter is enforced server-side: User.status = ACTIVE, CreatorProfile.creatorStatus = ACTIVE, vacationMode = false. Cursor-based pagination โ clients pass nextCursor from the previous response.
Cursor pagination, not offset. Pass ?cursor=<lastResultId> from the previous response โ the server uses Prisma's cursor + skip: 1 to continue from the right place. There's no page param. Cursor values are opaque (currently the row id of the last result) and tied to the same query / sort.
FTS fallback is silent. When the FTS query parser rejects the input (e.g. unsupported operators) the server falls back to ILIKE without telling the client. Result ranking + ordering may differ subtly between the two paths. The minimum query length is 2 chars (DTO MinLength(2)); calls below that fail validation, not search.
Request
Query parameters
| Param | Type | Required | Validation | Notes |
|---|---|---|---|---|
query | string | โ | MinLength(2), MaxLength(200) | Search input. FTS-friendly chars stripped (angle brackets, colon, parens, ampersand, pipe, exclamation, asterisk, quotes); whitespace tokens joined with the FTS AND operator. |
platform | string | optional | IsString() | Reserved DTO field โ currently NOT used by the search service. Pass for forward compat or skip. |
cursor | string | optional | IsString() | Opaque next-page cursor (last result id). |
limit | number | optional | Min(1), Max(50), @Type(Number) | Server-clamped further to min(limit, 100) AND discover.page_size (admin-managed default 20) when not supplied. |
No headers required.
Response headers
| Header | Value |
|---|---|
Cache-Control | public, s-maxage=300, stale-while-revalidate=600 |
Response
200 OK โ ApiResponseOf<CursorPaginatedCreatorsDto>
{
"success": true,
"data": {
"results": [
{
"id": "c1a2b3c4-d5e6-7890-abcd-ef1234567890",
"userId": "u1a2b3c4-d5e6-7890-abcd-ef1234567890",
"username": "johndoe",
"displayName": "John Doe",
"avatarUrl": "https://cdn.bio.re/avatars/abc.webp",
"level": "BRONZE",
"dmActive": true,
"totalFollowers": 1500
}
],
"nextCursor": "c1a2b3c4-d5e6-7890-abcd-ef1234567890",
"hasMore": true
}
}Item fields (CreatorCardDto)
| Field | Type | Notes |
|---|---|---|
id | string | CreatorProfile.id โ pass to bio render / DM send paths |
userId | string (UUID) | undefined | The owning User.id |
username / displayName / avatarUrl | string | undefined | Profile basics |
level | enum | undefined | BRONZE / SILVER / GOLD / PLATINUM |
dmActive | boolean | DM-accepting flag (raw โ not vacation-adjusted) |
totalFollowers | number | Sum of follower counts across linked social accounts |
Top-level fields
| Field | Type | Notes |
|---|---|---|
results | array | Up to limit cards, ordered by FTS rank (or updatedAt DESC on fallback) |
nextCursor | string | null | Pass back as ?cursor= for the next page; null when no more results |
hasMore | boolean | True when there's at least one more page |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | (DTO validation) | query shorter than 2 chars / longer than 200; limit outside [1, 50]; etc. |
429 | (throttle) | Rate limit exceeded (60 req/min) |
503 | features.discover_disabled | Admin kill switch DISCOVER is active |
Side effects
- Resolve
pageSize = min(limit ?? config.discover.page_size ?? 20, 100). - FTS path: build
tsQuery(strip risky chars, join tokens with&); raw SQL againstto_tsvector(username + displayName + bio), ordered byts_rank. Returns IDs only (defense-in-depth โ secondfindManyfetches the full rows withcreatorInclude). Preserve FTS rank ordering in-memory. - Fallback (FTS fail):
creatorProfile.findManywithILIKEoveruser.usernameanduser.displayName,orderBy updatedAt desc, with optional cursor. - Compute
hasMore = creators.length > pageSize; trim and assemblenextCursor. - Map each row โ
toCard()(theCreatorCardDtoshape). - Return
{ results, nextCursor, hasMore }. No mutations, no auth.
Code samples
curl 'https://api.bio.re/api/v1/discover/search?query=fitness&limit=20'
# Next page
curl 'https://api.bio.re/api/v1/discover/search?query=fitness&limit=20&cursor=c1a2b3c4-d5e6-7890-abcd-ef1234567890'type CreatorCard = {
id: string;
userId?: string;
username?: string;
displayName?: string;
avatarUrl?: string;
level?: 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINUM';
dmActive: boolean;
totalFollowers: number;
};
type CursorPage<T> = {
results: T[];
nextCursor: string | null;
hasMore: boolean;
};
async function searchCreators(
query: string,
options: { cursor?: string; limit?: number } = {},
): Promise<CursorPage<CreatorCard>> {
const url = new URL('https://api.bio.re/api/v1/discover/search');
url.searchParams.set('query', query);
if (options.cursor) url.searchParams.set('cursor', options.cursor);
if (options.limit) url.searchParams.set('limit', String(options.limit));
const res = await fetch(url);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Search failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useInfiniteQuery } from '@tanstack/react-query';
export const discoverKeys = {
search: (query: string, limit: number) => ['discover', 'search', query, limit] as const,
};
export function useSearchInfinite(query: string, limit = 20) {
return useInfiniteQuery({
queryKey: discoverKeys.search(query, limit),
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) => {
const url = new URL('/api/v1/discover/search', window.location.origin);
url.searchParams.set('query', query);
url.searchParams.set('limit', String(limit));
if (pageParam) url.searchParams.set('cursor', pageParam);
const res = await fetch(url);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Search failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as CursorPage<CreatorCard>;
},
getNextPageParam: (last) => (last.hasMore ? last.nextCursor ?? undefined : undefined),
enabled: query.trim().length >= 2,
});
}Try it
Query Parameters
Search query
2 <= length <= 200Filter by platform
Cursor for cursor-based pagination
Number of results to return
1 <= value <= 50Response Body
application/json
curl -X GET "https://loading/api/v1/discover/search?query=fitness"{
"success": true,
"data": {
"results": [
{
"id": "cuid_creator_123",
"userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
"username": "johndoe",
"displayName": "John Doe",
"avatarUrl": "https://cdn.bio.re/avatars/abc.jpg",
"level": "BRONZE",
"dmActive": false,
"totalFollowers": 0
}
],
"nextCursor": "string",
"hasMore": false
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/discover/discover.controller.ts | 35โ41 (search) |
| DTO (request) | apps/api-core/src/modules/discover/dto/index.ts | 6โ18 (SearchDto) |
| DTO (response) | apps/api-core/src/modules/discover/dto/discover-response.dto.ts | 37โ46 (CursorPaginatedCreatorsDto), 7โ31 (CreatorCardDto) |
| Service | apps/api-core/src/modules/discover/discover.service.ts | 33โ94 (search โ FTS + ILIKE fallback) |
| Config | apps/api-core/src/modules/config/config.service.ts | discover.page_size (admin-managed default 20) |
| Kill switch | apps/api-core/src/common/guards/kill-switch.guard.ts | RequireKillSwitch('DISCOVER') (class-level) |
| Prisma model | packages/prisma/prisma/schema.prisma | CreatorProfile (active filter), User (status, deletedAt), FTS via raw SQL to_tsvector |
Export Creator Analytics (CSV)
Download a CSV of the overview, traffic, or links endpoint. CSV-injection-safe via formula-character escaping. Returns text/csv with attachment Content-Disposition.
Get Trending Creators
Public trending creator list. Multi-factor scoring (7-day window) cached in Redis 15min. Falls back to totalMessages sort if scoring fails. CDN-cached 5min.