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

Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.
Book Hourly AdvisoryLast 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.
The first useful thing to understand is that @payloadcms/plugin-search already gives you most of the heavy lifting:
search collectionThat 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.
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.
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:
The source collections remain tenant-owned. The search collection is just the read model you use for querying.
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.
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.
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.
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.commaking-light.comAnd 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:
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.
Once everything is wired together, the architecture is actually very clean:
beforeSync enriches each row with tenant-aware metadataThe result is one public search system that works naturally across two domains while still respecting how your multi-tenant routing is set up.
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
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.