docs: replace built in fumadocs search with custom semantic vector search (#12862)

**Description:**
Replaces Fumadocs' default client-side Orama search with our server-side
Postgres vector search backend.

**Key Changes:**

- API Proxy (app/api/search/route.ts): Added a secure proxy to the
search backend with IP rate-limiting and Edge caching.

- Custom Search UI (custom-search-dialog.tsx): Built a headless Fumadocs
search dialog featuring 300ms debouncing, request cancellation
(AbortController), and URL deduplication.

- Smart Highlighting: Added the <HighlightMatch> component to render
exact matches with Tailwind styling inside the search results.

- Providers: Hooked the new custom dialog into the RootProvider.

(cherry picked from commit ab3b44cd74)
This commit is contained in:
Federico Coppede authored and Livio Spring committed 2026-10-09 14:30:12 +02:00
1 parent fa6faa3339
commit c06106b09b
3 files changed
+276 -60

No files matched your search

+54 -46
View File
@@ -1,50 +1,58 @@
import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb';
import { createSearchAPI } from 'fumadocs-core/search/server';
import { source, versionSource } from '@/lib/source';
import { getVersionFromUrl, LATEST_VERSION } from '@/lib/versions';
// app/api/search/route.ts
import { NextResponse } from 'next/server';
// Both loaders share one index. Every entry is tagged with its docs version so the
// client can scope results to the version being viewed (see app/providers.tsx).
function indexPages(loader: typeof source | typeof versionSource, getTag: (url: string) => string) {
const tree = loader.getPageTree();
return loader.getPages().map((page) => ({
id: page.url,
url: page.url,
title: page.data.title,
description: page.data.description,
structuredData: page.data.structuredData,
// Sidebar path (e.g. "Deploy & Operate › Self-Hosted"), shown with each result.
breadcrumbs: getBreadcrumbItems(page.url, tree)
.map((item) => item.name)
.filter((name): name is string => typeof name === 'string'),
tag: getTag(page.url),
}));
}
const searchAPI = createSearchAPI('advanced', {
// https://docs.orama.com/docs/orama-js/supported-languages
language: 'english',
indexes: [
...indexPages(source, () => LATEST_VERSION),
...indexPages(versionSource, getVersionFromUrl),
],
});
// fumadocs' built-in default. Its endpoint passes `limit: undefined` when the URL
// has no `?limit=`, which overrides that default and returns every match (450
// entries for "oidc").
const DEFAULT_LIMIT = '60';
// Results only change with a deploy, so the CDN answers repeated queries (cached per
// URL, i.e. per query and tag) instead of a function that may first have to rebuild
// the index on a cold start. A static index is not an option: one version alone
// serializes to over 20 MB of JSON.
export async function GET(request: Request) {
const url = new URL(request.url);
if (!url.searchParams.has('limit')) url.searchParams.set('limit', DEFAULT_LIMIT);
try {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
const searchType = searchParams.get('type') || 'docs';
const response = await searchAPI.GET(new Request(url));
response.headers.set('Cache-Control', 'public, s-maxage=86400, stale-while-revalidate=604800');
return response;
if (!query || query.trim() === '') {
return NextResponse.json([]);
}
const safeQuery = query.substring(0, 150).trim();
// Extract original client IP to pass down to the Express rate limiter
const forwardedFor = request.headers.get('x-forwarded-for');
const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : '127.0.0.1';
const baseUrl = process.env.DOCS_SEARCH_URL || 'http://localhost:8080';
const cleanBaseUrl = baseUrl.replace(/\/+$/, '');
const backendUrl = new URL(`${cleanBaseUrl}/api/search/docs`);
backendUrl.searchParams.set('q', safeQuery);
backendUrl.searchParams.set('limit', '15');
backendUrl.searchParams.set('type', searchType);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch(backendUrl.toString(), {
method: 'GET',
headers: {
'Authorization': `Bearer ${process.env.DOCS_SEARCH_SECRET}`,
'Content-Type': 'application/json',
'X-Forwarded-For': clientIp // Forwarding IP to Express
},
signal: controller.signal,
next: { revalidate: 300 }
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 429) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
return NextResponse.json({ error: 'Search service unavailable' }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error: any) {
console.error('Proxy Search Error:', error.name === 'AbortError' ? 'Timeout' : error.message);
const status = error.name === 'AbortError' ? 504 : 500;
return NextResponse.json({ error: 'Search service connection failed' }, { status });
}
}
+6 -14
View File
@@ -2,26 +2,18 @@
import { RootProvider } from 'fumadocs-ui/provider/next';
import { ReactNode } from 'react';
import { useParams } from 'next/navigation';
import MixpanelProvider from '@/components/mixpanel-provider';
import PlausibleProvider from '@/components/plausible-provider';
import { getVersionFromSlug } from '@/lib/versions';
// Import our new custom dialog
import CustomSearchDialog from '@/components/custom-search-dialog';
export function Providers({ children }: { children: ReactNode }) {
const params = useParams();
const slug = params?.slug as string[] | undefined;
return (
<RootProvider
search={{
options: {
api: '/docs/api/search',
// Every search index entry is tagged with its docs version (see
// app/api/search/route.ts). `defaultTag` applies that filter; `tags`
// would only add a visible filter pill.
defaultTag: getVersionFromSlug(slug),
},
// Replace Fumadocs' default Orama Dialog with our Custom Debounced Dialog
SearchDialog: CustomSearchDialog,
}}
>
<PlausibleProvider />
@@ -30,4 +22,4 @@ export function Providers({ children }: { children: ReactNode }) {
</MixpanelProvider>
</RootProvider>
);
}
}
@@ -0,0 +1,216 @@
'use client';
import { useEffect, useState, ReactNode } from 'react';
import { useParams } from 'next/navigation';
import {
SearchDialog,
SearchDialogClose,
SearchDialogContent,
SearchDialogHeader,
SearchDialogIcon,
SearchDialogInput,
SearchDialogList,
SearchDialogOverlay,
type SharedProps,
} from 'fumadocs-ui/components/dialog/search';
import { getVersionFromSlug } from '@/lib/versions';
type SearchResultItem = {
id: string;
content: ReactNode;
url: string;
type: 'page';
};
function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const HighlightMatch = ({ text, query }: { text: string; query: string }) => {
if (!query || !text) {
return <span className="text-xs text-muted-foreground line-clamp-2">{text}</span>;
}
const safeRegex = new RegExp(`(${escapeRegExp(query)})`, 'gi');
const parts = text.split(safeRegex);
return (
<span className="text-xs text-muted-foreground line-clamp-2">
{parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark
key={i}
className="bg-primary/20 text-primary font-semibold rounded-sm px-1 py-0.5"
>
{part}
</mark>
) : (
<span key={i}>{part}</span>
)
)}
</span>
);
};
export default function CustomSearchDialog(props: SharedProps) {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [searchType, setSearchType] = useState<'docs' | 'api'>('docs');
const [results, setResults] = useState<SearchResultItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
// New error state to track backend failures
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const params = useParams();
const slug = params?.slug as string[] | undefined;
const currentVersion = getVersionFromSlug(slug);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
}, 300);
return () => clearTimeout(handler);
}, [query]);
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults([]);
setIsLoading(false);
setErrorMsg(null);
return;
}
setIsLoading(true);
setErrorMsg(null);
const abortController = new AbortController();
async function fetchResults() {
try {
const searchUrl = `/docs/api/search?q=${encodeURIComponent(debouncedQuery)}&type=${searchType}&tag=${encodeURIComponent(currentVersion)}`;
const res = await fetch(searchUrl, { signal: abortController.signal });
if (!res.ok) {
if (res.status === 429) throw new Error('Too many requests. Please slow down.');
throw new Error('Search service is currently unavailable.');
}
const data = await res.json();
const seenUrls = new Set<string>();
const formattedResults: SearchResultItem[] = [];
for (const item of data) {
const targetUrl = item.url;
if (!seenUrls.has(targetUrl)) {
seenUrls.add(targetUrl);
formattedResults.push({
id: targetUrl,
content: (
<div className="flex flex-col gap-1 py-0.5">
<span className="font-medium text-foreground">
{item.title || 'Documentation Page'}
</span>
<HighlightMatch text={item.description || ''} query={debouncedQuery} />
</div>
),
url: targetUrl,
type: 'page'
});
if (formattedResults.length >= 15) break;
}
}
setResults(formattedResults);
} catch (err: any) {
if (err.name !== 'AbortError') {
console.error('Search error:', err);
setErrorMsg(err.message);
setResults([]);
}
} finally {
// Fix: Only clear loading if this specific request wasn't superseded/aborted
if (!abortController.signal.aborted) {
setIsLoading(false);
}
}
}
fetchResults();
return () => abortController.abort();
}, [debouncedQuery, searchType, currentVersion]);
return (
<SearchDialog
{...props}
search={query}
onSearchChange={setQuery}
>
<SearchDialogOverlay />
<SearchDialogContent>
<SearchDialogHeader className="flex flex-col gap-2 border-b pb-2">
<div className="flex items-center gap-2 w-full">
<SearchDialogIcon />
<SearchDialogInput placeholder={searchType === 'docs' ? 'Search documentation...' : 'Search API endpoints...'} />
{isLoading && (
<div className="flex items-center justify-center px-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
<SearchDialogClose />
</div>
<div className="flex justify-start w-full gap-1 px-3 pt-1">
<button
type="button"
onClick={() => setSearchType('docs')}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${searchType === 'docs'
? 'bg-primary/10 text-primary border border-primary/20'
: 'text-muted-foreground hover:bg-muted'
}`}
>
Guides & Docs
</button>
<button
type="button"
onClick={() => setSearchType('api')}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${searchType === 'api'
? 'bg-primary/10 text-primary border border-primary/20'
: 'text-muted-foreground hover:bg-muted'
}`}
>
API Endpoints
</button>
</div>
</SearchDialogHeader>
{results.length > 0 && <SearchDialogList items={results} />}
{/* Explicit Error State */}
{errorMsg && !isLoading && (
<div className="p-6 text-center text-sm text-red-500 font-medium">
{errorMsg}
</div>
)}
{!debouncedQuery && !errorMsg && (
<div className="p-6 text-center text-sm text-muted-foreground">
Type a query to search {searchType === 'docs' ? 'Guides & Docs' : 'API Endpoints'}.
</div>
)}
{!isLoading && debouncedQuery && results.length === 0 && !errorMsg && (
<div className="p-6 text-center text-sm text-muted-foreground">
No results found in {searchType === 'docs' ? 'Docs' : 'API Endpoints'} for "<span className="font-semibold text-foreground">{debouncedQuery}</span>".
</div>
)}
</SearchDialogContent>
</SearchDialog>
);
}