Build a Universal "Search Everything" Component in Next.js
Build a Universal "Search Everything" Component in Next.js
Ever noticed how the best applications let you search across everything with a single interface? Think of Notion's universal search or Superhuman's command bar. These omnisearch components create a great user experience, but implementing one efficiently requires careful planning.
In this guide, I'll walk you through building a powerful search component that:
- Searches across multiple database tables at once
- Provides an elegant, animated UI with keyboard shortcut support (⌘+K)
- Displays different types of results with appropriate styling
- Performs efficiently with debouncing and optimized API calls
The Architecture
Our omnisearch will have two main parts:
- Frontend Component: A React component managing the UI, interactions, and result display
- Backend API: A tRPC endpoint that searches multiple tables in parallel
Let's break down each part step by step.
Part 1: The React Component Skeleton
First, let's build the basic structure of our search component.
We'll use a hook 'useClickOutside' to close the search when clicking outside. Let's add this first:
// hooks/useClickOutside
import { type RefObject, useEffect } from 'react'
export function useClickOutside(ref: RefObject<HTMLElement>, callback: () => void) {
useEffect(() => {
/**
* Fire callback on clicked outside of element
*/
function handleClickOutside(event: MouseEvent) {
assertIsNode(event.target)
if (ref.current && !ref.current.contains(event.target)) {
callback()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [ref])
}
function assertIsNode(e: EventTarget | null): asserts e is Node {
if (!e || !('nodeType' in e)) {
throw new Error(`Node expected`)
}
}
Great. Now let's build the omnisearch component
// OmniSearch.tsx (initial structure)
import { useRef, useState } from 'react'
import { useClickOutside } from '~/hooks/useClickOutside'
import { cn } from '~/utils/shad-utils'
interface OmniSearchProps {
className?: string
}
export function OmniSearch({ className }: OmniSearchProps) {
const [isFocused, setIsFocused] = useState(false)
const [inputValue, setInputValue] = useState('')
const searchContainerRef = useRef<HTMLDivElement>(null)
// Close search when clicking outside
useClickOutside(searchContainerRef, () => setIsFocused(false))
return (
<div
ref={searchContainerRef}
className={cn('relative w-full max-w-md', isFocused && 'rounded-b-none', className)}
>
{/* Input field container */}
<div className={cn(
'relative z-10 rounded-xl border',
isFocused ? 'border-transparent bg-gray-300' : 'border-gray-500 bg-white',
)}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className="w-full rounded-xl bg-transparent px-6 py-3.5 focus:outline-none"
onFocus={() => setIsFocused(true)}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 rounded-xl bg-gray-300 px-4 py-2">
<div>⌘K</div>
</div>
</div>
{/* Results panel will go here */}
</div>
)
}
Here's what we've done so far:
- Created a basic component that shows a search input
- Added state to track if the input is focused and what the user has typed
- Built a keyboard shortcut indicator (⌘K) that appears inside the input
- Set up a click-outside handler to close the search when needed
This gives us a clean search box, but it doesn't actually do anything yet. Let's add the search functionality next.
Part 2: Animated Placeholder Suggestions
To make our search box more engaging and intuitive, let's add cycling placeholder text suggestions:
// Placeholder options array
const placeholderOptions = [
'Search for anything...',
'Best CRM for startups',
'Compare HubSpot vs Salesforce',
'AI Notetaker tools',
'Free project management software',
'Best customer support software',
'Recruiting software',
'Best email marketing software',
]
// Animated placeholder component
const AnimatedPlaceholder = ({ isFocused }: { isFocused: boolean }) => {
const { placeholderIndex } = useCyclePlaceholder(isFocused)
return (
<div className="pointer-events-none absolute inset-0 z-0 flex items-center px-6">
<AnimatePresence mode="wait">
<motion.span
key={placeholderIndex}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.3 }}
className="text-base text-gray-800"
>
{placeholderOptions[placeholderIndex]}
</motion.span>
</AnimatePresence>
</div>
)
}
// Hook to cycle through placeholders
const useCyclePlaceholder = (isFocused: boolean) => {
const [placeholderIndex, setPlaceholderIndex] = useState(0)
useEffect(() => {
let intervalId: NodeJS.Timeout | null = null
if (isFocused) {
// Stop cycling when focused
setPlaceholderIndex(0)
} else {
// Cycle every 2.5 seconds when not focused
intervalId = setInterval(() => {
setPlaceholderIndex((prevIndex) => (prevIndex + 1) % placeholderOptions.length)
}, 2500)
}
return () => {
if (intervalId) clearInterval(intervalId)
}
}, [isFocused])
return { placeholderIndex, setPlaceholderIndex }
}
Now add this to your main component:
{/* Inside the input container, before the input element */}
{!inputValue && <AnimatedPlaceholder isFocused={isFocused} />}
{/* Update input className */}
className={cn(
'relative z-10 w-full rounded-xl bg-transparent px-6 py-3.5 text-base text-black placeholder:text-gray-500 focus:outline-none',
!inputValue && !isFocused && 'text-transparent placeholder-transparent',
)}
What we've done:
- Created a list of helpful search suggestions
- Built a component that animates between these suggestions
- Made the placeholder animate only when the input is unfocused and empty
- Made the input text transparent when unfocused and empty (so the animated placeholder shows through)
This creates a nice subtle effect that helps users understand what they can search for. The animation adds visual interest to the search box while providing useful suggestions.
Part 3: Keyboard Shortcut Integration
Now let's make the ⌘K shortcut actually work:
// Import the keyboard shortcut hook
import { useKeyboardShortcut } from '~/hooks/useKeyboardShortcut'
// Custom hook for toggling search with keyboard shortcut
const useToggleOmniSearchKeyboardShortcut = (
searchInputRef: React.RefObject<HTMLInputElement>,
isFocused: boolean,
setIsFocused: (isFocused: boolean) => void,
) => {
useKeyboardShortcut(
'k',
() => {
if (searchInputRef.current) {
if (isFocused) {
searchInputRef.current.blur()
setIsFocused(false)
} else {
searchInputRef.current.focus()
}
}
},
{ metaKey: true },
)
}
Then add to your main component:
// Add a ref for the input
const searchInputRef = useRef<HTMLInputElement>(null)
// Use the keyboard shortcut hook
useToggleOmniSearchKeyboardShortcut(searchInputRef, isFocused, setIsFocused)
// Add the ref to your input
<input
ref={searchInputRef}
// ... other props
/>
What we've done:
- Created a custom hook that listens for the ⌘K keyboard shortcut
- Made it toggle focus on the search input
- Properly closes the search when the shortcut is pressed while the search is open
This delivers a premium search experience where users can quickly access search from anywhere in your app with a simple keyboard shortcut.
Part 4: Adding the Search Results Panel
Now let's create the expandable panel that shows the search results:
// Add these imports
import { AnimatePresence, motion } from 'framer-motion'
import Link from 'next/link'
// Define the animation variants
const expandAnimationVariants = {
hidden: { opacity: 0, scale: 0.98, transition: { duration: 0.2 } },
visible: { opacity: 1, scale: 1, transition: { duration: 0.2 } },
}
// Create a simple FilterChip component for default suggestions
interface FilterChipProps {
label: string
href?: string
}
function FilterChip({ label, href = '#' }: FilterChipProps) {
return (
<Link
href={href}
className="whitespace-nowrap rounded-full border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 hover:bg-gray-300 hover:text-black"
>
{label}
</Link>
)
}
// Add some example categories and articles for the default view
const categories = [
{ id: 'cat1', name: 'CRM Software', slug: '/overview/crm' },
{ id: 'cat2', name: 'AI Notetakers', slug: '/overview/ai-notetaking' },
{ id: 'cat3', name: 'Recruiting', slug: '/overview/ats' },
{ id: 'cat4', name: 'Project Management', slug: '/overview/project-management' },
]
const articles = [
{
id: 'art1',
name: 'Best CRM for SaaS startups',
slug: '/best/crm-for-saas-startups',
},
{
id: 'art2',
name: 'Best AI lead generation tools',
slug: 'best/ai-lead-generation',
},
// Add 2-3 more articles...
]
Now add the results panel to your main component:
{/* Add this after the input container */}
<AnimatePresence initial={false}>
{isFocused && (
<motion.div
key="expandable-content"
initial="hidden"
animate="visible"
exit="hidden"
variants={expandAnimationVariants}
className="absolute left-0 right-0 top-full z-20 mt-3 space-y-2 overflow-hidden rounded-xl bg-white px-6 py-3 text-gray-800 shadow-lg"
>
{/* Default view when input is empty */}
{!inputValue && (
<>
<div className="mb-6">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-medium text-base">Categories</h3>
</div>
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<FilterChip key={cat.id} label={cat.name} href={cat.slug} />
))}
</div>
</div>
<div>
<h3 className="mb-3 font-medium text-base">Articles</h3>
<div className="flex flex-wrap gap-2">
{articles.map((article) => (
<FilterChip key={article.id} label={article.name} href={article.slug} />
))}
</div>
</div>
</>
)}
{/* Search results will go here when input has a value */}
{inputValue && (
<div>
<div className="text-gray-500">Searching...</div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
What we've done:
- Created an expandable panel that shows when the search is focused
- Added a default view with suggested categories and articles when no search term is entered
- Added a placeholder for actual search results
- Used Framer Motion for smooth animations when opening and closing the panel
This creates a polished experience where the search panel smoothly expands and collapses with nice animations.
Part 5: Setting Up the Backend API
Now let's create the backend API for our search. First, we'll set up a tRPC router:
// ~/server/api/routers/home/router.ts
import { z } from 'zod'
import { createTRPCRouter, publicProcedure } from '~/server/api/trpc'
import { getOmniSearchResults } from './functions/getOmniSearchResults'
export const homeRouter = createTRPCRouter({
// Other routes...
getOmniSearchResults: publicProcedure
.input(z.object({ searchTerm: z.string() }))
.query(async ({ ctx, input }) => {
return getOmniSearchResults(input.searchTerm, ctx.prisma)
}),
})
Now, let's implement the search function that queries multiple tables:
// ~/server/api/routers/home/functions/getOmniSearchResults.ts
import { PrismaClient } from '@prisma/client'
export const getOmniSearchResults = async (searchTerm: string, prisma: PrismaClient) => {
// Early return if search term is empty
if (!searchTerm.trim())
return { productApplications: [], headToHeads: [], bestXForYBlogPosts: [], subcategories: [] }
// Format search term for Prisma's search
const prismaSearchTerm = '*' + searchTerm.trim() + '*'
// Query all tables in parallel for better performance
const [productApplications, headToHeads, bestXForYBlogPosts, subcategories] = await Promise.all([
fetchProductApplicationsData(prisma, prismaSearchTerm),
fetchHeadToHeadsData(prisma, prismaSearchTerm),
fetchBestXForYBlogPostsData(prisma, prismaSearchTerm),
fetchSubcategoriesData(prisma, prismaSearchTerm),
])
// Format all results and return them
const combinedResults = {
productApplications: formatProducts(productApplications),
headToHeads: formatHeadToHead(headToHeads),
bestXForYBlogPosts: formatBestXForY(bestXForYBlogPosts),
subcategories: formatSubcategories(subcategories),
}
return combinedResults
}
Next, let's implement one of the table fetching functions to see how it works:
// Example of one of our fetch functions
const fetchProductApplicationsData = async (prisma: PrismaClient, searchTerm: string) => {
return prisma.productApplication.findMany({
where: {
isPublic: true,
product: { name: { search: searchTerm } },
},
select: {
id: true,
slug: true,
product: { select: { name: true } },
subcategory: { select: { name: true } },
logoUrl: true,
},
take: 2, // Limit results for performance
})
}
// Format the results into a consistent shape
function formatProducts(
productApplications: Awaited<ReturnType<typeof fetchProductApplicationsData>>,
) {
return productApplications.map((pa) => ({
id: pa.id,
tableName: 'productApplication',
name: pa.product.name,
url: `/product/${pa.slug}`,
subcategory: pa.subcategory?.name,
logoUrl: pa.logoUrl,
}))
}
// Export the type for use in the frontend
export type OmniSearchProductApplication = ReturnType<typeof formatProducts>[number]
The other table fetching functions follow a similar pattern, but are customized for each specific model.
What we've done:
- Created a tRPC endpoint that accepts a search term
- Built a function that searches multiple tables in parallel
- Implemented selective field selection to minimize data transfer
- Limited the number of results from each table for performance
- Created TypeScript types for the frontend to use
This gives us a powerful and efficient backend for our search component.
Part 6: Connecting Frontend to Backend
Now let's connect our frontend component to the backend API:
// Add these imports
import { useDebounce } from '~/hooks/useDebounce'
import { api } from '~/utils/api'
import { useMemo } from 'react'
import throttle from 'lodash.throttle'
import { AnalyticsEvent, trackEvent } from '~/utils/analytics/events'
import {
OmniSearchBestXForYBlogPost,
OmniSearchHeadToHead,
OmniSearchProductApplication,
OmniSearchSubcategory,
} from '~/server/api/routers/home/functions/getOmniSearchResults'
// Add to your main component
function OmniSearch({ className }: OmniSearchProps) {
// ... existing code
// Debounce search to prevent excessive API calls
const debouncedSearchTerm = useDebounce(inputValue, 200)
// Query the API with the debounced search term
const { data: searchData, isLoading: isLoadingOmniSearch } =
api.home.getOmniSearchResults.useQuery({
searchTerm: debouncedSearchTerm,
})
// Check if we have any results
const hasSearchResults = Object.keys(searchData ?? {}).some(
(key) => (searchData?.[key as keyof typeof searchData]?.length ?? 0) > 0,
)
// Throttled analytics event for tracking searches
const emitSearchTrackEvent = useMemo(
() =>
throttle(
(searchTerm: string, foundResults: boolean) => {
trackEvent(AnalyticsEvent.PRODUCT_SEARCH, {
partialSearchTerm: searchTerm,
foundResults,
})
},
500,
{ leading: false, trailing: true },
),
[],
)
// Update your input's onChange handler
onChange={(e) => {
const searchTerm = e.target.value
setInputValue(searchTerm)
emitSearchTrackEvent(searchTerm, hasSearchResults)
}}
}
What we've done:
- Connected to our tRPC API using a hook
- Added debouncing to prevent too many API calls while typing
- Added throttled analytics tracking
- Checked if we have any search results to display
Part 7: Displaying Search Results by Category
Now let's display our search results by category in the results panel:
// Define result display logic
const displayHeadToHeadResults = useMemo(() => {
// Only show head-to-heads if we have few other results
return searchData?.productApplications.length === 0 || searchData?.subcategories.length === 0
}, [searchData])
const displayArticlesResults = useMemo(() => {
// Only show articles if we have no other primary results
return searchData?.productApplications.length === 0 && searchData?.subcategories.length === 0
}, [searchData])
// Now replace your search results area (where we had "Searching...")
{inputValue && (
<>
{isLoadingOmniSearch && <div className="text-gray-500">Searching...</div>}
{!isLoadingOmniSearch && !hasSearchResults && (
<div className="">
<h3 className="font-medium text-base">No results found</h3>
<p className="pt-3 text-gray-900">Please try a different search.</p>
</div>
)}
{!isLoadingOmniSearch &&
searchData &&
searchData.productApplications.length > 0 && (
<div>
<h3 className="font-medium text-base">Products</h3>
<ul className="flex flex-wrap gap-2">
{searchData.productApplications.map((result) => (
<ProductApplicationResult key={result.id} result={result} />
))}
</ul>
</div>
)}
{!isLoadingOmniSearch && searchData && searchData.subcategories.length > 0 && (
<div>
<h3 className="font-medium text-base">Categories</h3>
<ul className="mt-2 flex flex-wrap gap-2">
{searchData.subcategories.map((result) => (
<SubcategoryResult key={result.id} result={result} />
))}
</ul>
</div>
)}
{/* Show head-to-heads conditionally */}
{!isLoadingOmniSearch &&
searchData &&
searchData.headToHeads.length > 0 &&
displayHeadToHeadResults && (
<div>
<h3 className="font-medium text-base">Compare</h3>
<ul className="flex flex-wrap ">
{searchData.headToHeads.map((result) => (
<HeadToHeadResult key={result.id} result={result} />
))}
</ul>
</div>
)}
{/* Show articles conditionally */}
{!isLoadingOmniSearch &&
searchData &&
searchData.bestXForYBlogPosts.length > 0 &&
displayArticlesResults && (
<div>
<h3 className="font-medium text-base">Articles</h3>
<ul className="mt-2 flex flex-wrap gap-2">
{searchData.bestXForYBlogPosts.map((result) => (
<BestXForYBlogPostResult key={result.id} result={result} />
))}
</ul>
</div>
)}
</>
)}
Now we need to add our result rendering components:
// Add these result rendering components
const ProductApplicationResult = ({ result }: { result: OmniSearchProductApplication }) => {
return (
<Link
href={result.url}
className="flex w-full items-center gap-4 rounded-xl bg-white p-2 hover:bg-gray-300"
>
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full p-2">
<img className="h-6 w-6" src={result.logoUrl ?? ''} alt={result.name} />
</div>
<div className="inline-flex flex-1 flex-col items-start justify-center">
<div className="text-base leading-normal text-black">{result.name}</div>
<div className="text-xs leading-none text-slate-500">{result.subcategory}</div>
</div>
</Link>
)
}
const HeadToHeadResult = ({ result }: { result: OmniSearchHeadToHead }) => {
return (
<Link
href={result.url}
className="flex w-full items-center gap-4 rounded-xl bg-white p-2 py-3 hover:bg-gray-300"
>
<div className="flex items-center gap-1 rounded bg-gray-100 p-0.5">
<img
className="h-5 w-5"
src={result.productApplicationA.logoUrl ?? ''}
alt={result.productApplicationA.name}
/>
<img
className="h-5 w-5"
src={result.productApplicationB.logoUrl ?? ''}
alt={result.productApplicationB.name}
/>
</div>
<div className="text-gray-900">
{result.productApplicationA.name} vs {result.productApplicationB.name}
</div>
</Link>
)
}
const BestXForYBlogPostResult = ({ result }: { result: OmniSearchBestXForYBlogPost }) => {
return <FilterChip label={result.name} href={result.url} />
}
const SubcategoryResult = ({ result }: { result: OmniSearchSubcategory }) => {
return (
<Link
href={`/overview/${result.slug}`}
className="flex w-full items-center gap-4 rounded-xl border bg-white p-2 py-4 hover:bg-gray-300"
>
{/* Icon Container */}
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-gray-300 p-2">
{categoryDataMap[result.slug as SupportedSubcategorySlug].icon}
</div>
{/* Text Content */}
<div className="flex flex-col">
<div className="text-base text-black">{result.name}</div>
<div className="text-slate-500">
{categoryDataMap[result.slug as SupportedSubcategorySlug].subtitle}
</div>
</div>
</Link>
)
}
What we've done:
- Created a conditional logic to prioritize which types of results to show
- Added dedicated rendering components for each result type
- Handled loading and empty states
- Created visually distinct UI for each result type
Each result type has its own specialized display format:
- Products show an icon, name, and category
- Head-to-heads show both product logos side by side
- Articles use the simple chip format
- Categories show an icon, name, and description
Performance Optimizations
Our component already includes several performance optimizations:
-
Debouncing: We don't trigger a search on every keystroke, only after the user pauses typing
-
Throttling: Analytics events are throttled to prevent too many events
-
Parallel Queries: We query all tables in parallel rather than sequentially
-
Limited Results: We only fetch 2-3 results from each table
-
Selective Data: We only request the fields we actually need
-
Early Return: We avoid unnecessary queries when the search term is empty
-
Conditional Rendering: We only show certain result types when appropriate
These optimizations ensure our search component stays fast and responsive, even when searching across multiple database tables.
How to Use the Component
To use this component in your Next.js application:
- Add it to your layout or page component:
import { OmniSearch } from '~/components/OmniSearch'
export default function Layout() {
return (
<header className="sticky top-0 z-50 flex h-16 w-full items-center px-4 shadow-sm">
<div className="mr-auto flex-1" />
{/* Omnisearch Component */}
<div className="w-full max-w-md px-4">
<OmniSearch />
</div>
<div className="ml-auto flex-1" />
</header>
)
}
-
Make sure your database schema has the necessary tables and fields
-
Set up proper indexes on your search fields for performance
Extending the Component
You can take this component further with these enhancements:
-
Keyboard Navigation: Add up/down arrow support to navigate through results
const [selectedResultIndex, setSelectedResultIndex] = useState(-1) // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault() setSelectedResultIndex(prev => Math.min(prev + 1, totalResults - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault() setSelectedResultIndex(prev => Math.max(prev - 1, -1)) } else if (e.key === 'Enter' && selectedResultIndex >= 0) { e.preventDefault() // Navigate to selected result } }
-
Search History: Store recent searches for quick access
// In your component const [recentSearches, setRecentSearches] = useState<string[]>([]) // When a search is executed const handleSearch = () => { if (inputValue && !recentSearches.includes(inputValue)) { setRecentSearches(prev => [inputValue, ...prev].slice(0, 5)) } } // Display recent searches in the dropdown {!inputValue && recentSearches.length > 0 && ( <div> <h3 className="font-medium text-base">Recent Searches</h3> <div className="flex flex-wrap gap-2"> {recentSearches.map((search, i) => ( <button key={i} onClick={() => setInputValue(search)} className="text-sm text-blue-600 hover:underline" > {search} </button> ))} </div> </div> )}
-
Advanced Filtering: Add the ability to filter results by type
const [activeFilters, setActiveFilters] = useState<string[]>([]) // Filter toggle buttons <div className="flex gap-2 mb-2"> {['Products', 'Categories', 'Articles'].map(filter => ( <button key={filter} onClick={() => toggleFilter(filter)} className={`px-2 py-1 text-xs rounded ${ activeFilters.includes(filter) ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700' }`} > {filter} </button> ))} </div>
Conclusion
Building a universal search component significantly enhances the user experience of your application. By following this guide, you've learned how to:
- Create an elegant search UI with animations and keyboard shortcuts
- Build an efficient backend that searches multiple tables in parallel
- Display different result types with appropriate styling
- Implement performance optimizations to keep everything fast
This approach works well for most applications. For extremely large datasets, you might want to consider specialized search services like Algolia or Elasticsearch, but the pattern remains the same—create a unified interface that makes finding information as seamless as possible.
With this omnisearch component in place, your users will be able to quickly find exactly what they're looking for, no matter which part of your application it lives in.