uidudevelopers
Domains

Search

Run filtered, paginated search across every searchable record type in your workspace.

search queries the workspace's search index: one call, filtered and sorted, across the record types you name. The index covers about forty of them — Contact, Deal, Donation, DonationCampaign, Event, Form, Product, Story, Task, Note, Employee, ModelItem and more (the node union of SearchResult lists them all).

Mental model

search(client, { models, params, first, after })
└── SearchResult                 a Relay connection, not a flat array
    ├── totalCount, totalPages   how many matches
    ├── term                     the term the engine searched for
    ├── took                     query time, in ms
    ├── pageInfo                 { hasNextPage, endCursor }
    └── edges[]                  { cursor, node: { __typename, id } }
  • Hits are references. Each node carries only __typename and id. Narrow on __typename, then fetch what you want to show with that domain's get* (getEvent, getStory, getForm…). The SDK's query is fixed, so you can't ask search for more fields per type.
  • models restricts the search to record types: ['Event', 'Story']. Always pass it. With a single model the engine applies that model's own defaults (scoping, newest first); a single model that isn't searchable fails the request.
  • Not indexed: knowledge-base articles. To search a help center, filter its articles in memory (Help Center).
  • Scope. Results are limited to the client's workspace and leave out trashed (discarded) records. Your filters can narrow that scope, never widen it.
  • If the index fails (missing, being rebuilt), you get an empty result rather than an error — so an empty result isn't proof that nothing matches.

params — every key is optional:

{
  term?: string;                       // full-text term; omitted = everything ('*')
  filters?: Array<{
    id: string;                        // the field, camelCase: 'createdAt', 'name'
    operator: string;                  // see below
    value?: unknown;                   // JSON
    kind?: string;                     // field kind: 'date', 'number', 'multipleSelect'…
  }>;
  sorting?: Array<{ id: string; desc?: boolean }>;
  grouping?: string[];
  options?: { /* engine options: fields, highlight, aggs, boostBy, misspellings… (JSON) */ };
}
operatorMatches
= / eqexactly value — case-sensitive; with kind: 'multipleSelect', all of value
inany of value (an array)
!= / notIn, doesNotContainanything but value
containsvalue as a full-text term on that field — case-insensitive, fuzzy
>, >=, <, <=ranges on numbers and dates
between, notBetweenvalue: [from, to]; leave either end empty for an open range
dateOverlapa date range that overlaps value
null, notNullempty or set; kind: 'number' or 'date' treats only null as empty, other kinds '' too

Sorting: relevance comes first, then your sorting in order, then the default order (newest first). Pagination is cursor-only: pass the last pageInfo.endCursor as after; there is no going back. hasNextPage is true whenever a page comes back full, so when the total is an exact multiple of first the last request returns an empty page.

Use cases

A search box over events and stories

Goal: /search?q=… shows matching events and stories with their titles, from one route.

// src/app/search/page.tsx
import Link from 'next/link';
import { getEvent, getStory, search } from '@uidu/client';
import { uidu } from '@/lib/uidu';

export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q = '' } = await searchParams;
  if (q.trim().length < 2) return <p>Type at least two characters.</p>;

  const result = await search(uidu, {
    models: ['Event', 'Story'],
    params: { term: q },
    first: 10,
  });
  const hits = (result?.edges ?? []).flatMap((e) => (e?.node ? [e.node] : []));

  // Hits are { __typename, id }: fetch each one's title from its own domain.
  const rows = await Promise.all(
    hits.map(async (hit) => {
      switch (hit.__typename) {
        case 'Event': {
          const event = await getEvent(uidu, { id: hit.id });
          const href = `/event/${encodeURIComponent(hit.id)}`;
          return event && { id: hit.id, kind: 'Event', title: event.name, href };
        }
        case 'Story': {
          const story = await getStory(uidu, { id: hit.id });
          const href = `/stories/${encodeURIComponent(hit.id)}`;
          return story && { id: hit.id, kind: 'Story', title: story.name, href };
        }
        default:
          return null;
      }
    }),
  );

  return (
    <>
      <p>
        {result?.totalCount ?? 0} results in {result?.took ?? 0} ms
      </p>
      <ul>
        {rows.map(
          (r) =>
            r && (
              <li key={r.id}>
                <Link href={r.href}>{r.title}</Link> <small>· {r.kind}</small>
              </li>
            ),
        )}
      </ul>
    </>
  );
}
  • Reads work with the public token, so the same call can run in the browser. Doing it in a Server Component keeps the follow-up get* calls in one place and off the visitor's connection.
  • Keep first small: every hit costs one more request to render.

A filtered, sorted listing with "load more"

Goal: the stories created since the start of the year, newest first, twenty at a time.

// src/app/stories/archive/actions.ts
'use server';
import { search } from '@uidu/client';
import { uidu } from '@/lib/uidu';

export async function storiesSince(from: string, after?: string) {
  const result = await search(uidu, {
    models: ['Story'],
    first: 20,
    after,
    params: {
      filters: [{ id: 'createdAt', kind: 'date', operator: '>=', value: from }],
      sorting: [{ id: 'createdAt', desc: true }],
    },
  });

  return {
    ids: (result?.edges ?? []).flatMap((e) => (e?.node ? [e.node.id] : [])),
    total: result?.totalCount ?? 0,
    // Pass back as `after` for the next twenty; null when there are no more.
    next: result?.pageInfo.hasNextPage ? (result.pageInfo.endCursor ?? null) : null,
  };
}

// storiesSince('2026-01-01')            → first page
// storiesSince('2026-01-01', page.next) → the next one
  • Filter ids are the indexed field names in camelCase (createdAt, not created_at). A filter on a field the model doesn't index matches nothing.
  • = is exact and case-sensitive (Vanini ≠ vanini). For "name contains", use contains, or put the text in term.
  • With no term, the search matches everything that passes the filters — this is a plain listing, ordered by your sorting.

Every @uidu/client function in Search, generated from the source — the full reference has the rest of the SDK.

FunctionKindAuthCLIDescription
searchreadpublic token—Search the workspace index, optionally limited to models, with filters and sorting in params; page with first/after.

Types: SearchResult

On this page