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.
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.
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:
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:
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.tsimport { MigrateUpArgs, MigrateDownArgs, sql } from'@payloadcms/db-postgres'exportasyncfunctionup({ 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.
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/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.
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.
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.