name: search description: Setup Meilisearch full-text search with Redis caching, rate limiting, input sanitization, and API key management. Use this skill when the user says "setup search", "add search", "setup meilisearch", "full-text search", or "search integration". author: "@mattwoodco" version: 1.1.0 created: 2026-01-11 validated: 2026-02-17 dependencies: [docker, env-config]
Meilisearch Search Integration
Production-ready full-text search with Meilisearch, featuring Redis caching, rate limiting, input sanitization, and secure API key management.
Features
- Dual-Environment Support: Local development and production with different API keys
- Input Sanitization: Zod-based query validation to prevent injection attacks
- Redis Caching: 60-second TTL for search results
- Rate Limiting: 20 requests/minute per IP address
- Index Management: Type-safe index creation and document indexing
- API Key Rotation: Automated key rotation for production security
- CORS Support: Configurable allowed origins
Environment Variables
Local Development
# Meilisearch (local via Docker)
MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_API_KEY=devMasterKey123
Production
# Meilisearch (production)
MEILISEARCH_HOST=https://your-meilisearch-instance.com
MEILISEARCH_API_KEY=your_master_key_here
MEILISEARCH_SEARCH_KEY=your_search_only_key_here
# Required for env detection
VERCEL_ENV=production
Docker Setup
Meilisearch is included in the Docker skill. If not already set up, add to docker-compose.yml:
services:
meilisearch:
image: getmeili/meilisearch:v1.10
ports:
- "7700:7700"
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-devMasterKey123}
MEILI_NO_ANALYTICS: "true"
MEILI_ENV: development
volumes:
- meilisearch_data:/meili_data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
meilisearch_data:
Prerequisites
Add scripts/ to tsconfig.json exclude to avoid path resolution errors with seed scripts:
{
"exclude": ["node_modules", "scripts"]
}
Also add MEILISEARCH_SEARCH_KEY and VERCEL_ENV to your src/env.ts server section:
MEILISEARCH_SEARCH_KEY: z.string().optional(),
VERCEL_ENV: z.string().optional(),
Installation
bun add meilisearch zod lucide-react
File Structure
src/
├── lib/
│ └── search.ts # Meilisearch client and utilities
├── hooks/
│ ├── use-search.ts # React hook for search with debouncing
│ ├── use-hotkey.ts # Global keyboard shortcut hook
│ └── use-search-params-sync.ts # URL query param synchronization
├── components/
│ ├── search-box.tsx # Basic search input component
│ └── search-modal.tsx # Command palette search modal (⌘K)
└── app/
├── api/
│ └── search/
│ └── route.ts # Search API endpoint
└── search/
└── page.tsx # Search results page with URL params
Implementation
File: src/lib/search.ts
import { MeiliSearch } from "meilisearch";
import { z } from "zod";
// Use process.env directly to avoid @/env blocking route handlers during env validation
const isProduction = process.env.VERCEL_ENV === "production";
const host = process.env.MEILISEARCH_HOST ?? "http://localhost:7700";
// === CLIENT INITIALIZATION ===
// Admin client with full permissions (server-side only)
export const searchClient = new MeiliSearch({
host,
apiKey: isProduction
? (process.env.MEILISEARCH_API_KEY ?? "")
: "devMasterKey123",
});
// Search-only client for frontend (limited permissions)
export const searchOnlyClient = new MeiliSearch({
host,
apiKey: process.env.MEILISEARCH_SEARCH_KEY ?? "devMasterKey123",
});
// === INPUT SANITIZATION ===
const searchQuerySchema = z.object({
query: z
.string()
.max(500)
.transform((q) => q.replace(/[<>"'\\]/g, "").trim()),
limit: z.number().int().min(1).max(100).default(20),
offset: z.number().int().min(0).default(0),
filter: z.string().max(1000).optional(),
sort: z.array(z.string()).max(5).optional(),
});
export type SearchQuery = z.infer<typeof searchQuerySchema>;
export function sanitizeSearchQuery(input: unknown): SearchQuery {
return searchQuerySchema.parse(input);
}
// === CACHED SEARCH ===
// Note: Redis caching is optional. When redis is not available, searches go directly to Meilisearch.
// To enable caching, install ioredis and import your redis client here.
export async function cachedSearch(index: string, query: SearchQuery) {
// Without Redis, fall back to direct search
return directSearch(index, query);
}
// === DIRECT SEARCH (NO CACHE) ===
export async function directSearch(index: string, query: SearchQuery) {
return searchClient.index(index).search(query.query, {
limit: query.limit,
offset: query.offset,
filter: query.filter,
sort: query.sort,
});
}
// === INDEX MANAGEMENT ===
export async function createProductIndex() {
const index = searchClient.index("products");
await index.updateSettings({
searchableAttributes: ["name", "description", "category"],
filterableAttributes: ["category", "price", "inStock"],
sortableAttributes: ["price", "createdAt", "name"],
rankingRules: [
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness",
],
});
return index;
}
// Generic index configuration helper
export async function configureIndex(
indexName: string,
settings: {
searchableAttributes?: string[];
filterableAttributes?: string[];
sortableAttributes?: string[];
rankingRules?: string[];
stopWords?: string[];
synonyms?: Record<string, string[]>;
distinctAttribute?: string;
}
) {
const index = searchClient.index(indexName);
const task = await index.updateSettings(settings);
return { index, task };
}
// === DOCUMENT OPERATIONS ===
type BaseDocument = {
id: string;
[key: string]: unknown;
};
export async function indexDocuments<T extends BaseDocument>(
indexName: string,
documents: T[],
primaryKey = "id"
) {
const index = searchClient.index(indexName);
return index.addDocuments(documents, { primaryKey });
}
export async function updateDocuments<T extends BaseDocument>(
indexName: string,
documents: T[],
primaryKey = "id"
) {
const index = searchClient.index(indexName);
return index.updateDocuments(documents, { primaryKey });
}
export async function deleteDocuments(indexName: string, ids: string[]) {
const index = searchClient.index(indexName);
return index.deleteDocuments(ids);
}
export async function deleteAllDocuments(indexName: string) {
const index = searchClient.index(indexName);
return index.deleteAllDocuments();
}
// === TASK MANAGEMENT ===
export async function waitForTask(taskUid: number) {
return searchClient.tasks.waitForTask(taskUid);
}
export async function getTaskStatus(taskUid: number) {
return searchClient.tasks.getTask(taskUid);
}
// === API KEY MANAGEMENT (Production) ===
export async function createSearchKey(
indexes: string[],
expiresInDays = 30,
description?: string
) {
if (!isProduction) return null;
const newKey = await searchClient.createKey({
description: description ?? `Search key created at ${new Date().toISOString()}`,
actions: ["search"],
indexes,
expiresAt: new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000),
});
return newKey;
}
export async function rotateApiKeys() {
if (!isProduction) return null;
// Create new search key
const newKey = await searchClient.createKey({
description: `Search key rotated at ${new Date().toISOString()}`,
actions: ["search"],
indexes: ["*"],
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
});
return newKey;
}
export async function listApiKeys() {
return searchClient.getKeys();
}
export async function deleteApiKey(keyUidOrKey: string) {
return searchClient.deleteKey(keyUidOrKey);
}
// === STATS & HEALTH ===
export async function getIndexStats(indexName: string) {
const index = searchClient.index(indexName);
return index.getStats();
}
export async function healthCheck() {
return searchClient.health();
}
File: src/app/api/search/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { cachedSearch, sanitizeSearchQuery } from "@/lib/search";
import { z } from "zod";
const ALLOWED_ORIGINS = [
process.env.NEXT_PUBLIC_APP_URL,
"https://your-domain.com",
].filter(Boolean);
// Inline rate limiting (no external dependency needed)
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
const RATE_LIMIT_MAX = 20; // 20 requests per minute
function checkInlineRateLimit(identifier: string): {
success: boolean;
remaining: number;
resetAt: number;
} {
const now = Date.now();
const entry = rateLimitMap.get(identifier);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(identifier, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return { success: true, remaining: RATE_LIMIT_MAX - 1, resetAt: now + RATE_LIMIT_WINDOW_MS };
}
entry.count++;
if (entry.count > RATE_LIMIT_MAX) {
return { success: false, remaining: 0, resetAt: entry.resetAt };
}
return { success: true, remaining: RATE_LIMIT_MAX - entry.count, resetAt: entry.resetAt };
}
export async function GET(req: NextRequest) {
const origin = req.headers.get("origin");
const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
// CORS check
const corsHeaders: HeadersInit = {};
if (origin && ALLOWED_ORIGINS.includes(origin)) {
corsHeaders["Access-Control-Allow-Origin"] = origin;
corsHeaders["Access-Control-Allow-Methods"] = "GET, OPTIONS";
corsHeaders["Access-Control-Allow-Headers"] = "Content-Type";
}
// Rate limiting (20 requests/minute per IP)
const rateLimit = checkInlineRateLimit(`search:${ip}`);
if (!rateLimit.success) {
return NextResponse.json(
{ error: "Rate limit exceeded" },
{
status: 429,
headers: {
...corsHeaders,
"Retry-After": String(Math.ceil((rateLimit.resetAt - Date.now()) / 1000)),
"X-RateLimit-Remaining": "0",
},
}
);
}
try {
const url = new URL(req.url);
const rawQuery = {
query: url.searchParams.get("q") ?? "",
limit: Number.parseInt(url.searchParams.get("limit") ?? "20"),
offset: Number.parseInt(url.searchParams.get("offset") ?? "0"),
filter: url.searchParams.get("filter") ?? undefined,
sort: url.searchParams.get("sort")?.split(",") ?? undefined,
};
// Sanitize and validate input
const query = sanitizeSearchQuery(rawQuery);
const startTime = Date.now();
const results = await cachedSearch("products", query);
const latencyMs = Date.now() - startTime;
// Log search analytics
console.info("search.query", {
query: query.query,
resultCount: results.hits.length,
latencyMs,
cached: latencyMs < 50,
ip,
});
return NextResponse.json(results, {
headers: {
...corsHeaders,
"X-RateLimit-Remaining": String(rateLimit.remaining),
"X-Search-Latency": `${latencyMs}ms`,
},
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid search query", details: error.issues },
{ status: 400, headers: corsHeaders }
);
}
console.error("search.error", { error: String(error), ip });
return NextResponse.json(
{ error: "Search failed" },
{ status: 500, headers: corsHeaders }
);
}
}
export async function OPTIONS(req: NextRequest) {
const origin = req.headers.get("origin");
if (origin && ALLOWED_ORIGINS.includes(origin)) {
return new NextResponse(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
},
});
}
return new NextResponse(null, { status: 204 });
}
React Hook for Search
File: src/hooks/use-search.ts
import { useState, useCallback, useRef, useEffect } from "react";
import { useId } from "react";
interface SearchResult<T> {
hits: T[];
query: string;
processingTimeMs: number;
estimatedTotalHits: number;
offset: number;
limit: number;
}
interface UseSearchOptions {
debounceMs?: number;
minQueryLength?: number;
limit?: number;
}
interface UseSearchReturn<T> {
query: string;
setQuery: (query: string) => void;
results: T[];
isLoading: boolean;
error: string | null;
totalHits: number;
search: (query: string) => Promise<void>;
loadMore: () => Promise<void>;
hasMore: boolean;
}
export function useSearch<T = Record<string, unknown>>(
endpoint: string,
options: UseSearchOptions = {}
): UseSearchReturn<T> {
const { debounceMs = 300, minQueryLength = 2, limit = 20 } = options;
const [query, setQueryState] = useState("");
const [results, setResults] = useState<T[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [totalHits, setTotalHits] = useState(0);
const [offset, setOffset] = useState(0);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const abortRef = useRef<AbortController>(undefined);
const searchId = useId();
const performSearch = useCallback(
async (searchQuery: string, searchOffset = 0, append = false) => {
if (searchQuery.length < minQueryLength) {
setResults([]);
setTotalHits(0);
return;
}
// Cancel previous request
abortRef.current?.abort();
abortRef.current = new AbortController();
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams({
q: searchQuery,
limit: String(limit),
offset: String(searchOffset),
});
const response = await fetch(`${endpoint}?${params}`, {
signal: abortRef.current.signal,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error ?? "Search failed");
}
const data: SearchResult<T> = await response.json();
setResults((prev) => (append ? [...prev, ...data.hits] : data.hits));
setTotalHits(data.estimatedTotalHits);
setOffset(searchOffset + data.hits.length);
} catch (err) {
if (err instanceof Error && err.name !== "AbortError") {
setError(err.message);
}
} finally {
setIsLoading(false);
}
},
[endpoint, limit, minQueryLength]
);
const setQuery = useCallback(
(newQuery: string) => {
setQueryState(newQuery);
setOffset(0);
// Clear existing debounce
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
// Debounce the search
debounceRef.current = setTimeout(() => {
performSearch(newQuery, 0, false);
}, debounceMs);
},
[debounceMs, performSearch]
);
const search = useCallback(
async (searchQuery: string) => {
setQueryState(searchQuery);
setOffset(0);
await performSearch(searchQuery, 0, false);
},
[performSearch]
);
const loadMore = useCallback(async () => {
if (!isLoading && results.length < totalHits) {
await performSearch(query, offset, true);
}
}, [isLoading, results.length, totalHits, query, offset, performSearch]);
// Cleanup on unmount
useEffect(() => {
return () => {
debounceRef.current && clearTimeout(debounceRef.current);
abortRef.current?.abort();
};
}, []);
return {
query,
setQuery,
results,
isLoading,
error,
totalHits,
search,
loadMore,
hasMore: results.length < totalHits,
};
}
Search Component Example
File: src/components/search-box.tsx
"use client";
import { useSearch } from "@/hooks/use-search";
import { useId } from "react";
interface Product {
id: string;
name: string;
description: string;
price: number;
category: string;
}
export function SearchBox() {
const {
query,
setQuery,
results,
isLoading,
error,
totalHits,
loadMore,
hasMore,
} = useSearch<Product>("/api/search");
const inputId = useId();
return (
<div className="w-full max-w-2xl mx-auto">
<div className="relative">
<input
id={inputId}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
aria-label="Search products"
/>
{isLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="animate-spin h-5 w-5 border-2 border-blue-500 rounded-full border-t-transparent" />
</div>
)}
</div>
{error && (
<p className="mt-2 text-red-600 text-sm">{error}</p>
)}
{results.length > 0 && (
<div className="mt-4 space-y-3">
<p className="text-sm text-gray-500">
{totalHits} result{totalHits !== 1 ? "s" : ""} found
</p>
<ul className="divide-y">
{results.map((product) => (
<li key={product.id} className="py-3">
<h3 className="font-medium">{product.name}</h3>
<p className="text-sm text-gray-600">{product.description}</p>
<p className="text-sm font-medium text-green-600">
${product.price.toFixed(2)}
</p>
</li>
))}
</ul>
{hasMore && (
<button
onClick={loadMore}
disabled={isLoading}
className="w-full py-2 px-4 bg-gray-100 hover:bg-gray-200 rounded-lg disabled:opacity-50"
>
{isLoading ? "Loading..." : "Load more"}
</button>
)}
</div>
)}
</div>
);
}
Search Modal (Command Palette)
A keyboard-driven search modal that opens with ⌘K (Mac) or Ctrl+K (Windows/Linux).
File: src/hooks/use-hotkey.ts
"use client";
import { useEffect, useCallback } from "react";
type ModifierKey = "meta" | "ctrl" | "alt" | "shift";
type KeyCombo = {
key: string;
modifiers?: ModifierKey[];
};
function parseKeyCombo(combo: string): KeyCombo {
const parts = combo.toLowerCase().split("+");
const key = parts.pop() ?? "";
const modifiers = parts as ModifierKey[];
return { key, modifiers };
}
function matchesModifiers(event: KeyboardEvent, modifiers: ModifierKey[] = []): boolean {
const hasCtrl = modifiers.includes("ctrl");
const hasMeta = modifiers.includes("meta");
const ctrlOrMeta = hasCtrl || hasMeta;
const eventCtrlOrMeta = event.ctrlKey || event.metaKey;
if (ctrlOrMeta && !eventCtrlOrMeta) return false;
if (!ctrlOrMeta && eventCtrlOrMeta) return false;
if (modifiers.includes("alt") !== event.altKey) return false;
if (modifiers.includes("shift") !== event.shiftKey) return false;
return true;
}
export function useHotkey(
keyCombo: string,
callback: (event: KeyboardEvent) => void,
options: {
enabled?: boolean;
preventDefault?: boolean;
enableOnFormElements?: boolean;
} = {}
) {
const { enabled = true, preventDefault = true, enableOnFormElements = false } = options;
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!enabled) return;
if (!enableOnFormElements) {
const target = event.target as HTMLElement;
const isFormElement = ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
const { modifiers } = parseKeyCombo(keyCombo);
if (isFormElement && (!modifiers || modifiers.length === 0)) return;
}
const { key, modifiers } = parseKeyCombo(keyCombo);
if (event.key.toLowerCase() === key && matchesModifiers(event, modifiers)) {
if (preventDefault) {
event.preventDefault();
event.stopPropagation();
}
callback(event);
}
},
[keyCombo, callback, enabled, preventDefault, enableOnFormElements]
);
useEffect(() => {
if (!enabled) return;
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown, enabled]);
}
export function useEscapeKey(callback: (event: KeyboardEvent) => void, enabled = true) {
useHotkey("escape", callback, { enabled, enableOnFormElements: true });
}
File: src/components/search-modal.tsx
"use client";
import { useCallback, useEffect, useRef, useState, useId } from "react";
import { useRouter } from "next/navigation";
import { SearchIcon, LoaderIcon, XIcon } from "lucide-react";
import { useHotkey, useEscapeKey } from "@/hooks/use-hotkey";
import { useSearch } from "@/hooks/use-search";
import { cn } from "@/lib/utils";
type SearchModalProps = {
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
export function SearchModal({ open: controlledOpen, onOpenChange }: SearchModalProps) {
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
const { query, setQuery, results, isLoading, totalHits } = useSearch<{
id: string;
name: string;
description: string;
price: number;
category: string;
}>("/api/search", { debounceMs: 200, minQueryLength: 2 });
const [selectedIndex, setSelectedIndex] = useState(-1);
// Open modal with Cmd+K / Ctrl+K
useHotkey("meta+k", () => setOpen(true), { enableOnFormElements: true });
// Close modal with Escape
useEscapeKey(() => open && setOpen(false), open);
// Handle opening - save focus and lock scroll
useEffect(() => {
if (open) {
previousActiveElement.current = document.activeElement as HTMLElement;
document.body.style.overflow = "hidden";
requestAnimationFrame(() => inputRef.current?.focus());
} else {
document.body.style.overflow = "";
previousActiveElement.current?.focus();
}
return () => { document.body.style.overflow = ""; };
}, [open]);
// Reset state when modal closes
useEffect(() => {
if (!open) {
setQuery("");
setSelectedIndex(-1);
}
}, [open, setQuery]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev));
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
break;
case "Enter":
e.preventDefault();
if (query.length >= 2) {
setOpen(false);
router.push(`/search?q=${encodeURIComponent(query)}`);
}
break;
}
}, [results, query, setOpen, router]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setOpen(false)} />
<div className="relative w-full max-w-xl mx-4 bg-background border rounded-xl shadow-2xl">
<div className="flex items-center gap-3 px-4 border-b">
<SearchIcon className="h-5 w-5 text-muted-foreground" />
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => { setQuery(e.target.value); setSelectedIndex(-1); }}
onKeyDown={handleKeyDown}
placeholder="Search products..."
className="flex-1 h-14 bg-transparent text-foreground outline-none"
/>
{isLoading && <LoaderIcon className="h-5 w-5 animate-spin" />}
<button onClick={() => setOpen(false)} className="p-1.5 rounded-md hover:bg-accent">
<XIcon className="h-4 w-4" />
</button>
</div>
{/* Results list, keyboard hints, etc. */}
</div>
</div>
);
}
export function SearchTrigger({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className={cn("flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary/50 hover:bg-secondary", className)}
>
<SearchIcon className="h-4 w-4" />
<span className="hidden sm:inline">Search...</span>
<kbd className="hidden sm:inline px-1.5 py-0.5 rounded bg-background text-xs font-mono">⌘K</kbd>
</button>
<SearchModal open={open} onOpenChange={setOpen} />
</>
);
}
Search Page with URL Params
File: src/app/search/page.tsx
"use client";
import { Suspense, useCallback, useEffect, useId, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { SearchIcon, LoaderIcon } from "lucide-react";
import { useSearch } from "@/hooks/use-search";
function SearchPageContent() {
const searchParams = useSearchParams();
const router = useRouter();
const initialQuery = searchParams.get("q") ?? "";
const { query, setQuery, search, results, isLoading, totalHits, loadMore, hasMore } = useSearch<{
id: string;
name: string;
description: string;
price: number;
category: string;
}>("/api/search", { debounceMs: 300, minQueryLength: 2 });
const [isInitialized, setIsInitialized] = useState(false);
// Initialize search from URL params on mount
useEffect(() => {
if (!isInitialized && initialQuery) {
search(initialQuery);
setIsInitialized(true);
} else if (!isInitialized) {
setIsInitialized(true);
}
}, [initialQuery, search, isInitialized]);
// Sync URL when query changes (debounced)
useEffect(() => {
if (!isInitialized) return;
const timeout = setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (query.length >= 2) params.set("q", query);
else params.delete("q");
router.replace(params.toString() ? `?${params.toString()}` : "/search", { scroll: false });
}, 500);
return () => clearTimeout(timeout);
}, [query, isInitialized, router, searchParams]);
return (
<div className="min-h-screen bg-background py-8">
<div className="max-w-4xl mx-auto px-4">
<h1 className="text-3xl font-bold text-center mb-8">Search Products</h1>
<div className="relative max-w-2xl mx-auto mb-8">
<SearchIcon className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
className="w-full h-14 pl-12 pr-12 rounded-xl border bg-background text-foreground"
/>
{isLoading && <LoaderIcon className="absolute right-4 top-1/2 -translate-y-1/2 h-5 w-5 animate-spin" />}
</div>
{/* Results list */}
</div>
</div>
);
}
export default function SearchPage() {
return (
<Suspense fallback={<div className="flex justify-center py-20"><LoaderIcon className="animate-spin" /></div>}>
<SearchPageContent />
</Suspense>
);
}
Index Seeding Script
File: scripts/seed-search.ts
import { searchClient, configureIndex, indexDocuments, waitForTask } from "@/lib/search";
async function seedSearchIndex() {
console.log("Configuring products index...");
const { task } = await configureIndex("products", {
searchableAttributes: ["name", "description", "category"],
filterableAttributes: ["category", "price", "inStock"],
sortableAttributes: ["price", "createdAt", "name"],
rankingRules: [
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness",
],
});
await waitForTask(task.taskUid);
console.log("Index configured!");
// Sample products
const products = [
{
id: "prod_001",
name: "Wireless Headphones",
description: "Premium noise-canceling wireless headphones with 30-hour battery life",
price: 299.99,
category: "electronics",
inStock: true,
createdAt: new Date().toISOString(),
},
{
id: "prod_002",
name: "Mechanical Keyboard",
description: "RGB mechanical gaming keyboard with Cherry MX switches",
price: 149.99,
category: "electronics",
inStock: true,
createdAt: new Date().toISOString(),
},
{
id: "prod_003",
name: "Running Shoes",
description: "Lightweight running shoes with responsive cushioning",
price: 129.99,
category: "sports",
inStock: false,
createdAt: new Date().toISOString(),
},
];
console.log("Indexing products...");
const indexTask = await indexDocuments("products", products);
await waitForTask(indexTask.taskUid);
console.log(`Indexed ${products.length} products!`);
}
seedSearchIndex().catch(console.error);
Run with:
bun run scripts/seed-search.ts
API Reference
Search Client Methods
| Method | Description |
|---|---|
sanitizeSearchQuery(input) |
Validates and sanitizes search query input |
cachedSearch(index, query) |
Searches with Redis caching (60s TTL) |
directSearch(index, query) |
Searches without caching |
configureIndex(name, settings) |
Configures index settings |
indexDocuments(index, docs) |
Adds documents to index |
updateDocuments(index, docs) |
Updates existing documents |
deleteDocuments(index, ids) |
Deletes documents by ID |
createSearchKey(indexes) |
Creates limited search API key |
rotateApiKeys() |
Rotates API keys (production) |
healthCheck() |
Checks Meilisearch health |
Search Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
q |
string | required | Search query (max 500 chars) |
limit |
number | 20 | Results per page (1-100) |
offset |
number | 0 | Pagination offset |
filter |
string | - | Filter expression |
sort |
string | - | Sort fields (comma-separated) |
Filter Syntax Examples
# Single condition
filter=category = "electronics"
# Multiple conditions
filter=category = "electronics" AND price < 500
# OR conditions
filter=(category = "electronics" OR category = "sports")
# Range filters
filter=price >= 100 AND price <= 500
# Boolean filters
filter=inStock = true
Sort Syntax Examples
# Single field
sort=price:asc
# Multiple fields
sort=price:asc,name:desc
# Date sorting
sort=createdAt:desc
Security Best Practices
- Never expose master key to frontend - Use search-only keys
- Sanitize all user input - Use the
sanitizeSearchQueryfunction - Implement rate limiting - 20 requests/minute per IP recommended
- Rotate API keys regularly - Use
rotateApiKeys()in production - Use HTTPS in production - Never use HTTP for production Meilisearch
- Limit search key permissions - Only grant
searchaction to frontend keys
Troubleshooting
Search returns empty results?
# Check if index exists
curl http://localhost:7700/indexes/products -H "Authorization: Bearer devMasterKey123"
# Check indexed documents
curl http://localhost:7700/indexes/products/documents -H "Authorization: Bearer devMasterKey123"
Meilisearch not responding?
# Check health endpoint
curl http://localhost:7700/health
# Check Docker logs
docker compose logs -f meilisearch
Rate limit errors in development? The inline rate limiter uses an in-memory Map. Restart the dev server to clear rate limits.
Meilisearch SDK API changes (v0.55+)
waitForTask/getTask moved to searchClient.tasks:
// ❌ Incorrect — these methods don't exist on MeiliSearch instance
searchClient.waitForTask(taskUid);
searchClient.getTask(taskUid);
// ✅ Correct — access via .tasks property
searchClient.tasks.waitForTask(taskUid);
searchClient.tasks.getTask(taskUid);
Zod v4 changed ZodError.errors to ZodError.issues:
// ❌ Incorrect — property doesn't exist in Zod v4
error.errors
// ✅ Correct
error.issues
React 19 useRef() requires initial value:
// ❌ Incorrect — TypeScript error in React 19
const ref = useRef<AbortController>();
// ✅ Correct — pass undefined explicitly
const ref = useRef<AbortController>(undefined);
NavBar Integration
Add the SearchTrigger component to your navigation bar:
// src/components/nav-bar.tsx
import { SearchTrigger } from "@/components/search-modal";
export function NavBar() {
return (
<nav className="bg-background border-b sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 flex justify-between h-16 items-center">
{/* Logo and links */}
{/* Add search trigger */}
<SearchTrigger className="ml-2" />
{/* Auth section */}
</div>
</nav>
);
}
Dark Mode Support
The search components use Tailwind CSS variables for theming:
bg-background,text-foregroundfor main surfacesbg-accent,text-accent-foregroundfor interactive statestext-muted-foregroundfor secondary textborder-borderfor borders
Ensure your layout includes dark mode flash prevention:
// src/app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider";
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
UI/UX Best Practices
- Keyboard Navigation: Use
⌘K/Ctrl+Kglobally for search modal - URL Sync: Persist search query in
?q=param for shareability - Debouncing: 200-300ms debounce for modal, 300-500ms for page
- Focus Management: Restore focus to previous element when modal closes
- Scroll Lock: Disable body scroll when modal is open
- Accessibility: Include proper ARIA labels and keyboard navigation
- Loading States: Show spinner in input during search
- Result Limits: Show top 5 results in modal, full results on page
Validation Checklist
- Modal opens with ⌘K / Ctrl+K
- Modal dismisses on Escape / click outside
- Query syncs to
?q=URL parameter -
/search?q=testhydrates search state on load - No white flash on navigation or theme switch
- Scroll focus restores correctly after modal close
- Works in both light and dark mode
- Mobile responsive
Known Issues
Auth Integration: If using better-auth with drizzle, ensure database tables are migrated before testing. Session errors can block the UI.
Payload CMS Conflict: If running alongside Payload CMS, database schema may conflict. Run drizzle migrations in a clean database.