BTST

Search Plugin

Authorized discovery across optional public content providers

Full-stackReleased · Preview

Best for

Teams adding shared content discovery to an existing React application.

Search public content across the capabilities installed in your application.

Real BTST Search page showing results from registered Blog and application sources.
Search combines registered sources while preserving their application-owned permission checks.

BTST supplies

  • Request-authorized discovery across explicitly registered providers
  • SSR-aware search results and an embeddable source-scoped dialog
  • Optional Blog, CMS, School, Ecommerce, and application providers

You supply

  • Source providers and their authorization policy
  • The application shell and deployment

You own and customize

Your application registers source providers. Each source keeps its authorization and publication rules.

Compatibility and dependencies

Maintained: Next.js 15+ App Router, React Router v7, TanStack Start.

Requires: A BTST database adapter.

External services: None required.

From registration to result

A semantic workflow, not a setup shortcut

  1. 1Register sources

    Choose optional content providers or supply an application provider.

  2. 2Find content

    Use the shared results page or a source-scoped search dialog.

  3. 3Open a result

    Navigate to the source through the application router.

Search adds a /search?q=... results route and an embeddable discovery dialog. Install @btst/plugin-search@1.0.0 with the current Core. Search has no runtime dependency on Blog, CMS, School, Ecommerce, or a hosted search service.

Installation

Select search in btst init, or pass --plugins search,blog. The Next.js, React Router, and TanStack Start scaffolds register Search on both stacks and import @btst/plugin-search/css. Selecting Blog, School, or Ecommerce alongside Search adds its optional provider. CMS content types always require explicit application configuration.

lib/stack.ts
import { createBackendStack } from "@btst/stack/api"
import { searchBackendPlugin } from "@btst/plugin-search/api"
import { blogBackendPlugin } from "@btst/plugin-blog/api"
import {
  blogSearchProvider,
  type BlogSearchOperations,
} from "@btst/plugin-search/providers"

export const myStack = createBackendStack({
  basePath: "/api/data",
  adapter,
  auth: serverAuth,
  plugins: {
    blog: blogBackendPlugin(),
    search: searchBackendPlugin({
      providers: [blogSearchProvider({
        operations: (request): BlogSearchOperations =>
          myStack.forRequest(request).operations.blog,
      })],
    }),
  },
})

The provider resolver runs when a query arrives. Its return type annotation avoids circular inference when referring to the stack being initialized. Register searchClientPlugin under the canonical search key on the client stack. Configure the shared API origin, page prefix, QueryClient, router, and auth provider using the framework installation guide.

Authorization

Include searchPermissions from @btst/plugin-search/permissions in the application's defineAuthorization contract. search.query.read receives the validated source IDs as permission facts. Grant it only for sources the caller may query. Every source must also authorize its own operation before returning records, snippets, or continuation metadata.

The built-in adapters call request-bound source operations using the original Request. Blog uses published post queries, School uses the public course catalog, and Ecommerce uses its public product projection. Draft posts, unpublished courses, private lessons, and nonpublic products do not participate. Do not replace request-bound operations with trusted or raw getters. Client query parameters cannot supply identity or grant source permissions.

Shared discovery UI and Blog migration

import { SearchInput, SearchDialog } from "@btst/plugin-search/client/components"

// Cross-plugin discovery over the registered sources.
<SearchInput />

// Fixed-source discovery; URL source filters cannot widen this scope.
<SearchInput sources={["blog"]} buttonText="Search Posts" />

SearchInput accepts className, triggerClassName, placeholder, buttonText, and emptyMessage. SearchDialog supports controlled embedding. Both use the same resources and query keys as the results route. The route supports query/source URL state and bounded pagination; keyboard controls, loading, errors, and empty results are part of the shared UI. Result navigation uses the configured application router.

Blog's built-in standalone dialog has moved to Search. Applications that use PostsList or the built-in Blog pages can retain Blog-only discovery with the optional BlogPluginOverrides.search slot:

<StackProvider
  stack={clientStack}
  router={router}
  overrides={{
    blog: {
      uploadImage,
      search: <SearchInput sources={["blog"]} buttonText="Search Posts" />,
    },
  }}
>
  {children}
</StackProvider>

