BETTER-CONVEX

Filters

Full-text search and complex query patterns.

In this guide, we'll explore advanced querying in Convex. You'll learn to implement full-text search with relevance scoring, use predicate where for complex filtering (with explicit .withIndex(...)), and combine multiple queries with UNION and JOIN patterns.

Overview

Convex provides powerful querying capabilities beyond basic filtering:

FeatureDescription
Full-text searchSearch string fields with relevance scoring
Predicate filtersComplex filtering with explicit .withIndex(...)
UNION patternsCombine multiple queries into one paginated result
JOIN patternsPaginate through related documents
DISTINCTGet unique values efficiently

Let's explore each one.

Filtering + Pagination Compatibility

Pagination and filtering are tightly coupled. We'll start with a decision table, then walk through each mode with examples.

Query modeIndex requirementPagination supportRelation-aware filteringOrdering
findMany({ where: object })Optional. Planner uses indexes when possible; otherwise it runs a bounded scan. Some post-fetch operators are typed to require .withIndex(...).limit/offset, cursor + limitYes (where: { relation: ... })orderBy supported; first field drives cursor stability
findMany({ where: (table, ops) => ... })Optional. Planner uses indexes when possible; otherwise it runs a bounded scan. Some post-fetch operators are typed to require .withIndex(...).limit/offset, cursor + limitNo (base-table expression only)orderBy supported; first field drives cursor stability
findMany({ where: (_table, { predicate }) => predicate(...) })Required explicit .withIndex(name, range?)limit/offset, cursor + limit, optional maxScan (cursor mode)No (predicate replaces object relation filter path)Index/stream-backed; first order field matters for cursor mode
findMany({ search })Required searchIndexlimit/offset, cursor + limitNo relation filters (base-table object where only)Relevance order only (orderBy unsupported)
findMany({ vectorSearch })Required vectorIndex + configured providervectorSearch.limit only (no cursor, offset, top-level limit)NoSimilarity order only
select().<composition>.paginate({ cursor, limit })Schema + index/range path per union source (or inferred base path)cursor + limit (+ optional endCursor, maxScan) or non-cursorManual via composition chain (filter, map, flatMap, distinct, union, interleaveBy)Stream-backed index order

How to choose

Use this sequence:

  1. Need relevance-ranked text search? Use search.
  2. Need vector similarity? Use vectorSearch.
  3. Need relation-aware filtering? Use object where.
  4. Need Drizzle callback syntax for base-table filters? Use callback where.
  5. Need custom JS predicate logic? Use callback where + predicate(...) with explicit .withIndex(...).
  6. Need advanced pre-pagination composition (union/interleave/map/filter/flatMap/distinct)? Use the select() composition chain.

Note: The ORM supports search via findMany({ search: { index, query, filters? } }). Function where forms (callback and predicate) cannot be combined with search mode.

Select Composition Filtering (Advanced)

Most filtering should stay in plain findMany. Use select() composition when you need stream-style transforms before pagination.

This example uses a union + interleave (internal merged-stream semantics), then applies filter and map before returning the page:

convex/functions/messages.ts
import { z } from 'zod';
import { publicQuery } from '../lib/crpc';

const MessageSchema = z.object({
  id: z.string(),
  from: z.string(),
  to: z.string(),
  body: z.string(),
  deletedAt: z.number().nullable(),
});

export const listConversation = publicQuery
  .input(z.object({ me: z.string(), them: z.string() }))
  .paginated({ limit: 20, item: MessageSchema })
  .query(async ({ ctx, input }) => {
    return await ctx.orm.query.messages
      .withIndex('by_from_to')
      .select()
      .union([
        { where: { from: input.me, to: input.them } },
        { where: { from: input.them, to: input.me } },
      ])
      .interleaveBy(['createdAt', 'id'])
      .filter(async (m) => !m.deletedAt)
      .map(async (m) => ({ ...m, body: m.body.slice(0, 240) }))
      .paginate({
        cursor: input.cursor,
        limit: input.limit,
        maxScan: 500,
      });
  });

If you need page-boundary pinning in reactive UIs, pass endCursor on the follow-up call.

Full-text search enables searching string fields with relevance scoring. Each search index can search ONE field, with optional filter fields for equality filtering.

Schema Definition

Let's start by adding search indexes to your schema. Use the naming pattern search_<searchField>:

convex/functions/schema.ts
import { convexTable, defineSchema, searchIndex, text } from 'better-convex/orm';

