UI kit
RagSearchBox
SearchReact · Next.jsA search input over your pipeline. Debounces typing, calls /query, and renders the top retrieved chunks as snippets with the query terms highlighted. Pass onResult to wire the raw sources into your own UI.
<RagSearchBox />Live preview
Type to search a small in-memory corpus.
Installation
No npm dependencies — just React + Tailwind CSS. There are two ways to use it:
- React — copy the source below; it calls your pipeline directly from the browser.
- Next.js — switch to the Next.js tab in the source below; it keeps your pipeline URL + key server-side behind a Route Handler (adds a couple of
.env.localvars).
Usage
usage.tsx
import { RagSearchBox } from "@/components/RagSearchBox";
export default function Example() {
return (
<RagSearchBox endpoint="https://api.example.com/query" />
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
endpointrequired | string | — | Pipeline query URL. POST { query } → { sources }. |
placeholder | string | "Search…" | Input placeholder text. |
debounceMs | number | 300 | Delay before firing a search after typing stops. |
apiKey | string | — | Sent as X-API-Key when your serve.py has API_KEY set. |
onResult | (sources: Source[]) => void | — | Called with each result set. |
fetcher | typeof fetch | — | Override the transport. Defaults to window.fetch. |
Component source copy-paste ready
RagSearchBox.tsx
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
export interface Source {
content: string;
source?: string;
page?: number;
score?: number;
}
interface RagSearchBoxProps {
/** Pipeline query endpoint. POST { query } → { sources: Source[] }. */
endpoint: string;
placeholder?: string;
/** Debounce before firing a search, in ms. Default: 300. */
debounceMs?: number;
/** Sent as the X-API-Key header when your serve.py has API_KEY set. */
apiKey?: string;
onResult?: (sources: Source[]) => void;
/** Override the transport (e.g. for testing). Defaults to window.fetch. */
fetcher?: typeof fetch;
className?: string;
}
function highlight(text: string, query: string): ReactNode {
const terms = query.trim().split(/\s+/).filter((t) => t.length > 2);
if (!terms.length) return text;
const re = new RegExp(`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`, "ig");
return text.split(re).map((part, i) =>
re.test(part) ? (
<mark key={i} className="rounded bg-yellow-200 px-0.5 text-inherit dark:bg-yellow-500/30">
{part}
</mark>
) : (
part
)
);
}
/**
* RagSearchBox — a debounced search input over a Ragiment pipeline. Renders the
* top retrieved chunks as snippets with the query terms highlighted.
*/
export function RagSearchBox({
endpoint,
placeholder = "Search…",
debounceMs = 300,
apiKey,
onResult,
fetcher,
className = "",
}: RagSearchBoxProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Source[]>([]);
const [loading, setLoading] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const onResultRef = useRef(onResult);
useEffect(() => {
onResultRef.current = onResult;
}, [onResult]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const f = fetcher ?? fetch;
clearTimeout(timer.current);
timer.current = setTimeout(async () => {
setLoading(true);
try {
const res = await f(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", ...(apiKey ? { "X-API-Key": apiKey } : {}) },
body: JSON.stringify({ query }),
});
const data = await res.json();
const sources: Source[] = data.sources ?? (Array.isArray(data) ? data : []);
setResults(sources);
onResultRef.current?.(sources);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, debounceMs);
return () => clearTimeout(timer.current);
}, [query, endpoint, debounceMs, apiKey, fetcher]);
return (
<div className={`w-full ${className}`}>
<div className="relative">
<svg
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400"
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full rounded-lg border border-zinc-200 bg-white py-2.5 pl-9 pr-9 text-sm text-zinc-800 outline-none transition focus:border-zinc-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100"
/>
{loading && (
<span className="absolute right-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-600" />
)}
</div>
{results.length > 0 && (
<ul className="mt-2 flex flex-col gap-1.5">
{results.map((r, i) => (
<li key={i} className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px] dark:border-zinc-800 dark:bg-zinc-900">
<div className="mb-0.5 flex items-center justify-between">
<span className="truncate text-xs font-medium text-zinc-500">{r.source || `Result ${i + 1}`}</span>
{typeof r.score === "number" && (
<span className="text-[11px] text-zinc-400">{(r.score * 100).toFixed(0)}%</span>
)}
</div>
<p className="leading-relaxed text-zinc-600 dark:text-zinc-300">
{highlight(r.content.slice(0, 220), query)}
{r.content.length > 220 ? "…" : ""}
</p>
</li>
))}
</ul>
)}
</div>
);
}
1 · Add to .env.local
RAG_ENDPOINT=https://your-pipeline.onrender.com/query RAG_API_KEY=
RAG_ENDPOINT— Your pipeline's /query URLRAG_API_KEY— Optional — only if your serve.py sets API_KEY
Search is proxied through the Route Handler, so the endpoint and key stay server-side.
2 · Component
components/RagSearchBox.tsx
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
export interface Source {
content: string;
source?: string;
page?: number;
score?: number;
}
interface RagSearchBoxProps {
/** Local route handler that proxies your pipeline. Default: "/api/search". */
route?: string;
placeholder?: string;
debounceMs?: number;
onResult?: (sources: Source[]) => void;
className?: string;
}
function highlight(text: string, query: string): ReactNode {
const terms = query.trim().split(/\s+/).filter((t) => t.length > 2);
if (!terms.length) return text;
const re = new RegExp(`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`, "ig");
return text.split(re).map((part, i) =>
re.test(part) ? (
<mark key={i} className="rounded bg-yellow-200 px-0.5 text-inherit dark:bg-yellow-500/30">{part}</mark>
) : (
part
)
);
}
/**
* RagSearchBox (Next.js) — debounced search that calls a local Route Handler
* (app/api/search/route.ts) instead of the pipeline directly, so your endpoint
* and key stay server-side.
*/
export function RagSearchBox({ route = "/api/search", placeholder = "Search…", debounceMs = 300, onResult, className = "" }: RagSearchBoxProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Source[]>([]);
const [loading, setLoading] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const onResultRef = useRef(onResult);
useEffect(() => {
onResultRef.current = onResult;
}, [onResult]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
clearTimeout(timer.current);
timer.current = setTimeout(async () => {
setLoading(true);
try {
const res = await fetch(route, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
const data = await res.json();
const sources: Source[] = data.sources ?? [];
setResults(sources);
onResultRef.current?.(sources);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, debounceMs);
return () => clearTimeout(timer.current);
}, [query, route, debounceMs]);
return (
<div className={`w-full ${className}`}>
<div className="relative">
<svg className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full rounded-lg border border-zinc-200 bg-white py-2.5 pl-9 pr-9 text-sm text-zinc-800 outline-none transition focus:border-zinc-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100"
/>
{loading && <span className="absolute right-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-600" />}
</div>
{results.length > 0 && (
<ul className="mt-2 flex flex-col gap-1.5">
{results.map((r, i) => (
<li key={i} className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px] dark:border-zinc-800 dark:bg-zinc-900">
<div className="mb-0.5 flex items-center justify-between">
<span className="truncate text-xs font-medium text-zinc-500">{r.source || `Result ${i + 1}`}</span>
{typeof r.score === "number" && <span className="text-[11px] text-zinc-400">{(r.score * 100).toFixed(0)}%</span>}
</div>
<p className="leading-relaxed text-zinc-600 dark:text-zinc-300">
{highlight(r.content.slice(0, 220), query)}
{r.content.length > 220 ? "…" : ""}
</p>
</li>
))}
</ul>
)}
</div>
);
}
3 · Route handler
app/api/search/route.ts
// app/api/search/route.ts
//
// Proxies pipeline search so RAG_ENDPOINT + RAG_API_KEY stay server-side.
// Set them in .env.local.
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
const { query } = (await req.json()) as { query: string };
const endpoint = process.env.RAG_ENDPOINT;
if (!endpoint) {
return new Response("RAG_ENDPOINT is not set", { status: 500 });
}
const upstream = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env.RAG_API_KEY ? { "X-API-Key": process.env.RAG_API_KEY } : {}),
},
body: JSON.stringify({ query }),
});
if (!upstream.ok) {
return Response.json({ sources: [] }, { status: 502 });
}
const data = await upstream.json();
return Response.json({ sources: data.sources ?? [] });
}