Search & Discovery
Search on Ozeaon is a keyword match by name or title over four kinds of entity: organisations, projects, articles and people. It has no search index. Each kind is matched with an ilike query that returns only ids and a date. The matches are merged newest-first and sliced to the requested page, and the surviving ids are then hydrated through the same feed queries that power the public feeds. Search results therefore render with the same cards as the feeds.
Overview
Section titled “Overview”The feature is live in three places:
- The results page.
/searchreads?q=, server-renders the first page by callingsearchContentdirectly, and is markednoindex. - Infinite scroll.
SearchResultsFeedreceives that first page as a prop. It fetches later pages fromGET /api/search?q=&page=&limit=throughGenericInfiniteFeed. The route capslimitat 50 and calls the samesearchContent. - Entry points. The desktop
SearchBarandMobileSearchBarsit inTopNav.MobileSearchTriggersits inNavMobileActions.
The per-entity endpoints /api/articles/search, /api/users/search, /api/organizations/search, /api/projects/search and /api/locations/search are autocomplete endpoints for form pickers, such as author, team member and related-project inputs. They are not part of this feature.
Architecture
Section titled “Architecture”flowchart LR
SB["SearchBar / MobileSearchBar"] -->|"router.push(searchHref)"| Page["/search page.tsx"]
Page -->|"page 1"| SC["searchContent()"]
Page -->|"initial results"| Feed["SearchResultsFeed"]
Feed -->|"page 2+"| API["GET /api/search"]
API --> SC
SC -->|"ids per kind"| Hydrate["getOrganizationsFeed / getProjectsFeed /<br/>getArticlesFeed / getUsersByIds"]
All of the query logic lives in queries/search.ts. It runs against createPublicClient(), so it only sees what an anonymous visitor could see under RLS.
The raw term is trimmed, and % and \ are stripped from it before the minimum-length check:
// `%` and `\` are dropped before the length check, so a term of nothing but// wildcards can't clear it and match every row. `_` stays: titles and display// names contain it, and it only ever widens a match by a single character.const trimmed = query.trim().replace(/[%\\]/g, "");if (trimmed.length < SEARCH_MIN_QUERY_LENGTH) return [];The term is then double-quoted, with any embedded " escaped, before it goes into a PostgREST or() list. Without the quoting, a term containing ,, ( or ) would break the filter grammar.
People are matched first, on display_name or username, because their ids also let projects (owner_id) and articles (author_id) match by author. This people query is limited to SEARCH_AUTHOR_MATCH_CAP rather than to the current page, so the author set is the same on every page. Organisations, projects and articles are then matched in parallel. Each query selects only id and its own date aliased to sorted_at: created_at for organisations and people, and published_at for projects and articles. Projects and articles must also have published = true.
Merge and Slice
Section titled “Merge and Slice”Each kind is asked for take = offset + limit rows, not just one page’s worth. The global newest-first window [offset, offset + limit) can only contain rows that fall within each kind’s own top take, so this keeps paging exact. That only holds while every match predicate is page-independent, which is why the author set comes from a fixed cap.
The four match lists are concatenated and sorted by byRecency: newest first, missing dates last (matching the database’s nullsFirst: false), with an id tiebreak so the ordering is total and pages never overlap or leave gaps. There is no relevance scoring.
Hydrate
Section titled “Hydrate”The page’s ids are grouped by kind and passed to the normal feed queries. A kind with no ids on the page is skipped, because an empty ids filter on a feed query means no filter and would return the whole feed. The output is rebuilt by flatMap over the page slice, not over the hydration results, so the merged order is kept, and any id that hydration did not return is dropped.
The result type in types/search.ts is a union on kind that reuses each feed card’s row type. SearchResultsFeed switches on kind and renders the same OrganizationCard, ProjectCard, ArticleCard or UserCard that the feeds use.
URL Contract
Section titled “URL Contract”The query lives in ?q=, so results can be shared and bookmarked and work with back and forward. searchHref trims and encodes the term and collapses an empty one to a bare /search. SearchBar seeds its value from initialQuery, then ?q=, then the empty string. It only navigates; the results page runs the query. SEARCH_PATHNAME is also where the mobile nav docks its search field.
Failure Modes & Edge Cases
Section titled “Failure Modes & Edge Cases”- Wildcard-only terms. A query such as
%%%collapses to an empty string and is rejected by the length check, rather than matching every row. As a side effect, nobody can search for a literal%or\. - People results stop at the cap. People results are paged out of the same capped author set, so they run out at
SEARCH_AUTHOR_MATCH_CAPhowever deep the user scrolls. Author-based matches on projects and articles are bounded by the same cap. - Partial failure. Every match query goes through
toMatches, which logs the error and returns[]. One failing kind just disappears from the results; nothing is thrown or shown to the user. - Empty
idshazard. Hydration is skipped for kinds absent from the page (getUsersByIdsreturns early on an empty list), and an empty page returns before any hydration runs. - Id collisions. The hydration map is keyed by bare id, not by
kind + id, so it relies on UUIDs being unique across tables. - Concurrent writes. This is offset pagination, so a row inserted between page requests can shift the ranking. A static dataset pages exactly.
Operational Notes
Section titled “Operational Notes”- Each request makes two rounds. The first is the people query followed by the three parallel match queries. The second is up to four parallel hydrations.
%term%patterns cannot use a btree index, soilikecost grows with table size. The design relies on boundedlimits and id-only projections instead of indexing. There is no cache.SEARCH_PAGE_LIMIT,SEARCH_MIN_QUERY_LENGTHandSEARCH_AUTHOR_MATCH_CAPlive inconfig/constants/feeds.ts. Raising the author cap widens both author matching and reachable people results, but it lengthens thein.(...)list on every search.
Extension Points
Section titled “Extension Points”To add a new kind of result:
- Add a variant to
SearchResultItemthat wraps the entity’s existing feed row type. - Add a match query to the parallel block, selecting
id, sorted_at:<date>and using the sharedor()helper. AddbyAuthor(...)if the entity has an owner. - Add a hydration branch guarded by
ids.length > 0, and arenderResultcase inSearchResultsFeed.
byRecency and the final flatMap don’t depend on kind, so the ordering logic needs no changes. Searchable columns are declared per query, so no central list needs updating.
Related Links
Section titled “Related Links”- Search components
- Articles reader, Projects and Organisations for the feed queries reused during hydration
- API routes
queries/search.ts,app/api/search/route.ts,components/search/