BIO.RE
Discover

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

ParamTypeRequiredValidationNotes
querystringโœ“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.
platformstringoptionalIsString()Reserved DTO field โ€” currently NOT used by the search service. Pass for forward compat or skip.
cursorstringoptionalIsString()Opaque next-page cursor (last result id).
limitnumberoptionalMin(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

HeaderValue
Cache-Controlpublic, 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)

FieldTypeNotes
idstringCreatorProfile.id โ€” pass to bio render / DM send paths
userIdstring (UUID) | undefinedThe owning User.id
username / displayName / avatarUrlstring | undefinedProfile basics
levelenum | undefinedBRONZE / SILVER / GOLD / PLATINUM
dmActivebooleanDM-accepting flag (raw โ€” not vacation-adjusted)
totalFollowersnumberSum of follower counts across linked social accounts

Top-level fields

FieldTypeNotes
resultsarrayUp to limit cards, ordered by FTS rank (or updatedAt DESC on fallback)
nextCursorstring | nullPass back as ?cursor= for the next page; null when no more results
hasMorebooleanTrue when there's at least one more page

Errors

HTTPcode / i18nKeyReason
400(DTO validation)query shorter than 2 chars / longer than 200; limit outside [1, 50]; etc.
429(throttle)Rate limit exceeded (60 req/min)
503features.discover_disabledAdmin kill switch DISCOVER is active

Side effects

  1. Resolve pageSize = min(limit ?? config.discover.page_size ?? 20, 100).
  2. FTS path: build tsQuery (strip risky chars, join tokens with &); raw SQL against to_tsvector(username + displayName + bio), ordered by ts_rank. Returns IDs only (defense-in-depth โ€” second findMany fetches the full rows with creatorInclude). Preserve FTS rank ordering in-memory.
  3. Fallback (FTS fail): creatorProfile.findMany with ILIKE over user.username and user.displayName, orderBy updatedAt desc, with optional cursor.
  4. Compute hasMore = creators.length > pageSize; trim and assemble nextCursor.
  5. Map each row โ†’ toCard() (the CreatorCardDto shape).
  6. 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

GET
/api/v1/discover/search

Query Parameters

query*string

Search query

Length2 <= length <= 200
platform?string

Filter by platform

cursor?string

Cursor for cursor-based pagination

limit?number

Number of results to return

Range1 <= value <= 50

Response 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

SourcePathLines
Controllerapps/api-core/src/modules/discover/discover.controller.ts35โ€“41 (search)
DTO (request)apps/api-core/src/modules/discover/dto/index.ts6โ€“18 (SearchDto)
DTO (response)apps/api-core/src/modules/discover/dto/discover-response.dto.ts37โ€“46 (CursorPaginatedCreatorsDto), 7โ€“31 (CreatorCardDto)
Serviceapps/api-core/src/modules/discover/discover.service.ts33โ€“94 (search โ€” FTS + ILIKE fallback)
Configapps/api-core/src/modules/config/config.service.tsdiscover.page_size (admin-managed default 20)
Kill switchapps/api-core/src/common/guards/kill-switch.guard.tsRequireKillSwitch('DISCOVER') (class-level)
Prisma modelpackages/prisma/prisma/schema.prismaCreatorProfile (active filter), User (status, deletedAt), FTS via raw SQL to_tsvector

On this page