Build This Now
Build This Now
Was ist der Claude Code?Claude Code installierenClaude Code Native InstallerDein erstes Claude Code-Projekt
Claude Code v2.1.122 Release NotesClaude Code Dynamic Workflows: 1.000 Subagents auf einer echten Codebase orchestrierenClaude Code Best PracticesClaude Opus 4.7 Best PracticesClaude Code auf einem VPSGit-IntegrationClaude Code ReviewClaude Code WorktreesClaude Code Remote ControlClaude Code ChannelsChannels, Routines, Teleport, DispatchGeplante Aufgaben mit Claude CodeClaude Code BerechtigungenClaude Code Auto-ModusStripe-Zahlungen mit Claude Code einbauenFeedback-LoopsTodo-WorkflowsClaude Code TasksProjekt-TemplatesClaude Code Preise und Token-NutzungClaude Code Preise: Was du wirklich zahlstClaude Code Ultra ReviewEine Next.js-App mit Claude Code bauenClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeSo baust du ein SaaS-MVP mit Claude CodeAuthentifizierung mit Claude Code einbauen (Supabase Auth)Transaktions-E-Mails mit Claude Code einbauen (Resend + React Email)Eine typsichere API mit Claude Code bauen (oRPC + Zod)How to Add File Uploads With Claude Code (Supabase Storage)How to Run Background Jobs with Claude Code and InngestHow to Build an Admin Dashboard With Claude CodeHow to Add Full-Text Search With Claude Code (Postgres)Agentic Commerce: Wie du eine App baust, für die KI-Agents bezahlen könnenClaude Code 1M Context in Practice: When Bigger Isn't BetterClaude Code GitHub Actions Setup Guide (@claude + Cron)Claude Code Headless Mode: The Definitive Guide to claude -pClaude Code Max Plan vs API Cost: Break-Even GuideClaude Code Prompt Caching: The Token Discount Most People Never Turn OnWas es 2026 kostet, ein SaaS mit Claude Code zu bauenRun a Team of AI Agents in Parallel with Git WorktreesPrompt Injection in Coding Agents: How to Not Get Pwned
speedy_devvkoen_salo
Blog/Handbook/Workflow/How to Add Full-Text Search With Claude Code (Postgres)

How to Add Full-Text Search With Claude Code (Postgres)

Build fast in-app search without a new service. Postgres tsvector columns, GIN indexes, ranking, a debounced React UI, and RLS-safe results through oRPC.

Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →
speedy_devvWritten by speedy_devvPublished Jul 14, 202610 min readHandbook hubWorkflow index

Your app has a search box and it runs ILIKE '%term%' against three columns. It is slow, it cannot rank results, and it misses "running" when the user types "run". You do not need Elasticsearch to fix that. PostgreSQL has real full-text search built in, and Claude Code can wire the whole feature (migration, ranking query, API, and debounced UI) in one session.


Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →

What Full-Text Search Actually Needs

A working search feature has four moving parts, and it helps to name them before you ask Claude to build anything.

First, a tsvector column. This stores a preprocessed version of your text: lowercased, stemmed to word roots, with stop words like "the" and "and" removed. "Running" and "runs" both reduce to the lexeme "run", so a search for either finds both.

Second, a GIN index on that column. Without it, every search is a sequential scan over the whole table. With it, Postgres keeps an inverted index (lexeme to row) and lookups stay fast into the millions of rows.

Third, a ranking function. ts_rank scores each matching row by term frequency so the most relevant results sort to the top instead of coming back in insertion order.

Fourth, the plumbing: an API endpoint that runs the query, a UI that debounces keystrokes, and a guarantee that a user only ever sees rows they are allowed to see. That last part is where most hand-rolled search features leak data, so we handle it with row-level security rather than trusting the query.

The rest of this guide builds all four on top of PostgreSQL via Supabase, exposed through oRPC with Zod, and consumed by a React 19 client component in Next.js 16.

The Migration: A Generated tsvector Column

The cleanest way to store the search vector is a generated column. Postgres computes it from your source columns on every insert and update, so it can never drift out of sync and there are no triggers to maintain. The setweight calls tag the title as weight A and the body as weight B, which lets ranking favor a title match over a body match later.

Ask Claude to write this migration, and check that it uses generated always as (...) stored (not a plain column you have to populate yourself):

-- supabase/migrations/20260714000000_add_document_search.sql

alter table documents
  add column search_vector tsvector
  generated always as (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body, '')), 'B')
  ) stored;

