Skip to main content

Overview

The search module provides full-text search capabilities powered by PostgreSQL’s tsvector and a custom search_prompts database function. Search queries are tokenized and matched against prompt titles, descriptions, and content.

searchPrompts

Performs a full-text search across prompts with optional filtering. Location: src/features/search/actions.ts:15
export async function searchPrompts(
  query: string,
  options?: {
    userId?: string;
    collectionId?: string;
    archived?: boolean;
  }
): Promise<{ data: SearchResult[] | null; error: string | null }>

Parameters

query
string
required
Search query string (1-500 characters). Searches across prompt titles, descriptions, and content.
options
object
Optional filters to narrow search results
options.userId
string
UUID of a user to filter results by ownership. If provided, only prompts owned by this user are returned.
options.collectionId
string
UUID of a collection to filter results. If provided, only prompts in this collection are returned.
options.archived
boolean
Whether to include archived prompts. If true, returns only archived prompts. If false, returns only non-archived prompts. If omitted, returns both.

Returns

result
object
data
SearchResult[] | null
Array of matching prompts, or null if an error occurred. Returns empty array [] if query is empty.
error
string | null
Error message if search failed, otherwise null

SearchResult Type

Each search result contains:
SearchResult
object
id
string
Prompt UUID
user_id
string
UUID of the prompt owner
title
string
Prompt title
description
string | null
Prompt description (may be null)
archived_at
string | null
ISO timestamp when prompt was archived, or null if not archived
is_public
boolean
Whether the prompt is publicly accessible
created_at
string
ISO timestamp when prompt was created
updated_at
string
ISO timestamp when prompt was last updated
latest_content
string
Content from the most recent version
latest_version_id
string
UUID of the latest version
collection_ids
string[]
Array of collection UUIDs this prompt belongs to
rank
number
Relevance score from PostgreSQL full-text search. Higher values indicate better matches.

Validation

Input is validated using the searchSchema Zod schema:
const searchSchema = z.object({
  query: z.string().min(1).max(500),
  userId: z.string().uuid().optional(),
  collectionId: z.string().uuid().optional(),
  archived: z.boolean().optional(),
});
Validation errors return:
{ data: null, error: 'Invalid search query or parameters' }

Examples

import { searchPrompts } from '@/features/search/actions';

const { data, error } = await searchPrompts('code review');

if (error) {
  console.error('Search failed:', error);
} else if (data) {
  console.log(`Found ${data.length} prompts`);
  data.forEach(result => {
    console.log(`- ${result.title} (rank: ${result.rank})`);
  });
}

Search with filters

import { searchPrompts } from '@/features/search/actions';

// Search only non-archived prompts in a specific collection
const { data, error } = await searchPrompts('debugging', {
  collectionId: 'collection-uuid',
  archived: false,
});

if (data) {
  console.log(`Found ${data.length} active prompts in collection`);
}

Search user’s prompts

import { searchPrompts } from '@/features/search/actions';
import { createClient } from '@/lib/supabase/server';
import { cookies } from 'next/headers';

const cookieStore = await cookies();
const supabase = createClient(cookieStore);
const { data: { user } } = await supabase.auth.getUser();

if (user) {
  const { data, error } = await searchPrompts('api', {
    userId: user.id,
  });
  
  console.log(`Found ${data?.length ?? 0} of your prompts`);
}

Behavior

  • Empty query: Returns { data: [], error: null } without performing a database query
  • Case-insensitive: Search is case-insensitive
  • Ranking: Results are ordered by relevance (PostgreSQL ts_rank)
  • Database function: Delegates to the search_prompts RPC function in PostgreSQL
  • Authentication: Does not require authentication, but typically used with userId filter to scope results

Database Implementation

The search is powered by a PostgreSQL RPC function:
CREATE FUNCTION search_prompts(
  query_text TEXT,
  filter_user_id UUID DEFAULT NULL,
  filter_collection_id UUID DEFAULT NULL,
  filter_archived BOOLEAN DEFAULT NULL
) RETURNS TABLE (...)
The function uses:
  • to_tsquery() for query parsing
  • tsvector column (search_tokens) for indexed full-text search
  • ts_rank() for relevance scoring
  • A trigger (update_prompt_search_tokens) that automatically updates the search index when prompts or versions change

Performance

  • Search queries are fast due to GIN index on the search_tokens column
  • The search_tokens tsvector is automatically maintained by a database trigger
  • Results are limited by the database function (typically to prevent excessive result sets)

Error Handling

Possible error scenarios:
  1. Validation failure: Invalid UUID format or query length
    { data: null, error: 'Invalid search query or parameters' }
    
  2. Database error: Connection issues or query execution failure
    { data: null, error: 'Search failed: <database error message>' }
    
  3. Empty query: Not treated as error
    { data: [], error: null }
    
Database errors are logged to the server console for debugging:
console.error('Search error:', error);

Security

  • Search respects Row-Level Security (RLS) policies in Supabase
  • Users can only search prompts they have access to (owned by them or public)
  • API key authentication (for MCP) automatically scopes search to the key owner’s prompts

Integration with UI

Typical usage in a search component:
'use client';

import { useState } from 'react';
import { searchPrompts } from '@/features/search/actions';
import type { SearchResult } from '@/features/search/types';

export function SearchBar() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [loading, setLoading] = useState(false);

  const handleSearch = async (q: string) => {
    setQuery(q);
    if (!q.trim()) {
      setResults([]);
      return;
    }

    setLoading(true);
    const { data, error } = await searchPrompts(q);
    setLoading(false);

    if (error) {
      console.error(error);
      return;
    }

    setResults(data ?? []);
  };

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search prompts..."
      />
      {loading && <div>Searching...</div>}
      <ul>
        {results.map((result) => (
          <li key={result.id}>
            <a href={`/prompts/${result.id}`}>{result.title}</a>
            <span>Relevance: {result.rank.toFixed(2)}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

MCP Integration

The search action is exposed via the Model Context Protocol at /api/mcp as the search_prompts tool:
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "search_prompts",
    "arguments": {
      "query": "api documentation",
      "archived": false
    }
  },
  "id": 1
}
See the MCP Overview for more details.

Build docs developers (and LLMs) love