jevql docs
playgroundGitHub ↗

Semantic search in React

A search box that takes a plain-English request and returns rows from Postgres, with a small API route in front of jevql.

Left: a keyword search for “someone wants a refund” finds nothing, because none of those words appear in the tickets. Right: the same words through jev() find “Charged twice”, and the panel underneath shows the SQL that ran.

You will build a <SemanticSearch> component. The user types something like “customers who sound like they are about to churn”, the component asks your API, the API turns the request into a jevql query, and the rows come back with what the query cost.

The browser never talks to jevql directly. Your API route holds the database URL and the TypeSafe key, and it decides which table and columns are searchable. That keeps the token and the SQL surface on the server.

What you need

  • Node 18+ and a React app with an API route. The example uses a Next.js route handler; an Express handler is shown at the end.
  • npm i jevql. The TypeScript SDK bundles the engine, so nothing else to install.
  • A table to search. The example uses tickets(id, subject, body, status, created_at).

1. The API route

One handler, one shared Jevql instance, and a tiny allow-list of what can be searched.

// app/api/search/route.ts
import { Jevql, JevqlError } from "jevql"
import { NextResponse } from "next/server"

// One engine per server process. It starts on first use and reads
// DATABASE_URL and TYPESAFE_API_KEY from the environment.
const db = new Jevql()

// Only these tables, only these columns. The condition is user input;
// the SQL around it is not.
const SEARCHABLE = {
  tickets: { columns: ["subject", "body"], recent: "created_at > now() - interval '90 days'" },
} as const

type Table = keyof typeof SEARCHABLE

export async function POST(req: Request) {
  const { table, condition, limit = 20 } = (await req.json()) as { table: Table; condition: string; limit?: number }
  const spec = SEARCHABLE[table]
  if (!spec || typeof condition !== "string" || condition.length < 3 || condition.length > 200) {
    return NextResponse.json({ error: "bad request" }, { status: 400 })
  }

  // The condition goes in as a SQL string literal. Escape single quotes.
  const q = condition.replace(/'/g, "''")
  // Column-list form: only these columns are sent to TypeSafe, and because
  // jev() and jev_prob() name the same columns, each row is judged once.
  const src = `(${spec.columns.map((c) => `t.${c}`).join(", ")})`
  const sql = `
    SELECT id, subject, jev_prob(${src}, '${q}') AS relevance
    FROM ${table} t
    WHERE t.status = 'open' AND t.${spec.recent}
      AND jev(${src}, '${q}')
    ORDER BY relevance DESC
    LIMIT ${Math.min(Number(limit) || 20, 50)}`

  try {
    const res = await db.query(sql, { maxRows: 500 })   // never judge more than 500 rows per search
    return NextResponse.json({ columns: res.columns, rows: res.rows, stats: res.stats, sql })
  } catch (e) {
    if (e instanceof JevqlError && e.code === "budget") {
      return NextResponse.json({ error: "Too many matching rows to search. Add a filter." }, { status: 422 })
    }
    throw e
  }
}

Three things worth noticing:

  • The plain predicates (status = 'open', last 90 days) run on Postgres before jevql judges anything. Every row that survives them is sent to TypeSafe, so this is where the bill is decided.
  • jev_prob gives a relevance score to sort by; jev in the outer WHERE drops rows under the threshold (0.5 by default, pass threshold to change it).
  • maxRows: 500 is the safety net. If a request would judge more than that, jevql refuses before making any API call and you get a budget error to turn into a friendly message.

2. The component

// components/SemanticSearch.tsx
"use client"
import { useEffect, useRef, useState } from "react"

type Row = [id: number, subject: string, relevance: number]
type Result = { rows: Row[]; stats: { judged: number; cache_hits: number; usd: number; elapsed_ms: number } | null; sql?: string }

export function SemanticSearch({ table = "tickets" }: { table?: string }) {
  const [condition, setCondition] = useState("")
  const [result, setResult] = useState<Result | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [busy, setBusy] = useState(false)
  const abort = useRef<AbortController | null>(null)

  // Search on submit, not on every keystroke: each search costs money.
  async function search(e: React.FormEvent) {
    e.preventDefault()
    if (condition.trim().length < 3) return
    abort.current?.abort()
    const ctl = new AbortController()
    abort.current = ctl
    setBusy(true)
    setError(null)
    try {
      const r = await fetch("/api/search", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ table, condition }),
        signal: ctl.signal,
      })
      if (!r.ok) {
        // API errors are JSON ({ error }), but a crashed server may send HTML.
        const text = await r.text()
        let message = r.statusText
        try { message = JSON.parse(text).error ?? message } catch {}
        throw new Error(message)
      }
      setResult(await r.json())
    } catch (err) {
      if ((err as Error).name !== "AbortError") setError((err as Error).message)
    } finally {
      setBusy(false)
    }
  }

  useEffect(() => () => abort.current?.abort(), [])

  return (
    <div className="semantic-search">
      <form onSubmit={search}>
        <input
          value={condition}
          onChange={(e) => setCondition(e.target.value)}
          placeholder="Describe what you are looking for, e.g. customers who sound like they will churn"
          aria-label="Search condition"
        />
        <button type="submit" disabled={busy}>{busy ? "Judging…" : "Search"}</button>
      </form>

      {error && <p role="alert">{error}</p>}

      {result && (
        <>
          <ol>
            {result.rows.map(([id, subject, relevance]) => (
              <li key={id}>
                <span className="score">{Math.round(relevance * 100)}%</span> {subject}
              </li>
            ))}
          </ol>
          {result.rows.length === 0 && <p>No rows matched.</p>}
          {result.stats && (
            <p className="footer">
              {result.stats.judged} rows judged, {result.stats.cache_hits} from cache, ${result.stats.usd.toFixed(4)},{" "}
              {result.stats.elapsed_ms} ms
            </p>
          )}
          {result.sql && (
            <details className="sql">
              <summary>the SQL that ran</summary>
              <pre>{result.sql}</pre>
            </details>
          )}
        </>
      )}
    </div>
  )
}

