Skip to content
Recipes

Search your backend

Connect sources to REST, Algolia, Supabase, GraphQL, tRPC and TanStack Query.

A source is just an async function, so any backend works. Each recipe returns items with href, so selecting one navigates with your router.

REST

import type { Source } from '@qikbar/qikbar'

export const projects: Source = {
  id: 'projects',
  title: 'Projects',
  minQueryLength: 2,
  async search({ query, signal }) {
    const res = await fetch(`/api/projects?q=${encodeURIComponent(query)}&limit=8`, { signal })
    if (!res.ok) return []
    const rows: { id: string; name: string; owner: string; updatedAt: string }[] = await res.json()
    return rows.map((p) => ({
      id: `project:${p.id}`,
      title: p.name,
      subtitle: p.owner,
      icon: 'folder',
      href: `/projects/${p.id}`,
      accessories: [{ date: p.updatedAt }],
    }))
  },
}

Always pass signal to fetch. Qikbar cancels the request when the user keeps typing, so stale responses never overwrite fresh ones.

Algolia

Use the REST API directly (no SDK needed) and keep the search-only key in the client:

const APP_ID = 'YOUR_APP_ID'
const SEARCH_KEY = 'YOUR_SEARCH_ONLY_KEY'

export const docs: Source = {
  id: 'algolia',
  title: 'Docs',
  debounce: 80,
  async search({ query, signal }) {
    const res = await fetch(`https://${APP_ID}-dsn.algolia.net/1/indexes/docs/query`, {
      method: 'POST',
      signal,
      headers: { 'x-algolia-application-id': APP_ID, 'x-algolia-api-key': SEARCH_KEY },
      body: JSON.stringify({ query, hitsPerPage: 8 }),
    })
    const { hits } = await res.json()
    return hits.map((h: any) => ({ id: h.objectID, title: h.title, subtitle: h.section, icon: 'book', href: h.url }))
  },
}

Supabase

import { supabase } from '@/lib/supabase'

export const customers: Source = {
  id: 'customers',
  title: 'Customers',
  async search({ query, signal }) {
    const { data } = await supabase
      .from('customers')
      .select('id, name, email, plan')
      .or(`name.ilike.%${query}%,email.ilike.%${query}%`)
      .limit(8)
      .abortSignal(signal)
    return (data ?? []).map((c) => ({
      id: `customer:${c.id}`,
      title: c.name,
      subtitle: c.email,
      icon: 'user',
      href: `/customers/${c.id}`,
      accessories: [{ tag: { value: c.plan, color: c.plan === 'pro' ? 'purple' : 'gray' } }],
    }))
  },
}

Row-level security still applies. The source runs as the signed-in user, so they only see what they’re allowed to see.

GraphQL

async search({ query, signal }) {
  const res = await fetch('/graphql', {
    method: 'POST',
    signal,
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ query: 'query($q: String!) { searchIssues(q: $q, first: 8) { id title number } }', variables: { q: query } }),
  })
  const { data } = await res.json()
  return data.searchIssues.map((i) => ({ title: i.title, subtitle: `#${i.number}`, icon: 'circle', href: `/issues/${i.number}` }))
}

tRPC

import { trpc } from '@/utils/trpc' // a vanilla tRPC client

export const issues: Source = {
  id: 'issues',
  title: 'Issues',
  search: async ({ query, signal }) =>
    (await trpc.issues.search.query({ q: query }, { signal })).map((i) => ({ title: i.title, href: `/issues/${i.id}` })),
}

TanStack Query cache

Reuse your query cache so results that are already loaded appear instantly:

import { queryClient } from '@/lib/query'

export const teams: Source = {
  id: 'teams',
  title: 'Teams',
  search: ({ query, signal }) =>
    queryClient.fetchQuery({
      queryKey: ['teams', 'search', query],
      queryFn: () => api.teams.search(query, { signal }),
      staleTime: 30_000,
    }).then((teams) => teams.map((t) => ({ title: t.name, icon: 'users', href: `/teams/${t.slug}` }))),
}

Search everything once, filter locally

For small datasets (fewer than about 5,000 rows), fetch everything once and let Qikbar rank it. Every keystroke after the first is instant:

let cache: Promise<Item[]> | undefined

export const people: Source = {
  id: 'people',
  title: 'People',
  filter: true,
  debounce: 0,
  search: () => (cache ??= fetch('/api/people').then((r) => r.json()).then((ps) => ps.map(toItem))),
}
Edit this page on GitHub