BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Payload CMS Search: Build a Public Multi-Tenant Index

Payload CMS Search: Build a Public Multi-Tenant Index

Step-by-step guide using @payloadcms/plugin-search, migration, Next.js public API, and tenant-aware cross-domain…

20th March 2026·Updated on:19th August 2026··
Payload
Payload CMS Search: Build a Public Multi-Tenant Index

Evaluating Payload CMS Implementation Costs?

Scope design, content structure, and migration hours to estimate a realistic production timeline and hosting setup.

Try the Cost EstimatorGet a Second Opinion

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

Related Posts:

  • •Mastering Payload CMS API: Authentication & Queries Explained
  • •How to Structure Payload CMS Collections for Long-Term Maintainability
  • •Payload CMS Custom Admin Fields and Views: A Guide to @payloadcms/ui
📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

Mastering Payload CMS API: Authentication & Queries Explained
Mastering Payload CMS API: Authentication & Queries Explained

5th November 2025

How to Structure Payload CMS Collections for Long-Term Maintainability
How to Structure Payload CMS Collections for Long-Term Maintainability

2nd October 2025

Payload CMS Custom Admin Fields and Views: A Guide to @payloadcms/ui
Payload CMS Custom Admin Fields and Views: A Guide to @payloadcms/ui

16th October 2025

Contents

  • Why Payload Search Is So Good Out of the Box
  • Extending `beforeSync` for Rich Text and Domain-Specific Collections
  • The Required Migration Step
  • Why Both Tenants Share One Search Collection
  • Building the Public Search API
  • The Frontend Bug That Made Valid Results Look Empty
  • Adding the Search Input to the Navbar
  • The Two-Domain Edge Case: Why Routing Breaks Without Tenant-Aware Links
  • Why This Architecture Works So Well
  • Conclusion
On this page:
  • Why Payload Search Is So Good Out of the Box
  • Extending `beforeSync` for Rich Text and Domain-Specific Collections
  • The Required Migration Step
  • Why Both Tenants Share One Search Collection
  • Building the Public Search API
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

Last week, I needed to finish a public search experience for a multi-tenant Payload CMS project. Getting @payloadcms/plugin-search working out of the box was already surprisingly good, but the real work started when I needed the same search UI to run on two public domains, return results from both tenants, and still send users to the correct site when they clicked a result.

This guide walks through the exact implementation. You will start with Payload’s search plugin, create the required migration, expose a public search API in Next.js, and then handle the edge case that matters in production: one shared search collection indexed across multiple tenants, but public result links that must resolve to different domains like adart.com and making-light.com.

Why Payload Search Is So Good Out of the Box

The first useful thing to understand is that @payloadcms/plugin-search already gives you most of the heavy lifting:

  • a dedicated search collection
  • indexing of configured collections
  • a searchable read model instead of querying each source collection directly
  • a reindex flow for backfilling existing content

That means you do not need to build your own search table manually unless you want to. In our case, the plugin-generated collection was exactly what we needed. The important shift was to treat that collection as a shared public index, not as tenant-owned content.

Here is the plugin setup.

ts
// File: src/payload/plugins/search.ts
import { searchPlugin } from '@payloadcms/plugin-search'
import type { BeforeSync } from '@payloadcms/plugin-search/types'
import { lexicalToMarkdown } from '@/lib/vector/lexical-to-markdown'
import { isSuperAdmin, userFromClientUser } from '@/payload/access-control'

function resolveTenantSlug(tenant: unknown): string {
  if (!tenant) return ''
  if (typeof tenant === 'object' && tenant !== null && 'slug' in tenant) {
    return (tenant as { slug: string }).slug
  }

  if (typeof tenant === 'number' || typeof tenant === 'string') {
    const id = String(tenant)
    if (id === '1') return 'adart'
    if (id === '2') return 'making-light'
  }

  return ''
}

function buildFullText(doc: Record<string, unknown>, title: string): string {
  const parts: string[] = [title]

  if (typeof doc.excerpt === 'string' && doc.excerpt) parts.push(doc.excerpt)
  if (typeof doc.description === 'string' && doc.description) parts.push(doc.description)
  if (typeof doc.sku === 'string' && doc.sku) parts.push(doc.sku)

  if (doc.content && typeof doc.content === 'object') {
    const markdown = lexicalToMarkdown(doc.content as Parameters<typeof lexicalToMarkdown>[0])
    if (markdown) parts.push(markdown)
  }

  return parts.join(' ').trim()
}

