Search
Search across every object in the workspace at once.
The platform offers two kinds of full-text search:
- Per-object search finds records of one object, with the same filters as any record query. See Working with records for the REST endpoint, or
useSearchRecordsin the React hooks. - Global search looks across every object at once. It returns lightweight hits rather than full records, which is what a workspace-wide search bar needs.
This page covers global search.
Searching everything
useGlobalSearch takes the query and returns matches from any object:
import { useGlobalSearch } from "@stndrds/react";
function SearchBar({ query }: { query: string }) {
const { data, isLoading } = useGlobalSearch(query);
return data?.results.map((hit) => (
<li key={hit.recordId}>
{hit.label} <small>{hit.objectLabel}</small>
</li>
));
}Outside React, client.globalSearch({ q }) does the same — see SchemaClient.
What a hit contains
A hit is not a record. It carries just enough to render a result row and link to the record:
{
recordId: string;
objectId: string;
objectName: string; // "contacts"
objectLabel: string; // "Contact" — ready to show as a section header
label: string; // the record's display name, the same one it shows everywhere else
createdAt: string;
updatedAt: string;
}Because each hit names its object, you can route to the right detail page without a second lookup. To show attribute values, fetch the record with useRecord.
Options
| Option | Effect |
|---|---|
objectNames | Restrict the search to these objects. Omit to search all of them. |
limit / offset | Page through results. For the next request, use the returned nextOffset rather than adding the number of hits. Not supported by the grouped hook below. |
minQueryLength | Skip the request until the query is this long. Defaults to 1. |
enabled | Set to false to skip the request entirely. |
Options go in a second argument:
useGlobalSearch(query, { objectNames: ["contacts", "companies"], minQueryLength: 2 });Every flat response includes { total, totalIsExact, hasMore, nextOffset? }. Search authorization is applied after the search engine produces candidates, so total is a viewer-safe lower bound and totalIsExact is false. A page can contain no results while hasMore is still true when its bounded candidate window was rejected; in that case nextOffset always advances. Continue with that exact cursor until hasMore becomes false.
Grouping by object
A flat result list mixes contacts, companies, and deals together. useGlobalSearchGrouped returns one group per object instead, which suits a command palette or a sectioned dropdown:
const { data } = useGlobalSearchGrouped(query);
data?.groups.map((group) => (
<section key={group.objectName}>
<h3>
{group.objectLabel} ({group.count})
</h3>
{group.results.map((hit) => (
<li key={hit.recordId}>{hit.label}</li>
))}
</section>
));Outside React, client.globalSearchGrouped({ q }) returns the same shape.
The two hooks aren't interchangeable
Pick one per surface rather than switching at runtime: the flat hook returns { results, total, totalIsExact, hasMore, nextOffset? }, the grouped one returns the same metadata beside groups. The grouped hook also has no limit or offset — it is built for a bounded sectioned list, not for paging.
Searching the trash
Global search covers live records only. To search trashed records, use useGlobalArchivedSearch. It searches one object at a time, so objectName is singular:
useGlobalArchivedSearch(query, { objectName: "contacts" });Its minQueryLength defaults to 0 rather than 1, so an empty query lists everything in that object's trash. limit defaults to 50.
The imperative client is more flexible here: client.globalSearch({ q, deletedMode }) accepts "archivedOnly" for trashed records only, or "all" to search live and trashed together.
Maintaining the index
The index keeps itself in sync: writes are indexed as they happen, and a reconcile pass compares Postgres and the search engine on boot and on an interval. Two admin endpoints exist for the cases where it has drifted anyway — a restore, a backend wipe, a schema change applied while the API was down. Both require the workspace.update system permission, both act only on the authenticated tenant, and both take that tenant's id in the body as a confirmation of intent:
# Recompute the index settings (searchable / filterable / sortable / displayed)
# from the live schema. Converges settings only — a no-op when they already match.
curl -X POST "$API_URL/admin/search/reapply-settings" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"tenantId": "<your-tenant-id>"}'
# Clear the index and re-inject every record. This is the lever that touches documents.
curl -X POST "$API_URL/admin/search/full-reindex" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"tenantId": "<your-tenant-id>"}'Both answer 202 Accepted with { "success": true }. A body naming another tenant is refused with 403, a missing tenantId with 400.
full-reindex runs in the background and takes minutes on a large workspace — the response tells you it started, not that it finished. Progress and the final record count go to the API logs. While it runs, the index is being refilled, so search results are incomplete; a second call during that window is refused with 409 rather than queued. That refusal is per API instance, so if you run several replicas, treat it as protection against a double-click rather than a distributed lock.
Next steps
- Working with records — filtering, pagination, and the record lifecycle.
- React hooks — the full hook surface.
- SchemaClient — searching outside React.