Skip to main content

searchCards

Performs full-text search across card content, metadata, and AI-generated fields with advanced filtering capabilities.
import { useQuery } from "convex/react";
import { api } from "@teak/convex";

function SearchResults() {
  const results = useQuery(api.card.getCards.searchCards, {
    searchQuery: "design system",
    types: ["link", "image"],
    favoritesOnly: false,
    limit: 50
  });

  return (
    <div>
      {results?.map(card => (
        <CardItem key={card._id} card={card} />
      ))}
    </div>
  );
}

Arguments

searchQuery
string
Full-text search query. Searches across multiple fields:
  • Card content
  • Notes
  • AI summary
  • AI transcript (audio/video cards)
  • Metadata title (link cards)
  • Metadata description (link cards)
  • User tags
  • AI tags
Special keywords:
  • "fav", "favs", "favorites" - Returns all favorited cards
  • "trash", "deleted", "bin" - Returns all deleted cards
If omitted, returns cards based on other filters only.
types
CardType[]
Filter by one or more card types.
types: ["link", "image", "video"]
Available types: text, link, image, video, audio, document, palette, quote
favoritesOnly
boolean
default:false
If true, returns only cards marked as favorites.
showTrashOnly
boolean
default:false
If true, returns only soft-deleted cards. Mutually exclusive with normal card queries.
styleFilters
string[]
Filter by AI-detected visual styles. Only applicable to image and palette cards.
styleFilters: ["minimal", "modern", "vintage"]
Common values: "minimal", "modern", "vintage", "abstract", "geometric", etc.
hueFilters
string[]
Filter by color hue categories.
hueFilters: ["red", "blue", "green"]
Available hues: "red", "orange", "yellow", "green", "blue", "purple", "pink", "brown", "gray", "black", "white"
hexFilters
string[]
Filter by exact hex color codes.
hexFilters: ["#FF5733", "#33FF57"]
Useful for finding cards with specific brand colors.
createdAtRange
object
Filter by creation date range.
createdAtRange: {
  start: new Date("2024-01-01").getTime(),
  end: new Date("2024-12-31").getTime()
}
limit
number
default:50
Maximum number of cards to return. Results are sorted by creation date (newest first).

Returns

cards
Card[]
Array of cards matching the search criteria. Each card includes:
  • All standard card fields
  • Auto-generated file URLs (fileUrl, thumbnailUrl, etc.)
  • Quote formatting applied for display
Returns empty array if:
  • User is not authenticated
  • No cards match the filters
  • Search query has no results

Search Behavior

Uses Convex search indexes for efficient full-text search:
// Example: Content search
ctx.db
  .query("cards")
  .withSearchIndex("search_content", (q) =>
    q.search("content", searchQuery)
     .eq("userId", user.subject)
     .eq("isDeleted", showTrashOnly ? true : undefined)
  )
  .take(limit)
Each search index filters by:
  • userId - Only current user’s cards
  • isDeleted - Normal vs. deleted cards
  • type (if single type filter)
  • isFavorited (if favorites only)
When visual filters are present (styles, hues, or hex colors):
  1. Uses dedicated facet indexes: search_visual_styles, search_color_hexes, search_color_hues
  2. Runs parallel queries for each filter type
  3. Intersects results to find cards matching all filters
  4. Over-fetches (limit * 3) to account for filtering
// Example: Color hue filter
ctx.db
  .query("cards")
  .withSearchIndex("search_color_hues", (q) =>
    q.search("colorHues", "red")
     .eq("userId", user.subject)
  )
  .take(limit * 3)
When createdAtRange is specified:
  1. Uses by_created index for efficient range queries
  2. Applies deletion filter via .filter() (not index)
  3. Type filters applied as OR conditions in post-query filter
ctx.db
  .query("cards")
  .withIndex("by_created", (q) =>
    q.eq("userId", user.subject)
     .gte("createdAt", range.start)
     .lt("createdAt", range.end)
  )
Certain search terms trigger optimized queries:
  • Favorites: "fav", "favs", "favorites", "favourite", "favourites"
    • Uses by_user_favorites_deleted index
    • Returns all favorited cards regardless of content
  • Trash: "trash", "deleted", "bin", "recycle", "trashed"
    • Uses by_user_deleted index with isDeleted: true
    • Returns all deleted cards