const beforeSync: BeforeSync = ({ originalDoc, searchDoc }) => {
  const doc = originalDoc as Record<string, unknown>
  const collectionSlug = searchDoc.doc.relationTo

  searchDoc.tenant = resolveTenantSlug(doc.tenant)
  searchDoc.contentType = collectionSlug
  searchDoc.sku = collectionSlug === 'products' && typeof doc.sku === 'string' ? doc.sku : ''
  searchDoc.fullText = buildFullText(doc, searchDoc.title || '')
  searchDoc.excerpt =
    typeof doc.excerpt === 'string'
      ? doc.excerpt
      : typeof doc.description === 'string'
        ? doc.description
        : ''
  searchDoc.slug = typeof doc.slug === 'string' ? doc.slug : ''
  searchDoc.hide = Boolean(doc.hide)

  return searchDoc
}

export const search = searchPlugin({
  collections: ['page', 'post', 'products', 'project', 'solution', 'industry', 'job_opening'],
  beforeSync,
  searchOverrides: {
    slug: 'search',
    admin: {
      hidden: ({ user }) => {
        if (!user) return true
        const validUser = userFromClientUser(user)
        return !isSuperAdmin(validUser)
      },
    },
    access: {
      read: () => true,
    },
    fields: ({ defaultFields }) => [
      ...defaultFields,
      { name: 'tenant', type: 'text', index: true },
      { name: 'contentType', type: 'text', index: true },
      { name: 'sku', type: 'text', index: true },
      { name: 'fullText', type: 'textarea' },
      { name: 'excerpt', type: 'textarea' },
      { name: 'slug', type: 'text' },
      { name: 'hide', type: 'checkbox', defaultValue: false },
    ],
  },
})

This code turns the plugin into a real shared search index. The important part is beforeSync. That is where each indexed row gets tenant metadata, normalized text fields, and any extra values you want to search against like SKU. That single hook is what makes the plugin usable in a multi-tenant public setup instead of just a default internal index.

Extending beforeSync for Rich Text and Domain-Specific Collections

The setup above works well when your indexed collections stay simple: text fields, a description, maybe a SKU. Collections with real rich text bodies or a different shape need more than that. On a separate multi-tenant project, I ran into this directly. The collections included FAQs with a question and a rich-text answer, recipes with structured ingredient lists and step-by-step instructions, and long-form Lexical content across products and blog posts. None of that indexes usefully with a flat title-and-excerpt approach.

The first problem is Lexical itself. Rich text fields save as a JSON AST, not plain text, and running LIKE against a serialized JSON column either fails outright or returns matches that have nothing to do with what the user typed. A dedicated extraction step has to run before anything gets written to the search collection.

ts
// File: src/payload/utilities/extract-plain-text.ts
export function extractLexicalText(content: unknown): string {
  if (!content || typeof content !== "object") return "";

  const extractNode = (node: LexicalNode | null | undefined): string => {
    if (!node || typeof node !== "object") return "";

    if (node.type === "text" && typeof node.text === "string") {
      return node.text;
    }

    if (Array.isArray(node.children)) {
      return node.children
        .map(extractNode)
        .filter(Boolean)
        .join(" ");
    }

    return "";
  };

  const root = (content as LexicalContent).root;
  if (root && Array.isArray(root.children)) {
    return root.children
      .map(extractNode)
      .filter(Boolean)
      .join(" ")
      .replace(/\s+/g, " ")
      .trim();
  }

  return "";
}

This walks the Lexical tree recursively, pulling out text nodes and joining them into a single normalized string. Whitespace collapses at the end, since Lexical's AST tends to produce a lot of extra spacing across nested nodes. Pass any rich text field through this function before it lands in a searchable text field, and LIKE starts matching what the field actually says instead of matching JSON syntax by accident.

Structured collections need their own mapping too, since a generic field doesn't capture what makes them findable. FAQs and recipes are a good example of the same beforeSync hook doing very different work depending on collectionSlug:

ts
switch (collectionSlug) {
  case "faqs": {
    title = typeof originalDoc?.question === "string" ? originalDoc.question : title;
    description = extractLexicalText(originalDoc?.answer);
    break;
  }
  case "recipes": {
    const recipeText = extractRecipeText(originalDoc);
    const instructionsText = Array.isArray(originalDoc?.instructions)
      ? originalDoc.instructions
          .map((i: unknown) =>
            typeof i === "object" && i !== null && "instruction" in i && typeof i.instruction === "string"
              ? i.instruction
              : "",
          )
          .filter(Boolean)
          .join(" ")
      : "";
    description = description || instructionsText;
    keywords = recipeText;
    break;
  }
}

A FAQ document's real title, from a search perspective, is its question rather than whatever generic title field the collection happens to have. A recipe's most useful search text is buried in an ingredients array and a list of instruction steps, neither of which is a single field you can point beforeSync at directly. extractRecipeText flattens the ingredients into a searchable string, and the instructions get mapped and joined the same way, so a user searching "rolled oats" or "chia seeds" matches the recipe even though neither word appears in its title.

Two more details are worth building in before an index like this scales past a handful of collections. First, exclude archived documents rather than letting them sit in search results after they're no longer meant to be visible:

ts
skipSync: ({ doc }) => {
  if (doc && typeof doc === "object" && "archived" in doc && doc.archived === true) {
    return true;
  }
  return false;
},

Second, once you've changed the beforeSync mapping like this, existing search rows are stale until they resync. The plugin ships a reindex button in the Admin UI list view for exactly this, batching updates in groups controlled by reindexBatchSize rather than resyncing everything in one pass. On a collection set in the tens of thousands, it's also worth adding a pg_trgm or GIN index on the text columns matched with LIKE, since a sequential scan across a large fullText or keywords column gets slow well before the row count feels large.

Whatever new fields this hook produces still need matching columns in the migration below, the same way tenant and fullText do above.

The Required Migration Step

This part is easy to miss. The plugin config does not magically create the database table in production. You still need to run the migration that creates the search table and its relationships.

In this project, the migration looked like this:

ts
// File: src/migrations/20260302_180949.ts
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'

export async function up({ db }: MigrateUpArgs): Promise<void> {
  await db.execute(sql`
    CREATE TABLE "search" (
      "id" serial PRIMARY KEY NOT NULL,
      "title" varchar,
      "priority" numeric,
      "tenant" varchar,
      "content_type" varchar,
      "sku" varchar,
      "full_text" varchar,
      "excerpt" varchar,
      "slug" varchar,
      "hide" boolean DEFAULT false,
      "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
      "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
    );
  `)
}

What this does is create the physical storage behind the plugin-managed collection. Without this, your API code may compile and your UI may load, but every search query will fail with relation "search" does not exist.

Once the migration is applied, you still need to reindex. That second step matters because the table may exist while still containing zero rows. If your UI says “No results found” for everything, check the search collection first before assuming the frontend is broken.

Why Both Tenants Share One Search Collection

This is the part that often looks wrong at first, but is actually the correct design.

In a multi-tenant Payload setup, it is tempting to expect one search collection per tenant. That is not what the plugin gives you, and in this case it should not. The search collection is a shared index. Both tenants write rows into the same collection, and each row is tagged with its tenant.

That means tenant separation is row-level, not collection-level.

This is useful because:

  • one query can search both tenants
  • the index is maintained in one place
  • you can still filter by tenant when needed
  • cross-tenant public search becomes straightforward

The source collections remain tenant-owned. The search collection is just the read model you use for querying.

Building the Public Search API

Once the plugin and migration are in place, the next step is a public API route. The route below does two important things: it searches the shared search collection, and it returns the source tenant for each result so the frontend can route correctly.

ts
// File: src/app/api/global-search/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import type { Where, Payload, PaginatedDocs } from 'payload'

interface SearchDoc {
  id: number
  title: string
  contentType: string
  slug: string
  excerpt: string
  sku: string
  tenant: string
  priority: number
  hide: boolean
  fullText: string
}

interface SearchResult {
  id: number
  title: string
  tenant: string
  contentType: string
  slug: string
  excerpt: string
  sku: string
  isExactMatch: boolean
  priority: number
}

