# Command search

> A search dialog over the search index, with favorites and recents before the user types.

Source: https://bettersupabase.com/docs/registry/ui-recipes/command-search

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useCommandSearch` from `better-supabase/blocks/search/react`; the [search block](https://bettersupabase.com/blocks/search) must be installed (`npx better-supabase add search`). Its page on bettersupabase.com is [/recipes/command-search](https://bettersupabase.com/recipes/command-search).

Web, with shadcn components:

```bash
npx shadcn add button dialog input
```

```tsx title="apps/examples/nextjs/src/features/search/components/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";
import { blockConfig } from "@/lib/supabase/blocks.generated";

/** The API schema `gen` wrote for the search block. */
const API_SCHEMA = blockConfig.search.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="hidden text-xs text-muted-foreground 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-sm text-destructive">
                {t("Search failed. Try again.")}
              </p>
            )}
            {search.groups.length === 0 && !search.loading ? (
              <p className="text-sm text-muted-foreground">
                {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="mb-1 text-xs font-medium text-muted-foreground">
                  {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="block rounded-md px-2 py-1.5 outline-none hover:bg-muted focus-visible:bg-muted"
                      >
                        {item.title ?? item.subjectId}
                      </Link>
                    </li>
                  ))}
                </ul>
              </section>
            ))}
          </div>
        </DialogContent>
      </Dialog>
    </>
  );
}
```

React Native, with `Modal`, `Pressable`, `SectionList`, `Text`, `TextInput`, `View` from `react-native`:

```tsx title="apps/examples/expo-powersync/src/components/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>
    </>
  );
}
```