These keywords bypass normal search and apply other filters on top.
When searching across multiple indexes:
  1. Results from all searches are combined into a flat array
  2. Duplicates are removed using a Map keyed by card ID
  3. Additional filters (type, favorites, date range) are applied
  4. Results are sorted by createdAt descending
  5. Limited to requested limit
const uniqueResults = Array.from(
  new Map(allResults.map(card => [card._id, card])).values()
);

searchCardsPaginated

Paginated version of searchCards for loading large result sets incrementally.
import { useQuery } from "convex/react";
import { api } from "@teak/convex";
import { useState } from "react";

function InfiniteCardList() {
  const [cursor, setCursor] = useState<string | null>(null);
  
  const result = useQuery(api.card.getCards.searchCardsPaginated, {
    paginationOpts: {
      numItems: 20,
      cursor: cursor
    },
    searchQuery: "design",
    types: ["link", "image"]
  });

  const loadMore = () => {
    if (result && !result.isDone) {
      setCursor(result.continueCursor);
    }
  };

  return (
    <div>
      {result?.page.map(card => (
        <CardItem key={card._id} card={card} />
      ))}
      {!result?.isDone && (
        <button onClick={loadMore}>Load More</button>
      )}
    </div>
  );
}

Arguments

paginationOpts
object
required
Pagination configuration object.
All other arguments are the same as searchCards:
  • searchQuery, types, favoritesOnly, showTrashOnly
  • styleFilters, hueFilters, hexFilters
  • createdAtRange

Returns

result
object
Pagination result object.

Pagination Behavior

For visual filters and search queries:
  1. Cursor is a stringified offset number (e.g., "0", "20", "40")
  2. First page: cursor: null → starts at offset 0
  3. Next page: cursor: "20" → starts at offset 20
  4. Cursor increments by numItems each page
// Internal implementation
const rawCursor = paginationOpts.cursor ?? "0";
const offset = Number(rawCursor) || 0;
const page = results.slice(offset, offset + numItems);
const continueCursor = isDone ? null : String(offset + numItems);
When using Convex indexes (no search query, no visual filters):Uses native Convex pagination:
const result = await query
  .order("desc")
  .paginate(paginationOpts);
Cursor is opaque and managed by Convex internally.
For search queries with text search:
  1. Runs all search index queries in batches
  2. Deduplicates results incrementally
  3. Applies filters and sorting
  4. Slices to offset and page size
  5. Returns cursor as stringified offset
Early termination: Stops searching once enough results are found:
if (uniqueResults.length >= desiredLimit) break;
The function adjusts fetch limits based on page size:
  • Search queries: searchLimit = max(offset + pageSize + 20, pageSize)
  • Visual filters: limit = max(desiredLimit * 2, desiredLimit + 40)
  • Tag searches: tagSearchLimit = max(searchLimit + 20, searchLimit + 1)
This over-fetching compensates for filtering and ensures full pages.

Example: Infinite Scroll

import { useQuery } from "convex/react";
import { api } from "@teak/convex";
import { useState } from "react";

function InfiniteScrollCards() {
  const [allCards, setAllCards] = useState<Card[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  
  const result = useQuery(
    !isLoading ? api.card.getCards.searchCardsPaginated : undefined,
    !isLoading ? {
      paginationOpts: { numItems: 50, cursor },
      searchQuery: searchTerm,
      types: selectedTypes
    } : "skip"
  );
  
  useEffect(() => {
    if (result) {
      setAllCards(prev => [...prev, ...result.page]);
      setIsLoading(false);
    }
  }, [result]);
  
  const loadMore = () => {
    if (result && !result.isDone) {
      setIsLoading(true);
      setCursor(result.continueCursor);
    }
  };
  
  // Trigger load more on scroll
  useEffect(() => {
    const handleScroll = () => {
      const bottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 500;
      if (bottom && !isLoading && result && !result.isDone) {
        loadMore();
      }
    };
    
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
  }, [isLoading, result]);
  
  return (
    <div>
      {allCards.map(card => <CardItem key={card._id} card={card} />)}
      {isLoading && <LoadingSpinner />}
      {result?.isDone && <div>No more cards</div>}
    </div>
  );
}

Source References

  • searchCards: packages/convex/card/getCards.ts:96
  • searchCardsPaginated: packages/convex/card/getCards.ts:400

Build docs developers (and LLMs) love