async function findSearchDocs(
  payload: Payload,
  where: Where,
  limit: number,
  sort: string,
): Promise<PaginatedDocs<SearchDoc>> {
  return (payload.find as (args: {
    collection: string
    where: Where
    limit: number
    sort: string
  }) => Promise<PaginatedDocs<SearchDoc>>)({
    collection: 'search',
    where,
    limit,
    sort,
  })
}

function toSearchResult(doc: SearchDoc, isExactMatch: boolean): SearchResult {
  return {
    id: doc.id,
    title: doc.title || '',
    tenant: doc.tenant || '',
    contentType: doc.contentType || '',
    slug: doc.slug || '',
    excerpt: doc.excerpt || '',
    sku: doc.sku || '',
    isExactMatch,
    priority: doc.priority ?? 0,
  }
}

export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl
  const q = searchParams.get('q')?.trim() ?? ''
  const type = searchParams.get('type')?.trim() ?? ''
  const limit = Math.min(Number(searchParams.get('limit') ?? 20), 50)

  if (q.length < 2) {
    return NextResponse.json({ results: [], totalDocs: 0 })
  }

  const payload = await getPayload({ config })
  const tenantScope = ['adart', 'making-light']

  const baseWhere: Where = {
    tenant: { in: tenantScope },
    hide: { not_equals: true },
  }

  if (type) {
    baseWhere.contentType = { equals: type }
  }

  const resultMap = new Map<number, SearchResult>()

  const skuResults = await findSearchDocs(
    payload,
    { ...baseWhere, sku: { equals: q } },
    limit,
    '-priority',
  )

  for (const doc of skuResults.docs) {
    resultMap.set(doc.id, toSearchResult(doc, true))
  }

  const remaining = limit - resultMap.size
  if (remaining > 0) {
    const keywordResults = await findSearchDocs(
      payload,
      {
        ...baseWhere,
        or: [
          { title: { like: q } },
          { fullText: { like: q } },
          { sku: { like: q } },
        ],
      },
      remaining + resultMap.size,
      '-priority',
    )

    for (const doc of keywordResults.docs) {
      if (!resultMap.has(doc.id)) {
        resultMap.set(doc.id, toSearchResult(doc, false))
      }
    }
  }

  const results = Array.from(resultMap.values()).slice(0, limit)

  return NextResponse.json({
    results,
    totalDocs: results.length,
  })
}

This route gives you one shared public endpoint across both domains. The important design choice is that the API returns tenant as part of each search result. That is what allows the frontend to know which domain should handle the click.

The Frontend Bug That Made Valid Results Look Empty

After the backend was working, there was a frustrating issue where the API returned valid results, but the command palette still looked blank. The root cause was cmdk filtering the results again on the client after the server had already filtered them.

That is a subtle but common issue with async search UIs. If your server matches on fields like fullText, but your rendered item only shows title, the command UI can hide a result that your API correctly returned.

The fix was to disable internal cmdk filtering and trust the server.

tsx
// File: src/components/ui/command.tsx
function CommandDialog({
  title = "Command Palette",
  description = "Search for a command to run...",
  children,
  className,
  showCloseButton = true,
  shouldFilter = true,
  ...props
}: React.ComponentProps<typeof Dialog> & {
  title?: string
  description?: string
  className?: string
  showCloseButton?: boolean
  shouldFilter?: boolean
}) {
  return (
    <Dialog {...props}>
      <DialogContent className={cn("overflow-hidden p-0", className)}>
        <Command shouldFilter={shouldFilter}>
          {children}
        </Command>
      </DialogContent>
    </Dialog>
  )
}
tsx
// File: src/components/search/global-search-dialog.tsx
<CommandDialog
  open={open}
  onOpenChange={onOpenChange}
  title="Search"
  description="Search across all content"
  showCloseButton={false}
  shouldFilter={false}
>
  {/* dialog content */}
</CommandDialog>

This change makes the frontend display exactly what the API returns. That is what you want when your server is the source of truth for ranking and filtering.

Adding the Search Input to the Navbar

The project already had a command-dialog search, but adding a visible search input in the navbar made the feature much more discoverable. The cleanest implementation was to reuse the existing dialog and drive it with a controlled query from a real input field.