export const articles = convexTable(
  'articles',
  {
    title: text().notNull(),
    content: text().notNull(),
    author: text().notNull(),
    category: text().notNull(),
  },
  (t) => [
    // Search content with category/author as filter fields
    searchIndex('search_content').on(t.content).filter(t.category, t.author),
    // Separate index for title search
    searchIndex('search_title').on(t.title),
  ]
);

export const tables = { articles };
export default defineSchema(tables, { strict: false });

Note: Each search index can only search ONE field. Add filterFields for fast equality filtering before text search.

Now let's implement a basic search query:

convex/functions/articles.ts
import { z } from 'zod';
import { publicQuery } from '../lib/crpc';

export const search = publicQuery
  .input(z.object({
    query: z.string(),
    limit: z.number().min(1).max(100).default(10),
  }))
  .output(z.array(z.object({
    id: z.string(),
    title: z.string(),
    content: z.string(),
  })))
  .query(async ({ ctx, input }) => {
    return await ctx.orm.query.articles.findMany({
      search: {
        index: 'search_content',
        query: input.query,
      },
      limit: input.limit,
    });
  });

Important: Always use limit or cursor pagination (cursor + limit) with search queries to limit results.

Search with Filters

Filter fields enable fast pre-filtering before text search. Only equality filters are supported:

convex/functions/articles.ts
export const searchByCategory = publicQuery
  .input(z.object({
    query: z.string(),
    category: z.string(),
    author: z.string().optional(),
  }))
  .output(z.array(z.object({
    id: z.string(),
    title: z.string(),
    content: z.string(),
    category: z.string(),
  })))
  .query(async ({ ctx, input }) => {
    return await ctx.orm.query.articles.findMany({
      search: {
        index: 'search_content',
        query: input.query,
        filters: {
          category: input.category,
          ...(input.author ? { author: input.author } : {}),
        },
      },
      limit: 20,
    });
  });

Use .paginated({ limit, item }) (paginated builder) for cursor-based pagination:

convex/functions/articles.ts
const ArticleSchema = z.object({
  id: z.string(),
  title: z.string(),
  content: z.string(),
  category: z.string(),
});

export const searchPaginated = publicQuery
  .input(z.object({
    query: z.string(),
    category: z.string().optional(),
  }))
  .paginated({ limit: 20, item: ArticleSchema })
  .query(async ({ ctx, input }) => {
    return await ctx.orm.query.articles.findMany({
      search: {
        index: 'search_content',
        query: input.query,
        filters: input.category ? { category: input.category } : undefined,
      },
      cursor: input.cursor,
      limit: input.limit,
    });
  });

Tip: Search results are automatically sorted by relevance.

Callback where (Drizzle Style)

Use callback where when you want Drizzle-like expression syntax with typed operators:

convex/functions/users.ts
const admins = await db.query.users.findMany({
  where: (users, { and, eq, isNotNull }) =>
    and(eq(users.role, 'admin'), isNotNull(users.email)),
});

This mode compiles through the same expression planner as object where, so it can use indexes when possible.

Predicate where (Explicit Index Required)

Use a predicate where for complex JS logic. You must provide an explicit .withIndex(...).

Disclaimer: Use this while prototyping or when you know the query won't need to scale.
If you need to scale, prefer index-compiled filters and explicit index design.

convex/functions/characters.ts
const CharacterSchema = z.object({
  id: z.string(),
  name: z.string(),
  categories: z.array(z.string()).nullable(),
  score: z.number(),
});

export const searchCharacters = publicQuery
  .input(z.object({
    category: z.string().optional(),
    minScore: z.number().optional(),
  }))
  .paginated({ limit: 20, item: CharacterSchema })
  .query(async ({ ctx, input }) => {
    return await ctx.orm.query.characters
      .withIndex('private', (q) => q.eq('private', false))
      .findMany({
        where: (_characters, { predicate }) =>
          predicate((char) => {
            if (input.category && !char.categories?.includes(input.category)) {
              return false;
            }
            if (input.minScore && char.score < input.minScore) {
              return false;
            }
            return true;
          }),
        cursor: input.cursor,
        limit: input.limit,
      });
  });

Tip: Prefer index‑friendly object where for most queries. Use predicate where only when you need complex JS logic and can anchor it with .withIndex(...).

Search vs Predicate where

Understanding when to use each approach is critical. Here's a comparison:

FeatureSearchPredicate where
Text matchingFull-text search with relevanceNo text search
Complex filtersfilterFields only (equality)Full TypeScript
Range queriesNoYes
PaginationStandardWorks with cursor pagination (cursor + limit)
UNION/JOINNoManual merge in app code
PerformanceOptimized for textDepends on chosen index + predicate selectivity
CombinedSupported with base-field object wherePredicate / relation where still unsupported

Choosing the Right Approach

  1. Need text search? → Use search indexes
  2. Need complex per-row JS logic? → Use predicate where + explicit .withIndex(...)
  3. Need range queries (greater than, less than)? → Use object/callback expression where (index-compiled when possible)
  4. Need UNION or JOIN operations? → Merge in application code
  5. Need text search + relation/predicate filters? → See options below

Combining Search and Complex Filters

Search mode supports search.filters plus base-table object where. For predicate where or relation-based where, use one of these patterns.

Option 1: Add more filterFields (recommended)

Add equality filters to your search index:

searchIndex('search_content')
  .on(t.content)
  .filter(t.category, t.author, t.status, t.dateGroup);

Option 2: Separate query paths

Use search when text is provided, predicate filtering otherwise:

export const searchOrFilter = publicQuery
  .input(z.object({
    query: z.string().optional(),
    category: z.string().optional(),
    startDate: z.number().optional(),
  }))
  .paginated({ limit: 20, item: ArticleSchema })
  .query(async ({ ctx, input }) => {
    if (input.query) {
      // Text search path - limited filtering
      return await ctx.orm.query.articles.findMany({
        search: {
          index: 'search_content',
          query: input.query!,
          filters: input.category ? { category: input.category } : undefined,
        },
        cursor: input.cursor,
        limit: input.limit,
      });
    }

    // Predicate where path - full filtering power with explicit .withIndex(...)
    return await ctx.orm.query.articles
      .withIndex('by_creation_time')
      .findMany({
        where: (_articles, { predicate }) =>
          predicate((article) => {
            if (input.category && article.category !== input.category) return false;
            if (input.startDate && article.publishedAt < input.startDate) return false;
            return true;
          }),
        cursor: input.cursor,
        limit: input.limit,
      });
  });

Option 3: Post-process search results (small datasets only)

// Only viable for small result sets
const searchResults = await ctx
  .orm.query.articles.findMany({
    search: { index: 'search_content', query },
    limit: 100, // Hard limit
  });

const filtered = searchResults.filter((article) =>
  article.publishedAt >= startDate && article.publishedAt <= endDate
);

Performance Considerations

Understanding performance is critical for production apps.

Search Performance

  • Search indexes have overhead - don't over-index
  • Use filterFields to narrow results before text matching
  • Always limit results with limit or cursor pagination (cursor + limit)

Predicate where Performance

Predicate where runs post‑fetch against an explicit index stream. Use maxScan to cap scan size:

const results = await db.query.posts
  .withIndex('by_created_at')
  .findMany({
    where: (_posts, { predicate }) => predicate((post) => expensiveCheck(post)),
    cursor: input.cursor,
    limit: input.limit,
    maxScan: 500,
  });

Warning: Predicate filters can scan a large portion of a table. Keep page sizes small and use indexes when possible.

Cursor Stability

Cursor pagination is only stable when you reuse the same where predicate and orderBy:

const page1 = await db.query.messages
  .withIndex('by_creation_time')
  .findMany({
    where: (_messages, { predicate }) => predicate((m) => m.read),
    orderBy: { createdAt: 'desc' },
    cursor: null,
    limit: 20,
  });

Memory Efficiency

  • Prefer cursor pagination (cursor + limit) for user‑facing lists
  • Keep limits small for non‑paginated queries
  • Use selective index ranges for predicate where and cap reads with maxScan

Best Practices

Here are key practices to follow when using filters and search.

  1. Create appropriate indexes - Each search field needs its own search index
  2. Limit results - Always use limit or cursor pagination (cursor + limit)
  3. Use filterFields - Pre-filter with equality before text search
  4. Bound predicate scans - Use explicit .withIndex(...) and cap reads with maxScan
  5. Normalize search terms - Consider lowercase for consistent matching
  6. Handle empty queries - Decide behavior when search query is empty
  7. Prefer cursor pagination - Use findMany({ cursor: null, limit: 20 }) for large lists

That's it. You now have all the tools for full-text search, predicate filtering, and advanced query composition in the ORM.

Next Steps

On this page