Use it anywhere:

<SemanticSearch table="tickets" />

3. Style it

The component ships without styles so it drops into whatever you have. If you want the look from the video (paper, ink, one lime accent), this is the whole stylesheet; import it next to the component or download it.

/* semantic-search.css: the jevql look. Paper, ink, one lime accent, monospace. */
.semantic-search {
  --ink: #171715; --paper: #f7f6f0; --panel: #fffefa; --line: #c7c5bb; --muted: #66645e; --lime: #d4ff3f; --pink: #ee5ba6;
  max-width: 720px; margin: 0 auto; padding: 32px 0; color: var(--ink);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.semantic-search form { display: flex; gap: 10px; }
.semantic-search input {
  flex: 1; min-width: 0; border: 1.5px solid var(--ink); border-radius: 0; background: var(--panel);
  padding: 12px 14px; font: 600 .9rem/1.3 inherit; color: var(--ink); outline: none;
}
.semantic-search input:focus { box-shadow: 3px 3px 0 var(--ink); }
.semantic-search button {
  border: 1.5px solid var(--ink); border-radius: 0; background: var(--lime); box-shadow: 3px 3px 0 var(--ink);
  padding: 12px 16px; font: 900 .72rem/1 inherit; text-transform: uppercase; letter-spacing: .04em; color: var(--ink); cursor: pointer;
}
.semantic-search button:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); }
.semantic-search button:disabled { opacity: .5; cursor: progress; }
.semantic-search ol { list-style: none; margin: 20px 0 0; padding: 0; border: 1.5px solid var(--ink); background: var(--panel); }
.semantic-search li { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); font-size: .95rem; }
.semantic-search li:last-child { border-bottom: 0; }
.semantic-search .score { min-width: 3.5em; padding: 3px 6px; background: var(--lime); font-size: .72rem; font-weight: 900; text-align: center; }
.semantic-search [role="alert"] { margin-top: 16px; border-left: 5px solid var(--pink); background: #ecebe4; padding: 10px 12px; font-size: .85rem; }
.semantic-search .footer { margin: 10px 0 0; color: var(--muted); font-size: .72rem; font-weight: 700; }
.semantic-search p:not(.footer):not([role="alert"]) { margin-top: 16px; color: var(--muted); font-size: .9rem; }
.semantic-search details.sql { margin-top: 12px; border: 1px solid var(--line); background: var(--panel); }
.semantic-search details.sql summary { padding: 8px 12px; font-size: .68rem; font-weight: 900; text-transform: uppercase; letter-spacing: .05em; cursor: pointer; }
.semantic-search details.sql pre { margin: 0; padding: 10px 12px; border-top: 1px solid var(--line); font: 600 .74rem/1.55 inherit; white-space: pre-wrap; }

4. Make it feel instant

  • Cache. jevql caches every judgement by row content and question. The same search twice costs nothing the second time, and two users asking the same thing share the cache. That is why the footer shows cache_hits.
  • Suggested searches. Offer a few canned conditions as chips (“angry customer”, “asks for a refund”, “mentions a competitor”). They warm the cache and show people what kind of question works.
  • Do not search on every keystroke. Submit on Enter or after a long debounce. Each distinct condition is a new set of judgements.
  • Show the bill. The stats object is free; surfacing it keeps you honest with yourself about what a feature costs.

5. The same route in Express, Python and Go

The route is small enough to carry to any stack. Each version below was run against the demo store; all three answer the same JSON, and all three refuse to judge more than 500 rows.

import express from "express"
import { Jevql, JevqlError } from "jevql"

const app = express()
const db = new Jevql()
app.use(express.json())

app.post("/api/search", async (req, res) => {
  const q = String(req.body.condition ?? "").replace(/'/g, "''")
  if (q.length < 3) return res.status(400).json({ error: "condition too short" })
  // Same column list in both calls, so each row is judged once.
  const sql = `SELECT id, subject, jev_prob((t.subject, t.body), '${q}') AS relevance
FROM tickets t
WHERE t.status = 'open' AND t.created_at > now() - interval '90 days'
  AND jev((t.subject, t.body), '${q}')
ORDER BY relevance DESC LIMIT 20`
  try {
    const out = await db.query(sql, { maxRows: 500 })
    res.json({ columns: out.columns, rows: out.rows, stats: out.stats, sql })
  } catch (e) {
    // Always answer with JSON: budget guards are the user's problem, everything else is ours.
    if (e instanceof JevqlError && e.code === "budget") return res.status(422).json({ error: "Too many matching rows to search. Add a filter." })
    console.error(e)
    res.status(502).json({ error: "Search is unavailable right now. Try again." })
  }
})

app.listen(3000)
# api.py: FastAPI route in front of jevql
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from dataclasses import asdict
from jevql import Jevql, JevqlError

app = FastAPI()
db = Jevql()   # one engine per process; reads DATABASE_URL and TYPESAFE_API_KEY

class Search(BaseModel):
    condition: str = Field(min_length=3, max_length=200)
    limit: int = Field(default=20, le=50)

@app.post("/api/search")
def search(s: Search):
    q = s.condition.replace("'", "''")   # the condition is a SQL string literal
    sql = f"""
    SELECT id, subject, jev_prob((t.subject, t.body), '{q}') AS relevance
    FROM tickets t
    WHERE t.status = 'open' AND t.created_at > now() - interval '90 days'
      AND jev((t.subject, t.body), '{q}')
    ORDER BY relevance DESC
    LIMIT {s.limit}"""
    try:
        res = db.query(sql, max_rows=500)   # never judge more than 500 rows per search
    except JevqlError as e:
        if e.code == "budget":
            raise HTTPException(422, "Too many matching rows to search. Add a filter.")
        raise
    return {"columns": res.columns, "rows": res.rows, "stats": asdict(res.stats), "sql": sql}

FastAPI turns the Field limits into a 422 before the handler runs, so a two-letter condition never reaches jevql. Run it with uvicorn api:app.

// main.go: net/http route in front of jevql
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"

	jevql "github.com/kylemclaren/jevql/sdk/go"
)

func main() {
	ctx := context.Background()
	db, err := jevql.New(ctx, jevql.Options{DatabaseURL: os.Getenv("DATABASE_URL"), APIKey: os.Getenv("TYPESAFE_API_KEY")})
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close(ctx)

	http.HandleFunc("POST /api/search", func(w http.ResponseWriter, r *http.Request) {
		var in struct {
			Condition string `json:"condition"`
			Limit     int    `json:"limit"`
		}
		if json.NewDecoder(r.Body).Decode(&in) != nil || len(in.Condition) < 3 || len(in.Condition) > 200 {
			http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
			return
		}
		if in.Limit <= 0 || in.Limit > 50 {
			in.Limit = 20
		}
		q := strings.ReplaceAll(in.Condition, "'", "''") // the condition is a SQL string literal
		sql := fmt.Sprintf(`SELECT id, subject, jev_prob((t.subject, t.body), '%s') AS relevance
FROM tickets t
WHERE t.status = 'open' AND t.created_at > now() - interval '90 days'
  AND jev((t.subject, t.body), '%s')
ORDER BY relevance DESC LIMIT %d`, q, q, in.Limit)

		maxRows := 500 // never judge more than 500 rows per search
		res, err := db.Query(r.Context(), sql, jevql.QueryOptions{MaxRows: &maxRows})
		w.Header().Set("Content-Type", "application/json")
		var je *jevql.Error
		if errors.As(err, &je) && je.Code == "budget" {
			w.WriteHeader(http.StatusUnprocessableEntity)
			json.NewEncoder(w).Encode(map[string]string{"error": "Too many matching rows to search. Add a filter."})
			return
		}
		if err != nil {
			log.Println(err)
			w.WriteHeader(http.StatusBadGateway)
			json.NewEncoder(w).Encode(map[string]string{"error": "Search is unavailable right now. Try again."})
			return
		}
		json.NewEncoder(w).Encode(map[string]any{"columns": res.Columns, "rows": res.Rows, "stats": res.Stats, "sql": sql})
	})
	log.Fatal(http.ListenAndServe(":3000", nil))
}

The Go SDK is the engine itself, in process: no subprocess, no server. It needs CGO for the parser, the same as building the CLI.

Where to go next

  • Swap jev for jev_choice to build a classify box: “which team should handle this?” with fixed options. Triage support tickets does exactly that, and grades the result.
  • Point the API route at a shared node instead of an embedded engine: new Jevql({ url, token }). See Deploy a node.
  • For agents rather than people, expose the same table through the MCP server.

Edit this page on GitHub ↗