Convex provides built-in full-text search capabilities through search indexes. Search queries automatically rank results by relevance and support filtering on additional fields.
Search indexes
Define search indexes in your schema to enable full-text search:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
posts: defineTable({
title: v.string(),
body: v.string(),
category: v.string(),
authorId: v.string(),
})
.searchIndex("search_title", {
searchField: "title",
filterFields: ["category", "authorId"],
})
.searchIndex("search_body", {
searchField: "body",
filterFields: ["category"],
}),
});
A search index requires:
The field to perform full-text search on. Must be a string field.
Additional fields to filter on using equality filters. These can be any Convex value type.
Querying with search
Use .withSearchIndex() to perform full-text search queries:
import { query } from "./_generated/server";
import { v } from "convex/values";
export const searchPosts = query({
args: { searchQuery: v.string() },
handler: async (ctx, args) => {
const results = await ctx.db
.query("posts")
.withSearchIndex("search_title", (q) => q.search("title", args.searchQuery))
.take(20);
return results;
},
});
Search filter builder
The SearchFilterBuilder is provided to construct search queries:
search
Search for terms in the indexed field:
.withSearchIndex("search_title", (q) => q.search("title", searchQuery))
The name of the field to search in. Must match the index’s searchField.
The query text to search for.
How search works:
- Returns results where any word of the query appears in the field
- Results are ranked by relevance considering:
- How many words in the query appear in the text?
- How many times do they appear?
- How long is the text field?
eq (equality filter)
Restrict search results to documents where a filter field equals a value:
.withSearchIndex("search_title", (q) =>
q.search("title", searchQuery).eq("category", "tech")
)
The name of the field to compare. Must be listed in the search index’s filterFields.
The value to compare against. Type must match the field type.
You can chain multiple .eq() calls:
.withSearchIndex("search_title", (q) =>
q.search("title", searchQuery)
.eq("category", "tech")
.eq("authorId", userId)
)
Search results
Search queries return documents in relevance order - the most relevant results appear first. The relevance algorithm considers:
- Term frequency - How often query words appear in the document
- Document length - Shorter documents with matches rank higher
- Coverage - Documents matching more query words rank higher
Taking results
Use .take() to limit results:
const results = await ctx.db
.query("posts")
.withSearchIndex("search_title", (q) => q.search("title", query))
.take(50); // Get top 50 most relevant results
Search supports pagination for better performance:
import { query } from "./_generated/server";
import { v } from "convex/values";
export const searchPaginated = query({
args: {
searchQuery: v.string(),
paginationOpts: v.object({
numItems: v.number(),
cursor: v.union(v.string(), v.null()),
}),
},
handler: async (ctx, args) => {
const results = await ctx.db
.query("posts")
.withSearchIndex("search_title", (q) => q.search("title", args.searchQuery))
.paginate(args.paginationOpts);
return results;
},
});
Common patterns
Search with category filter
export const searchPostsByCategory = query({
args: {
query: v.string(),
category: v.string(),
},
handler: async (ctx, args) => {
return await ctx.db
.query("posts")
.withSearchIndex("search_title", (q) =>
q.search("title", args.query).eq("category", args.category)
)
.take(30);
},
});
Search with multiple filters
export const searchUserPosts = query({
args: {
query: v.string(),
userId: v.string(),
category: v.optional(v.string()),
},
handler: async (ctx, args) => {
let search = ctx.db
.query("posts")
.withSearchIndex("search_title", (q) => {
let builder = q.search("title", args.query).eq("authorId", args.userId);
if (args.category) {
builder = builder.eq("category", args.category);
}
return builder;
});
return await search.take(50);
},
});
Search across multiple fields
Create separate indexes for different fields and combine results:
export const searchEverywhere = query({
args: { query: v.string() },
handler: async (ctx, args) => {
// Search in titles
const titleResults = await ctx.db
.query("posts")
.withSearchIndex("search_title", (q) => q.search("title", args.query))
.take(10);
// Search in body
const bodyResults = await ctx.db
.query("posts")
.withSearchIndex("search_body", (q) => q.search("body", args.query))
.take(10);
// Combine and deduplicate results
const allResults = [...titleResults];
const seenIds = new Set(titleResults.map((p) => p._id));
for (const post of bodyResults) {
if (!seenIds.has(post._id)) {
allResults.push(post);
seenIds.add(post._id);
}
}
return allResults;
},
});
Empty search query
Return all documents (filtered) when search query is empty:
export const searchOrList = query({
args: {
query: v.string(),
category: v.optional(v.string()),
},
handler: async (ctx, args) => {
if (args.query.trim() === "") {
// No search query - use regular index
let q = ctx.db.query("posts");
if (args.category) {
q = q.withIndex("by_category", (q) => q.eq("category", args.category));
}
return await q.order("desc").take(50);
}
// Has search query - use search index
let search = ctx.db
.query("posts")
.withSearchIndex("search_title", (q) => {
let builder = q.search("title", args.query);
if (args.category) {
builder = builder.eq("category", args.category);
}
return builder;
});
return await search.take(50);
},
});
Search best practices
- Define indexes in schema - Search indexes must be defined in
convex/schema.ts before use.
- Limit results with
.take() - Don’t use .collect() on search queries, as result sets can be large.
- Use filter fields - Add
filterFields to search indexes for common filters like category or author.
- Consider multiple indexes - Create separate search indexes for different fields (title, body, etc.).
- Results are already ordered - Search results come in relevance order, don’t apply additional
.order().
- Prefer search over
.filter() for text - Full-text search is much more efficient than filtering with string contains.
- Handle empty queries - Decide whether empty search strings should return all results or none.
Limitations
- One search per query - You can only use
.withSearchIndex() once per query. To search multiple fields, run separate queries and combine results.
- Equality filters only - Search indexes only support
.eq() filters, not range queries or other comparisons.
- No ordering - Results are always in relevance order. You cannot apply
.order() to search results.
- Filter fields must be in index - You can only filter on fields listed in the index’s
filterFields array.
When to use search vs regular indexes
Use search indexes when:
- Searching for words or phrases in text content
- You want relevance-ranked results
- Users type free-form search queries
Use regular indexes when:
- Filtering by exact values (IDs, enums, booleans)
- Range queries (dates, numbers)
- You need specific ordering (newest first, alphabetical)
- Querying structured data, not text content