---
title: "Payload CMS Search: Build a Public Multi-Tenant Index"
slug: "payload-cms-multi-tenant-search-shared-index"
published: "2026-03-20"
updated: "2026-03-02"
validated: "2026-03-02"
categories:
  - "Payload"
tags:
  - "Payload CMS search"
  - "@payloadcms/plugin-search"
  - "multi-tenant search"
  - "shared search index"
  - "tenant-aware links"
  - "Next.js public API"
  - "search migration"
  - "search reindex"
  - "beforeSync hook"
  - "cmdk filtering"
  - "multi-tenant routing"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload@2.x (inferred)"
  - "@payloadcms/plugin-search@2.x (inferred)"
  - "next.js@15 (inferred)"
  - "react@18 (inferred)"
  - "typescript@5.x (inferred)"
  - "postgres@15 (inferred)"
  - "cmdk@latest"
  - "lexical@latest"
status: "stable"
llm-purpose: "Payload CMS search: build a public multi-tenant search with one shared index, migrate & reindex, expose a Next.js API, and generate tenant-aware…"
llm-prereqs:
  - "Access to @payloadcms/plugin-search"
  - "Access to Payload CMS"
  - "Access to Next.js"
  - "Access to TypeScript"
  - "Access to Postgres"
llm-outputs:
  - "Completed outcome: Payload CMS search: build a public multi-tenant search with one shared index, migrate & reindex, expose a Next.js API, and generate tenant-aware…"
---

**Summary Triples**
- (@payloadcms/plugin-search, creates, a dedicated 'search' collection and a searchable read model for configured collections)
- (Shared search index, should be treated as, a public, tenant-agnostic index containing tenant metadata (tenantId/tenantDomain))
- (beforeSync hook, is used to, attach tenantId, tenantDomain and compute public URL before documents are indexed)
- (Migration, must add, tenant metadata fields to existing search documents and mark them for reindexing)
- (Reindex flow, is required to, backfill new tenant fields into the shared index after migration)
- (Next.js public API, exposes, a read-only search endpoint that queries the shared search collection and returns tenant-aware links)
- (Public search endpoint, must implement, rate limiting, payload-safe read-only access, and input sanitization to avoid leaking PII)
- (Result link resolution, relies on, tenantDomain stored in the index or a runtime tenantId->domain map to produce correct cross-domain URLs)
- (Multi-tenant UI, can filter, across tenants using cmdk or UI filters, while link routing uses tenant metadata for navigation)

### {GOAL}
Payload CMS search: build a public multi-tenant search with one shared index, migrate & reindex, expose a Next.js API, and generate tenant-aware…

### {PREREQS}
- Access to @payloadcms/plugin-search
- Access to Payload CMS
- Access to Next.js
- Access to TypeScript
- Access to Postgres

### {STEPS}
1. Configure the search plugin
2. Create the search migration
3. Reindex existing content
4. Expose a Next.js public API
5. Trust server-side results on the frontend
6. Build tenant-aware result URLs
7. Add discoverable search input
8. Test cross-domain behavior

<!-- llm:goal="Payload CMS search: build a public multi-tenant search with one shared index, migrate & reindex, expose a Next.js API, and generate tenant-aware…" -->
<!-- llm:prereq="Access to @payloadcms/plugin-search" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Postgres" -->
<!-- llm:output="Completed outcome: Payload CMS search: build a public multi-tenant search with one shared index, migrate & reindex, expose a Next.js API, and generate tenant-aware…" -->

# Payload CMS Search: Build a Public Multi-Tenant Index
> Payload CMS search: build a public multi-tenant search with one shared index, migrate & reindex, expose a Next.js API, and generate tenant-aware…
Matija Žiberna · 2026-03-20

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.

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

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

## LLM Response Snippet
```json
{
  "goal": "Payload CMS search: build a public multi-tenant search with one shared index, migrate & reindex, expose a Next.js API, and generate tenant-aware…",
  "responses": [
    {
      "question": "What does the article \"Payload CMS Search: Build a Public Multi-Tenant Index\" cover?",
      "answer": "Payload CMS search: build a public multi-tenant search with one shared index, migrate & reindex, expose a Next.js API, and generate tenant-aware…"
    }
  ]
}
```