create index documents_search_idx
  on documents
  using gin (search_vector);

Two details matter here. The coalesce(title, '') guards against null columns, because concatenating a null tsvector produces null and silently drops the row from search. And the 'english' config is a constant, which is what makes the whole expression immutable and therefore legal inside a generated column. If you pass a column as the config instead of a literal, the migration fails.

Run it against your local stack first with the Supabase CLI, then push:

supabase migration up
supabase db push

Weighting and Ranking

The weights you set in the migration only do something when you ask ts_rank to use them. You pass a weight array in the order (D, C, B, A), giving the numeric multiplier for each label. A title match (weight A) counts for much more than a body match (weight B) here.

This is the core query. websearch_to_tsquery parses the user's raw input the way a search engine does, so "quarterly report" -draft becomes an AND of "quarterly" and "report" excluding "draft", and you never have to sanitize the string yourself:

select
  id,
  title,
  ts_rank('{0.1, 0.2, 0.4, 1.0}', search_vector,
          websearch_to_tsquery('english', 'quarterly report')) as rank
from documents
where search_vector @@ websearch_to_tsquery('english', 'quarterly report')
order by rank desc
limit 20;

The @@ operator is the match test: it returns true when the tsvector contains the tsquery. Putting the same websearch_to_tsquery call in both the where clause and the select looks redundant, but Postgres evaluates it once and the GIN index handles the filter. Only the 20 rows that survive get ranked.

websearch_to_tsquery is the function you want for a user-facing box because it never throws on malformed input. Its cousins plainto_tsquery and to_tsquery are stricter: to_tsquery will raise a syntax error if the user types a stray operator, which is not something you want happening on every keystroke.

Making Results RLS-Safe

Here is the part most search tutorials skip. If your app is multi-tenant, a search query that forgets its where org_id = ... clause leaks every tenant's data through the one endpoint that touches every row. Instead of trusting yourself to remember that filter, let row-level security enforce it.

Assume your table already has an RLS policy that scopes reads to the caller's organization:

alter table documents enable row level security;

create policy "members read their org documents"
on documents
for select
using ( org_id = (auth.jwt() ->> 'org_id')::uuid );

Now wrap the search in a database function marked security invoker. That flag means the function runs with the calling user's privileges, so the policy above applies to the function's query exactly as it would to a direct select. Add ts_headline while you are here to return a highlighted snippet with the matched terms wrapped in mark tags:

-- supabase/migrations/20260714000100_search_documents_fn.sql

create or replace function search_documents(search_query text)
returns table (id uuid, title text, snippet text, rank real)
language sql
stable
security invoker
set search_path = public
as $$
  select
    d.id,
    d.title,
    ts_headline(
      'english',
      d.body,
      websearch_to_tsquery('english', search_query),
      'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MaxWords=25,MinWords=8'
    ) as snippet,
    ts_rank('{0.1, 0.2, 0.4, 1.0}', d.search_vector,
            websearch_to_tsquery('english', search_query)) as rank
  from documents d
  where d.search_vector @@ websearch_to_tsquery('english', search_query)
  order by rank desc
  limit 20;
$$;

Because the function is security invoker and you call it through the user's authenticated session, a user physically cannot get back a row they could not already read. The authorization lives in one policy, not scattered across every query. That is the whole reason to route search through Postgres instead of reimplementing tenant checks in application code.

Exposing Search Through oRPC

