All recipes
Recipe
Command search
A search dialog over the search index, with favorites and recents before the user types. Web and native call useCommandSearch from better-supabase/blocks/search/react.
1.What you build
No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.
2.Add the components
Web: shadcn
npx shadcn add button dialog inputNative: React Native
The snippet uses Modal, Pressable, SectionList, Text, TextInput, View from react-native, so it needs no styling library. For shadcn-style native components, React Native Reusables serves them from a registry the shadcn CLI reads.
3.Install the glue
Install the block with npx better-supabase sql add search. The glue items are copied into your app, so edit them as you need.
Web
None: the hook calls the block from the client.
Native
None: the hook calls the block from the client.
4.Write the component
Web: React and shadcn
View in the example"use client";
import { useCommandSearch } from "better-supabase/blocks/search/react";
import { SearchIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Link } from "@/i18n/navigation";
/** `blocks.search.api` in better-supabase.config.ts. */
const API_SCHEMA = "api";
/** Cmd+K over the search index: customers by name, plus recents and favorites. */
export function CommandSearch() {
const t = useExtracted("search");
const search = useCommandSearch({ schema: API_SCHEMA });
const heading = (key: string) => {
if (key === "favorites") return t("Favorites");
if (key === "recents") return t("Recent");
if (key === "customer") return t("Customers");
return key;
};
return (
<>
<Button
variant="ghost"
size="sm"
aria-label={t("Search")}
aria-keyshortcuts="Meta+K Control+K"
onClick={() => {
search.setOpen(true);
}}
>
<SearchIcon />
<span className="hidden sm:inline">{t("Search")}</span>
<kbd className="text-muted-foreground hidden text-xs sm:inline">⌘K</kbd>
</Button>
<Dialog open={search.open} onOpenChange={search.setOpen}>
<DialogContent className="gap-4 sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("Search")}</DialogTitle>
<DialogDescription>
{t("Find a customer by name, even with a typo.")}
</DialogDescription>
</DialogHeader>
<Input
type="search"
value={search.query}
onChange={(event) => {
search.setQuery(event.target.value);
}}
placeholder={t("Search customers")}
aria-label={t("Search customers")}
/>
<div className="max-h-80 overflow-y-auto" aria-live="polite">
{search.error === undefined ? null : (
<p className="text-destructive text-sm">
{t("Search failed. Try again.")}
</p>
)}
{search.groups.length === 0 && !search.loading ? (
<p className="text-muted-foreground text-sm">
{search.query.trim() === ""
? t("Type to search.")
: t("No matches.")}
</p>
) : null}
{search.groups.map((group) => (
<section key={group.key} className="mb-3">
<h3 className="text-muted-foreground mb-1 text-xs font-medium">
{heading(group.key)}
</h3>
<ul>
{group.items.map((item) => (
<li key={`${item.subjectType}:${item.subjectId}`}>
<Link
href={item.url ?? "/"}
onClick={() => void search.select(item)}
className="hover:bg-muted focus-visible:bg-muted block rounded-md px-2 py-1.5 outline-none"
>
{item.title ?? item.subjectId}
</Link>
</li>
))}
</ul>
</section>
))}
</div>
</DialogContent>
</Dialog>
</>
);
}
Native: React Native
View in the exampleimport { useCommandSearch } from "better-supabase/blocks/search/react";
import {
Modal,
Pressable,
SectionList,
Text,
TextInput,
View,
} from "react-native";
import { API_SCHEMA } from "../lib/blocks";
function heading(key: string): string {
if (key === "favorites") return "Favorites";
if (key === "recents") return "Recent";
if (key === "customer") return "Customers";
return key;
}
/** Search over the search index: customers by name, plus recents and favorites. */
export function CommandSearch() {
// Native has no Cmd+K, so the button opens it.
const search = useCommandSearch({ schema: API_SCHEMA, shortcut: false });
return (
<>
<Pressable
accessibilityRole="button"
onPress={() => {
search.setOpen(true);
}}
style={{ padding: 8 }}
>
<Text>Search</Text>
</Pressable>
<Modal
visible={search.open}
animationType="slide"
onRequestClose={() => {
search.setOpen(false);
}}
>
<View style={{ flex: 1, gap: 12, padding: 16 }}>
<TextInput
value={search.query}
onChangeText={search.setQuery}
placeholder="Search customers"
accessibilityLabel="Search customers"
style={{ borderWidth: 1, borderRadius: 8, padding: 8 }}
/>
{search.error === undefined ? null : (
<Text>Search failed. Try again.</Text>
)}
<SectionList
sections={search.groups.map((group) => ({
key: group.key,
title: heading(group.key),
data: group.items,
}))}
keyExtractor={(item) => `${item.subjectType}:${item.subjectId}`}
renderSectionHeader={({ section }) => (
<Text style={{ color: "#737373", marginTop: 12 }}>
{section.title}
</Text>
)}
renderItem={({ item }) => (
<Pressable
accessibilityRole="link"
onPress={() => void search.select(item)}
style={{ paddingVertical: 8 }}
>
<Text>{item.title ?? item.subjectId}</Text>
</Pressable>
)}
ListEmptyComponent={
search.loading ? null : (
<Text>
{search.query.trim() === ""
? "Type to search."
: "No matches."}
</Text>
)
}
/>
<Pressable
accessibilityRole="button"
onPress={() => {
search.setOpen(false);
}}
>
<Text>Close</Text>
</Pressable>
</View>
</Modal>
</>
);
}