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.

The search blockWebReact Native

1.What you build

No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.

2.Add the components

Web: shadcn

terminal
npx shadcn add button dialog input

Native: 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
command-search.tsx
"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 example
command-search.tsx
import { 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>
    </>
  );
}