ShipKit docs
The stack

Search

pg_trgm trigram search baked into Postgres — fuzzy matching with no extra service to run.

Search ships as pg_trgm trigram indexes on Postgres. No separate service, no extra infra, fast enough for ~1M rows. Migration 0008_pg_trgm.sql enables the extension and creates GIN indexes on the columns that get searched (user name/email, audit action/resource, post title/slug).

Helpers

packages/db/src/search.ts exposes typed wrappers:

  • searchUsers(q, { limit }) — name + email (admin directory search)
  • searchAuditLog(q, { userId, limit }) — action + resource (scoped to the caller)
  • searchPosts(q, { userId, limit }) — title + slug (scoped to the caller)
  • trigramSearchClause(columns, q) — drop-in WHERE builder for ad-hoc queries

Each result row carries a rank field (the strongest column-level similarity() score), so callers can pick a threshold or display "best match first".

import { searchUsers } from "@workspace/db";

const hits = await searchUsers("alise", { limit: 10 });

API

GET /api/v1/search?q=<term>&type=users|audit|posts&limit=25

The whole route requires the user:list permission (admin/moderator). users matches name + email across the directory; audit and posts are scoped to the caller's userId. Empty q returns an empty result set rather than dumping the table.

UI

The admin app has a working search box at /users that calls the endpoint. Mirror the pattern (debounced input + ranked list) for any other entity you want to search.

When to upgrade

pg_trgm is great until one of these starts hurting:

  • More than ~1M rows in the searched table — index size and query time both grow.
  • You need real typo tolerance with edit-distance scoring (e.g. "tehre" ↔ "there").
  • You want stemming or multi-language ranking ("running" matching "run").
  • You want a single search box across many entity types with relevance tuning.

At that point bring in Meilisearch (or Typesense) as a sidecar. The recipe is:

  1. Index user-scoped documents on write (background job in apps/worker).
  2. Replace searchUsers etc. with calls to the search service while keeping the same return shape.
  3. Keep pg_trgm as the fallback so dev works without the extra service running.

We deliberately don't ship Meilisearch in the box — most apps never need it, and adding a second moving part by default makes the boilerplate harder to deploy.

On this page