The CLI adds this slot when both plugins are selected. Blog installed alone continues publishing, administration, and its domain search operations without Search. It renders no standalone discovery trigger until the slot is supplied. The superseded internal shared/search-input, shared/search-modal, and shared/highlight-text modules are removed; import the supported Search components entry instead. Blog's public usePostSearch hook remains available for source-local workflows. Existing School/Ecommerce catalog filters, CMS and Form Builder management search, relationship selectors, and other feature-local controls retain their own behavior and do not require Search.

Providers

A SearchProvider has a stable id, a label, and an asynchronous search callback. The callback receives validated q, bounded limit, the original request, and the resolved identity. Return already-authorized records with resourceId, title, a plain-text snippet, and an application-relative href, plus hasMore. Never use a client source ID to dynamically import code or call an arbitrary URL.

import type { SearchProvider } from "@btst/plugin-search/types"

const documents: SearchProvider = {
  id: "documents",
  label: "Documents",
  async search({ q, limit, request, identity }) {
    return findPublicDocuments({ q, limit, request, identity })
  },
}

blogSearchProvider, schoolSearchProvider, and ecommerceSearchProvider accept a request-bound operations resolver and an optional destination basePath. Default mounts are /pages/blog, /pages/learn, and /pages/shop/products. Set these when the application mounts source pages elsewhere. Blog search titles are limited to 500 characters. A Blog post whose complete encoded destination exceeds 2,000 characters is omitted from Search; truncated reports that projection limit. Included destinations stay exact, and the original Blog title and slug are unchanged.

Built-in provider snippets process only the first 2,000 UTF-16 code units of the source text before stripping markup and normalizing whitespace. The resulting snippet remains limited to 240 code units. Long markup or whitespace prefixes can leave a shorter or empty snippet; source matching and stored content are unchanged. The exported searchSnippet helper uses the same bounds.

cmsSearchProvider({ typeSlug, label, search }) requires an explicit public projection callback. No CMS content type is searched automatically. Do not pass a generic table getter for a domain-owned type: publication, expiry, ownership, and other domain rules belong in that domain's public projection. The adapter rejects reserved domain content-type prefixes; this is an additional guard, not a replacement for the application's visibility policy. Job Board is not a v1 provider. Private Helpdesk, CRM, and AI Chat federation is outside this release.

Limits, ordering, and errors

GET /api/data/search and myStack.forRequest(request).operations.search.search share the same validated operation. Inputs are q, optional comma-separated sources, page (1–10), and limit (1–20). Query text is trimmed and limited to 200 characters. Blank text returns no records without scanning providers. At most eight explicitly registered sources can run per query.

Providers return at most 201 candidates each. Search exposes a bounded window of 200 records, ordered by source ID and then resource ID, with up to ten pages. There is no global relevance score or exact global total. Duplicate resource IDs within a source collapse to the first record; identical IDs in different sources remain distinct through their sourceId:resourceId key. Pagination is recomputed per request, so concurrent source edits can change later pages. truncated indicates that more candidates exist beyond the window; it is not an exact count.

A provider failure or invalid result fails the query with SEARCH_PROVIDER_FAILED (HTTP 502), so unavailable sources do not masquerade as complete empty results. Unknown source filters are rejected.

These bounds constrain fan-out, returned candidates, and response size. Existing source operations can scan more records internally to perform text matching; Search does not make those scans indexed or constant-time. Use suitable database indexes or an application provider for larger collections. External indexes, background indexing, embeddings, and AI answers are separate follow-up work.

SSR and cache identity

The generated request-aware routes forward query parameters and credential headers into a fresh server client stack. searchClientPlugin({ searchParams, identityPartition }) lets manual integrations supply the same values. Keep the browser stack free of forwarded server credentials. The page and dialog share resource definitions from @btst/plugin-search/query-keys and hooks from @btst/plugin-search/client/hooks.

When source results can vary by identity, provide the current identity partition and clear cached data when identity changes. Use the shared StackProvider auth lifecycle; do not reuse one personalized server QueryClient across requests. Do not statically generate personalized search results.

Ejecting the UI

Install the btst-search item from the shadcn registry to customize the view layer. Data hooks stay in the Search package. Use the client plugin's page override API to replace the results view.