oRPC gives you an end-to-end type-safe endpoint: the input is validated by Zod on the server, and the client gets the return type inferred for free. Define the procedure with an input schema, an output schema, and a handler. The handler creates a request-scoped Supabase server client (the one that carries the user's JWT, so RLS applies) and calls the function through .rpc().

The Zod input caps the query length so nobody sends a megabyte of text to the parser, and trims whitespace before it reaches the database:

// lib/orpc/search.ts
import { os } from '@orpc/server'
import { z } from 'zod'
import { createServerClient } from '@/lib/supabase/server'

const SearchResult = z.object({
  id: z.string(),
  title: z.string(),
  snippet: z.string(),
  rank: z.number(),
})

export const searchDocuments = os
  .input(z.object({ query: z.string().trim().min(1).max(100) }))
  .output(z.array(SearchResult))
  .handler(async ({ input }) => {
    const supabase = await createServerClient()

    const { data, error } = await supabase.rpc('search_documents', {
      search_query: input.query,
    })

    if (error) {
      throw new Error(`Search failed: ${error.message}`)
    }

    return data ?? []
  })

Collect your procedures into a router. This is the single object the client will mirror, so its shape is your API surface:

// lib/orpc/router.ts
import { searchDocuments } from './search'

export const router = {
  searchDocuments,
}

Mount the router at a route handler. In Next.js 16 the catch-all segment name goes in the folder, and RPCHandler takes the whole request and returns a Response. The context object is empty here because the Supabase client is created inside the handler, not injected:

// app/rpc/[[...rest]]/route.ts
import { RPCHandler } from '@orpc/server/fetch'
import { router } from '@/lib/orpc/router'

const handler = new RPCHandler(router)

async function handle(request: Request) {
  const { response } = await handler.handle(request, {
    prefix: '/rpc',
    context: {},
  })

  return response ?? new Response('Not found', { status: 404 })
}

export const GET = handle
export const POST = handle

The typed client mirrors the router type without importing any server code, so none of your database logic ships to the browser:

// lib/orpc/client.ts
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import type { RouterClient } from '@orpc/server'
import type { router } from './router'

const link = new RPCLink({
  url:
    (typeof window !== 'undefined' ? window.location.origin : '') + '/rpc',
})

export const client: RouterClient<typeof router> = createORPCClient(link)

The Debounced Search UI

Firing a request on every keystroke hammers your database and shows results for a query the user already changed. A debounce fixes both: wait until typing pauses for 250ms, then search. And because a slow request can resolve after a newer one, each request carries an AbortController so stale responses get cancelled instead of overwriting fresh ones.

This is a client component. The cleanup function in useEffect clears the pending timer and aborts the in-flight request every time the query changes, which is what makes the debounce and the cancellation work together:

// components/search-box.tsx
'use client'

import { useEffect, useState } from 'react'
import { client } from '@/lib/orpc/client'

type Result = {
  id: string
  title: string
  snippet: string
  rank: number
}

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

  useEffect(() => {
    const trimmed = query.trim()

    if (trimmed.length === 0) {
      setResults([])
      setLoading(false)
      return
    }

    const controller = new AbortController()

    const timer = setTimeout(async () => {
      setLoading(true)
      try {
        const data = await client.searchDocuments(
          { query: trimmed },
          { signal: controller.signal },
        )
        setResults(data)
      } catch (err) {
        if (!controller.signal.aborted) {
          console.error(err)
        }
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false)
        }
      }
    }, 250)

    return () => {
      clearTimeout(timer)
      controller.abort()
    }
  }, [query])

  return (
    <div className="mx-auto max-w-xl">
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search documents..."
        className="w-full rounded-lg border px-4 py-2"
      />

      {loading && <p className="mt-2 text-sm text-gray-500">Searching...</p>}

      <ul className="mt-4 space-y-3">
        {results.map((r) => (
          <li key={r.id} className="rounded-lg border p-3">
            <p className="font-medium">{r.title}</p>
            <p
              className="mt-1 text-sm text-gray-600"
              dangerouslySetInnerHTML={{ __html: r.snippet }}
            />
          </li>
        ))}
      </ul>

      {!loading && query.trim().length > 0 && results.length === 0 && (
        <p className="mt-4 text-sm text-gray-500">No results found.</p>
      )}
    </div>
  )
}

One note on the snippet render. ts_headline returns HTML with mark tags, so it needs dangerouslySetInnerHTML to show the highlight. That is safe here because the markup comes from your own database function with a fixed StartSel and StopSel, not from arbitrary user input. If your body column can contain user-authored HTML, sanitize the output before rendering it.

Adding It to a Page

Drop the search box into any Server Component. The component itself is a client island, so the page around it stays a Server Component and renders instantly:

// app/search/page.tsx
import { SearchBox } from '@/components/search-box'

export default function SearchPage() {
  return (
    <main className="py-12">
      <h1 className="mb-6 text-center text-2xl font-bold">Search</h1>
      <SearchBox />
    </main>
  )
}

If you want the search term to live in the URL so results are shareable, read it from searchParams, which is a Promise in Next.js 16 and must be awaited:

// app/search/page.tsx
import { SearchBox } from '@/components/search-box'

interface PageProps {
  searchParams: Promise<{ q?: string }>
}

export default async function SearchPage({ searchParams }: PageProps) {
  const { q } = await searchParams

  return (
    <main className="py-12">
      <h1 className="mb-6 text-center text-2xl font-bold">Search</h1>
      <SearchBox initialQuery={q ?? ''} />
    </main>
  )
}

Wire initialQuery into the component's useState default and push the query into the URL with router.replace on change if you go this route. Claude can add that in a follow-up prompt once the base feature works.