tsx
// File: src/components/navigation/navbar.tsx
import { Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { InputGroup, InputGroupAddon, InputGroupText } from '@/components/ui/input-group'
import { GlobalSearchDialog } from '@/components/search/global-search-dialog'

const [desktopSearchOpen, setDesktopSearchOpen] = useState(false)
const [desktopSearchQuery, setDesktopSearchQuery] = useState('')

{tenant && (
  <div className="hidden lg:flex flex-1 max-w-md mx-6">
    <InputGroup>
      <InputGroupAddon>
        <InputGroupText>
          <Search className="size-4" />
        </InputGroupText>
      </InputGroupAddon>
      <Input
        type="search"
        value={desktopSearchQuery}
        onFocus={() => setDesktopSearchOpen(true)}
        onChange={(event) => {
          setDesktopSearchQuery(event.target.value)
          setDesktopSearchOpen(true)
        }}
        placeholder="Search pages, products, articles..."
        aria-label="Search site content"
      />
    </InputGroup>
  </div>
)}

{tenant && (
  <GlobalSearchDialog
    open={desktopSearchOpen}
    onOpenChange={(next) => {
      setDesktopSearchOpen(next)
      if (!next) setDesktopSearchQuery('')
    }}
    tenant={tenant}
    query={desktopSearchQuery}
    onQueryChange={setDesktopSearchQuery}
  />
)}

This works because the navbar input is just another controller for the existing search modal. You are not creating a second search system. You are simply giving users a more obvious place to start typing.

The Two-Domain Edge Case: Why Routing Breaks Without Tenant-Aware Links

This is the part that matters most in a real multi-tenant public setup.

In this project, the same app runs on two public domains:

  • adart.com
  • making-light.com

And proxy.ts determines the tenant from the host before rewriting internal routes.

That means a relative path like /pylon-signs is only correct if the result belongs to the same tenant as the current host. If you are on adart.com and click a making-light result, staying on the same host is wrong. You need to leave the current domain and go to the other one.

Here is the search URL helper that handles that correctly.

ts
// File: src/components/search/search-result-item.tsx
const TENANT_DOMAINS: Record<string, string> = {
  adart: 'https://adart.com',
  'making-light': 'https://making-light.com',
}

const CONTENT_TYPE_CONFIG: Record<string, { urlPrefix: string }> = {
  page: { urlPrefix: '' },
  post: { urlPrefix: 'blog' },
  products: { urlPrefix: 'products' },
  project: { urlPrefix: 'projects' },
  solution: { urlPrefix: '' },
  industry: { urlPrefix: '' },
  job_opening: { urlPrefix: 'careers' },
}

export function getSearchResultUrl(
  currentTenant: string,
  resultTenant: string,
  contentType: string,
  slug: string,
): string {
  const config = CONTENT_TYPE_CONFIG[contentType]
  const normalizedSlug = slug.startsWith('/') ? slug.slice(1) : slug

  const relativePath = config?.urlPrefix
    ? `/${config.urlPrefix}/${normalizedSlug}`
    : `/${normalizedSlug}`

  if (!resultTenant || resultTenant === currentTenant) {
    return relativePath
  }

  const tenantOrigin = TENANT_DOMAINS[resultTenant]
  if (!tenantOrigin) {
    return relativePath
  }

  return `${tenantOrigin}${relativePath}`
}

This code solves the real edge case cleanly:

  • same-tenant result: stay on the current domain with a relative path
  • cross-tenant result: navigate to the correct external tenant domain

That is the right model when tenant resolution is host-based. The search index can be shared, but result navigation must still be tenant-aware.

Why This Architecture Works So Well

Once everything is wired together, the architecture is actually very clean:

  • Payload’s search plugin gives you the shared index
  • beforeSync enriches each row with tenant-aware metadata
  • the API searches the index instead of source collections
  • the frontend trusts the API instead of filtering twice
  • result URLs are built from the result tenant, not just the current page context

The result is one public search system that works naturally across two domains while still respecting how your multi-tenant routing is set up.

Conclusion

Taking a very good out-of-the-box plugin and making it behave correctly in a public multi-tenant environment, where both tenants share one search index but serve different domains, was the real work behind this build.

The key solution was to keep the search collection shared, tag every indexed row with tenant, expose that tenant in the public API response, and generate result links based on the result’s owning tenant instead of assuming the current host is always correct.

By the end of this implementation, you have a public search feature that can index multiple collections, search across both tenants, render correctly in the UI, and send users to the right domain whether the result belongs to adart.com or making-light.com.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija