implement-search
Access: /template implement-search or /t implement-search
Adds comprehensive search functionality to your MakerKit application, including full-text search, filters, and search UI components.
What It Does
The implement-search command helps you:
- Set up database full-text search
- Create search APIs and endpoints
- Implement search UI components
- Add search filters and facets
- Configure search indexing
- Implement instant search with debouncing
Usage
bash
/template implement-search "Description"
# or
/t implement-search "Description"When prompted, specify:
- Resources to make searchable
- Search UI style (instant, modal, page)
- Filter requirements
- Search result ranking preferences
Prerequisites
- A MakerKit project with data to search
- PostgreSQL with full-text search extensions
- Commercial MakerKit license from MakerKit
What Gets Created
1. Database Search Setup
Enable full-text search:
sql
-- Enable required extensions
create extension if not exists pg_trgm;
create extension if not exists unaccent;
-- Add search columns to tables
alter table public.projects
add column search_vector tsvector generated always as (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
setweight(to_tsvector('english', coalesce(tags::text, '')), 'C')
) stored;
-- Create search index
create index idx_projects_search on public.projects using gin(search_vector);
-- Add fuzzy search support
create index idx_projects_name_trgm on public.projects using gin(name gin_trgm_ops);
-- Search function with ranking
create or replace function search_projects(
search_query text,
account_uuid uuid,
limit_count int default 20,
offset_count int default 0
)
returns table (
id uuid,
name text,
description text,
rank real,
highlight text
)
language plpgsql
as $$
begin
return query
select
p.id,
p.name,
p.description,
ts_rank(p.search_vector, websearch_to_tsquery('english', search_query)) as rank,
ts_headline(
'english',
p.name || ' ' || coalesce(p.description, ''),
websearch_to_tsquery('english', search_query),
'StartSel=<mark>, StopSel=</mark>, HighlightAll=true'
) as highlight
from public.projects p
where
p.account_id = account_uuid and
p.search_vector @@ websearch_to_tsquery('english', search_query)
order by rank desc
limit limit_count
offset offset_count;
end;
$$;2. Search API Endpoint
Create search server action:
typescript
// app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handler-client';
import { requireUser } from '@kit/supabase/require-user';
const SearchSchema = z.object({
q: z.string().min(1).max(100),
type: z.enum(['all', 'projects', 'documents', 'users']).default('all'),
filters: z.object({
status: z.string().optional(),
dateRange: z.object({
from: z.string().optional(),
to: z.string().optional(),
}).optional(),
tags: z.array(z.string()).optional(),
}).optional(),
limit: z.number().min(1).max(50).default(20),
offset: z.number().min(0).default(0),
});
export async function GET(request: NextRequest) {
try {
const client = getSupabaseRouteHandlerClient();
const { user } = await requireUser(client);
const { searchParams } = new URL(request.url);
const params = SearchSchema.parse({
q: searchParams.get('q'),
type: searchParams.get('type') || 'all',
filters: JSON.parse(searchParams.get('filters') || '{}'),
limit: parseInt(searchParams.get('limit') || '20'),
offset: parseInt(searchParams.get('offset') || '0'),
});
// Perform search based on type
const results = await performSearch(client, user.id, params);
return NextResponse.json({
results,
total: results.total,
query: params.q,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid search parameters', details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Search failed' },
{ status: 500 }
);
}
}
async function performSearch(client: any, userId: string, params: any) {
const results = {
projects: [],
documents: [],
users: [],
total: 0,
};
// Search projects
if (params.type === 'all' || params.type === 'projects') {
const { data: projects, count } = await client
.rpc('search_projects', {
search_query: params.q,
account_uuid: userId,
limit_count: params.limit,
offset_count: params.offset,
});
results.projects = projects || [];
results.total += count || 0;
}
// Add other search types...
return results;
}3. Instant Search Component
Real-time search UI:
typescript
// components/search/instant-search.tsx
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useDebounce } from '@kit/hooks/use-debounce';
import { Search, X, Loader2 } from 'lucide-react';
import { Input } from '@kit/ui/input';
import { Dialog, DialogContent } from '@kit/ui/dialog';
import { useHotkeys } from '@kit/hooks/use-hotkeys';
export function InstantSearch() {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResults>({ items: [] });
const [isSearching, setIsSearching] = useState(false);
const debouncedQuery = useDebounce(query, 300);
// Keyboard shortcut
useHotkeys('cmd+k', () => setIsOpen(true));
// Search effect
useEffect(() => {
if (!debouncedQuery) {
setResults({ items: [] });
return;
}
const performSearch = async () => {
setIsSearching(true);
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(debouncedQuery)}`
);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
} finally {
setIsSearching(false);
}
};
performSearch();
}, [debouncedQuery]);
return (
<>
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground border rounded-lg hover:border-primary transition-colors"
>
<Search className="h-4 w-4" />
<span>Search...</span>
<kbd className="pointer-events-none hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium sm:flex">
<span className="text-xs">⌘</span>K
</kbd>
</button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="max-w-2xl p-0">
<div className="border-b">
<div className="flex items-center px-4">
<Search className="h-5 w-5 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search projects, documents, and more..."
className="flex-1 border-0 focus-visible:ring-0"
autoFocus
/>
{isSearching && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{query && (
<button
onClick={() => setQuery('')}
className="p-1 hover:bg-accent rounded"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
<SearchResults
results={results}
query={query}
onSelect={() => setIsOpen(false)}
/>
</DialogContent>
</Dialog>
</>
);
}4. Search Results Component
Display search results:
typescript
// components/search/search-results.tsx
import { useRouter } from 'next/navigation';
import { FileText, Folder, User } from 'lucide-react';
interface SearchResultsProps {
results: SearchResults;
query: string;
onSelect: () => void;
}
export function SearchResults({
results,
query,
onSelect
}: SearchResultsProps) {
const router = useRouter();
if (!query) {
return (
<div className="p-8 text-center text-muted-foreground">
<p>Start typing to search...</p>
</div>
);
}
if (results.items.length === 0) {
return (
<div className="p-8 text-center">
<p className="text-muted-foreground">
No results found for "{query}"
</p>
<p className="text-sm text-muted-foreground mt-2">
Try searching with different keywords
</p>
</div>
);
}
return (
<div className="max-h-[400px] overflow-y-auto">
{/* Group results by type */}
{results.projects?.length > 0 && (
<div>
<div className="px-4 py-2 text-sm font-medium text-muted-foreground">
Projects
</div>
{results.projects.map((project) => (
<button
key={project.id}
onClick={() => {
router.push(`/projects/${project.id}`);
onSelect();
}}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
>
<Folder className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex-1 text-left">
<div
className="font-medium"
dangerouslySetInnerHTML={{
__html: project.highlight || project.name
}}
/>
{project.description && (
<div className="text-sm text-muted-foreground truncate">
{project.description}
</div>
)}
</div>
</button>
))}
</div>
)}
{/* Add other result types... */}
</div>
);
}5. Search Filters
Advanced filtering options:
typescript
// components/search/search-filters.tsx
export function SearchFilters({
filters,
onChange,
}: {
filters: SearchFilters;
onChange: (filters: SearchFilters) => void;
}) {
return (
<div className="space-y-4">
<div>
<Label>Type</Label>
<Select
value={filters.type}
onValueChange={(value) =>
onChange({ ...filters, type: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="projects">Projects</SelectItem>
<SelectItem value="documents">Documents</SelectItem>
<SelectItem value="users">Users</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Status</Label>
<Select
value={filters.status}
onValueChange={(value) =>
onChange({ ...filters, status: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Any status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Date Range</Label>
<DateRangePicker
value={filters.dateRange}
onChange={(dateRange) =>
onChange({ ...filters, dateRange })
}
/>
</div>
<div>
<Label>Tags</Label>
<TagSelector
value={filters.tags || []}
onChange={(tags) =>
onChange({ ...filters, tags })
}
/>
</div>
</div>
);
}6. Search Page
Dedicated search interface:
typescript
// app/search/page.tsx
'use client';
import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { SearchInput } from '@/components/search/search-input';
import { SearchFilters } from '@/components/search/search-filters';
import { SearchResults } from '@/components/search/search-results';
import { useSearch } from '@/hooks/use-search';
export default function SearchPage() {
const searchParams = useSearchParams();
const initialQuery = searchParams.get('q') || '';
const [query, setQuery] = useState(initialQuery);
const [filters, setFilters] = useState<SearchFilters>({
type: 'all',
});
const { data, isLoading } = useSearch(query, filters);
return (
<div className="container py-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Search</h1>
<SearchInput
value={query}
onChange={setQuery}
className="mb-8"
/>
<div className="grid md:grid-cols-4 gap-8">
<aside className="md:col-span-1">
<h2 className="font-semibold mb-4">Filters</h2>
<SearchFilters
filters={filters}
onChange={setFilters}
/>
</aside>
<main className="md:col-span-3">
{isLoading ? (
<SearchSkeleton />
) : (
<>
<div className="mb-4 text-muted-foreground">
{data?.total || 0} results for "{query}"
</div>
<SearchResults results={data} />
</>
)}
</main>
</div>
</div>
</div>
);
}7. Search Indexing
Background indexing job:
typescript
// lib/search/indexing.ts
export async function indexSearchableContent() {
const client = getSupabaseServerClient({ admin: true });
// Update search vectors for all tables
await Promise.all([
client.rpc('update_projects_search_vector'),
client.rpc('update_documents_search_vector'),
client.rpc('update_users_search_vector'),
]);
}
// Cron job for regular re-indexing
export async function setupSearchIndexing() {
// Run every night at 2 AM
cron.schedule('0 2 * * *', async () => {
console.log('Starting search index update...');
await indexSearchableContent();
console.log('Search index updated successfully');
});
}8. Search Analytics
Track search behavior:
typescript
// lib/search/analytics.ts
export async function trackSearch({
query,
results,
userId,
}: {
query: string;
results: number;
userId: string;
}) {
await supabase.from('search_analytics').insert({
query,
results_count: results,
user_id: userId,
created_at: new Date().toISOString(),
});
}
// Popular searches dashboard
export async function getPopularSearches(days: number = 7) {
const { data } = await supabase
.from('search_analytics')
.select('query')
.gte('created_at', new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString())
.order('count', { ascending: false })
.limit(10);
return data;
}9. Search Suggestions
Auto-complete functionality:
typescript
// hooks/use-search-suggestions.ts
export function useSearchSuggestions(query: string) {
return useQuery({
queryKey: ['search-suggestions', query],
queryFn: async () => {
if (query.length < 2) return [];
const { data } = await supabase
.rpc('get_search_suggestions', {
search_query: query,
limit_count: 5,
});
return data || [];
},
enabled: query.length >= 2,
});
}10. Global Search Hook
Unified search interface:
typescript
// hooks/use-global-search.ts
export function useGlobalSearch() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setIsOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
};
}Search Performance
Optimize search queries:
sql
-- Partial matching for autocomplete
create index idx_projects_name_prefix on projects(name text_pattern_ops);
-- Optimize common queries
create index idx_projects_account_status on projects(account_id, status);
-- Materialized view for complex searches
create materialized view search_index as
select
'project' as type,
id,
account_id,
name as title,
description,
search_vector,
created_at
from projects
union all
select
'document' as type,
id,
account_id,
title,
content as description,
search_vector,
created_at
from documents;
-- Refresh periodically
create index idx_search_index_vector on search_index using gin(search_vector);License Requirement
Important: This command requires a commercial MakerKit license from https://makerkit.dev?atp=MqaGgc MakerKit is a premium SaaS starter kit and requires proper licensing for commercial use.