Quality Gates Before You Ship

Two commands catch the mistakes that this feature is prone to: an untyped .rpc() return, a Server Action that leaks the service-role client, a dangerouslySetInnerHTML type mismatch.

Type check with zero errors, then confirm the build:

npx tsc --noEmit
npm run build

Ask Claude to run both and fix what breaks. The type check is where a mismatch between your Zod output schema and the actual search_documents return columns surfaces, before a user ever sees a broken result card. If you renamed a column in the SQL function but not in the SearchResult schema, this is the step that catches it.

One more thing worth verifying by hand: log in as a user in one tenant and search for a term you know exists only in another tenant's data. You should get zero results. If you get a hit, your function is not security invoker or you are calling it with a service-role client that bypasses RLS. Fix that before anything ships.

Where the Code Kit Fits

Claude Code will happily build every piece above in one session. The gap is consistency: remembering to run the type check, catching the RLS leak, keeping the Zod schema and the SQL columns in lockstep across a dozen features. The $29 Code Kit is a harness that sits on top of Claude Code and enforces that loop for you (plan, build, evaluate, test), with quality gates that require zero type errors, zero lint errors, and a clean build before a feature is considered done. It is a one-time $29 purchase with no subscription. Claude Code itself runs on your own paid Anthropic plan, which you bring. The harness just makes sure the search feature you shipped today does not quietly break the next feature you build on Friday.

Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →

Posted by @speedy_devv

Continue in Workflow

  • Agentic Commerce: Wie du eine App baust, für die KI-Agents bezahlen können
    Ein Guide in einfachem Deutsch zu Agentic Commerce im Jahr 2026: Was x402, ACP und das Machine Payments Protocol tun, plus eine Wochenend-Anleitung, um eine bezahlte API auszuliefern, von der KI-Agents kaufen können.
  • Claude Code Best Practices
    Fünf Gewohnheiten trennen Entwickler, die mit Claude Code liefern: PRDs, modulare CLAUDE.md-Regeln, Custom-Slash-Commands, /clear-Resets und eine System-Evolutions-Denkweise.
  • Claude Code Auto-Modus
    Ein zweites Sonnet-Modell prüft jeden Claude Code-Tool-Aufruf, bevor er ausgeführt wird. Was der Auto-Modus blockiert, was er erlaubt, und die Erlaubnisregeln, die er in deine Einstellungen schreibt.
  • Channels, Routines, Teleport, Dispatch
    Die vier Claude-Code-Features, die Anthropic im März und April 2026 ausgeliefert hat und die die CLI in eine ereignisgesteuerte Koordinationsschicht über Handy, Web und Desktop verwandeln.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Grundlagen für Agenten
    Fünf Möglichkeiten, spezialisierte Agenten in Claude Code zu erstellen: Aufgaben-Unteragenten, .claude/agents YAML, benutzerdefinierte Slash-Befehle, CLAUDE.md Personas und perspektivische Aufforderungen.
  • Agent-Harness-Engineering
    Der Harness ist jede Schicht rund um deinen KI-Agenten, außer dem Modell selbst. Lern die fünf Steuerungshebel, das Constraint-Paradoxon und warum das Harness-Design die Performance des Agenten mehr bestimmt als das Modell.
  • Agenten-Muster
    Orchestrator, Fan-out, Validierungskette, Spezialistenrouting, Progressive Verfeinerung und Watchdog. Sechs Orchestrierungsformen, um Claude Code Sub-Agenten zu verdrahten.
  • Agent Teams Best Practices
    Bewährte Muster für Claude Code Agent Teams. Kontextreiche Spawn-Prompts, richtig bemessene Aufgaben, Datei-Eigentümerschaft, Delegate-Modus und Fixes für v2.1.33-v2.1.45.

Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →

How to Build an Admin Dashboard With Claude Code

Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

Agentic Commerce: Wie du eine App baust, für die KI-Agents bezahlen können

Ein Guide in einfachem Deutsch zu Agentic Commerce im Jahr 2026: Was x402, ACP und das Machine Payments Protocol tun, plus eine Wochenend-Anleitung, um eine bezahlte API auszuliefern, von der KI-Agents kaufen können.

On this page

What Full-Text Search Actually Needs
The Migration: A Generated tsvector Column
Weighting and Ranking
Making Results RLS-Safe
Exposing Search Through oRPC
The Debounced Search UI
Adding It to a Page
Quality Gates Before You Ship
Where the Code Kit Fits

Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →