• Home
BuildWithMatija
Get In Touch
  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:2nd March 2026·MŽMatija Žiberna·
Payload
Payload CMS Search: Build a Public Multi-Tenant Index

Need Help Making the Switch?

Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.

Book Hourly Advisory

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.

// 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.

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:

// 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.

// 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.

// 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>
  )
}
// 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.

// 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.

// 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

The problem was not just “how do I add search to Payload CMS.” The real problem was how to take a very good out-of-the-box plugin and make it behave correctly in a public multi-tenant environment where both tenants share one search index but serve different domains.

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

📚 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.

📄View markdown version
0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

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.

Table of Contents

  • Why Payload Search Is So Good Out of the Box
  • 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
  • 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
Build With Matija Logo

Build with Matija

Matija Žiberna

I turn scattered business knowledge into one usable system. End-to-end system architecture, AI integration, and development.

Quick Links

Payload CMS Websites
  • Bespoke AI Applications
  • Projects
  • How I Work
  • Blog
  • Payload CMS

    • Migration
    • Pricing

    Get in Touch

    Have a project in mind? Let's discuss how we can help your business grow.

    Contact me →
    © 2026BuildWithMatija•Principal-led system architecture•All rights reserved