---
title: "Payload CMS Search: 1-Hook Fix to Search Across Relationships"
slug: "payload-cms-search-across-relationships-hook"
published: "2026-08-30"
updated: "2026-09-14"
categories:
  - "Payload"
tags:
  - "Payload CMS search"
  - "listSearchableFields"
  - "beforeOperation hook"
  - "search across relationship"
  - "mergeListSearchAndWhere"
  - "Payload 3.88.0"
  - "line-items referenceCode"
  - "admin list search Payload"
  - "hoistQueryParamsToAnd"
  - "access control in hooks"
  - "extend Payload admin search"
  - "Next.js Payload integration"
llm-intent: "how-to"
audience-level: "intermediate"
llm-purpose: "Payload CMS search: extend admin list search to related fields with a beforeOperation hook—find orders by line-item reference codes safely. Read the guide."
llm-prereqs:
  - "Payload CMS"
  - "Payload 3.88.0"
  - "TypeScript"
  - "Node.js"
  - "Next.js"
  - "npm"
---

**Summary Triples**
- (Payload CMS Search: 1-Hook Fix to Search Across Relationships, expresses-intent, how-to)
- (Payload CMS Search: 1-Hook Fix to Search Across Relationships, covers-topic, Payload CMS search)
- (Payload CMS Search: 1-Hook Fix to Search Across Relationships, provides-guidance-for, Payload CMS search: extend admin list search to related fields with a beforeOperation hook—find orders by line-item reference codes safely. Read the guide.)

### {GOAL}
Payload CMS search: extend admin list search to related fields with a beforeOperation hook—find orders by line-item reference codes safely. Read the guide.

### {PREREQS}
- Payload CMS
- Payload 3.88.0
- TypeScript
- Node.js
- Next.js
- npm

### {STEPS}
1. Review how admin search becomes a where clause
2. Configure listSearchableFields on the parent
3. Create a beforeOperation hook file
4. Detect the admin-generated search or conditions
5. Query the related collection for matches
6. Append matching parent IDs into the or array
7. Test, index fields, and watch for upgrades

<!-- llm:goal="Payload CMS search: extend admin list search to related fields with a beforeOperation hook—find orders by line-item reference codes safely. Read the guide." -->
<!-- llm:prereq="Payload CMS" -->
<!-- llm:prereq="Payload 3.88.0" -->
<!-- llm:prereq="TypeScript" -->
<!-- llm:prereq="Node.js" -->
<!-- llm:prereq="Next.js" -->
<!-- llm:prereq="npm" -->

# Payload CMS Search: 1-Hook Fix to Search Across Relationships
> Payload CMS search: extend admin list search to related fields with a beforeOperation hook—find orders by line-item reference codes safely. Read the guide.
Matija Žiberna · 2026-08-30

Payload CMS's `admin.listSearchableFields` only matches fields that live on the same collection as the one you're searching. If the value an editor actually needs to search by — a reference code, an external ID, anything — lives on a related collection instead, the admin search box returns nothing. No error, no warning in the console, just an empty result set that looks like a bug in your data.

The fix doesn't require replacing Payload's List view or standing up a custom search endpoint. It's a single `beforeOperation` hook on the parent collection's `find` operation: detect the `where` clause Payload's admin generates from a search term, resolve matching IDs from the related collection, and append them to the same `or` condition. This guide walks through exactly how to build that hook, based on the internal `where`-clause shape in Payload's own source — which isn't documented anywhere — tested against Payload 3.88.0.

I ran into this while adding search to two related collections in a Payload e-commerce setup: a parent `orders` collection and a child `line-items` collection carrying the actual reference codes. Editors needed to paste a reference code into the Orders list search box and land on the right order — the natural way anyone searches an order list. `listSearchableFields` looked like the obvious answer, right up until I typed a reference code into the search box and got nothing back, even though the matching line item was three clicks away.

## The problem: listSearchableFields only searches the collection it's set on

The naive first attempt looks like this:

```ts
// File: src/collections/Orders.ts
export const Orders: CollectionConfig = {
  slug: "orders",
  admin: {
    useAsTitle: "orderNumber",
    listSearchableFields: ["orderNumber", "referenceCode"],
  },
  fields: [
    { name: "orderNumber", type: "text", required: true },
    // no referenceCode field here — it lives on line-items
  ],
};
```

`referenceCode` doesn't exist on `orders` — it's a field on the child `line-items` collection, linked back via a `order` relationship field. Payload doesn't validate `listSearchableFields` against the collection's own schema, so this config doesn't throw. It just quietly does nothing for that field: the generated query only ever references fields Payload can actually find on `orders`, so a search for a reference code matches zero documents.

This is easy to miss in testing, because searching by `orderNumber` still works fine. The bug only shows up when someone searches by the field that was never really wired up.

## How the admin search box actually becomes a query

To fix this, it helps to know exactly what happens between typing into the search box and Payload running a database query — because the natural instinct is to look for a raw search string somewhere in a hook, and that string doesn't exist by the time hooks run.

The admin List view sends a plain `?search=` query parameter to the server — it does not build a `where` clause client-side. That parameter gets converted into an actual `where` clause server-side, inside `@payloadcms/next`'s list view renderer, before the Local API's `find()` is ever called. The function responsible is `mergeListSearchAndWhere`, and its source (from `payload@3.88.0`) is short enough to read in full:

```js
// payload/dist/utilities/mergeListSearchAndWhere.js (Payload 3.88.0)
export const mergeListSearchAndWhere = ({ collectionConfig, search, where = {} }) => {
  if (search) {
    let copyOfWhere = { ...where || {} };
    const searchAsConditions = (
      collectionConfig.admin.listSearchableFields || [collectionConfig.admin?.useAsTitle || "id"]
    ).map((fieldName) => ({ [fieldName]: { like: search } }));
    if (searchAsConditions.length > 0) {
      copyOfWhere = hoistQueryParamsToAnd(copyOfWhere, { or: searchAsConditions });
    }
    if (!isEmptyObject(copyOfWhere)) {
      where = copyOfWhere;
    }
  }
  return where;
};
```

For a collection configured with `listSearchableFields: ["orderNumber"]` and a search term of `"9142"`, this produces:

```json
{ "or": [{ "orderNumber": { "like": "9142" } }] }
```

If the list already had another filter applied — a status filter, a `baseListFilter`, anything — `hoistQueryParamsToAnd` nests both together under an `and`, so the exact shape you'll see in practice can be more deeply nested than the bare example above. The important part is that this all happens before any collection hook runs. By the time your code sees the request, there is no `search` string left anywhere — only this generated `where` object.

## Why beforeOperation is the right hook point

Payload's `find` operation runs a `beforeOperation` hook right at the start, before access control is applied and before the query is sent to the database. Here's the relevant part of `payload/dist/collections/operations/find.js`:

```js
// payload/dist/collections/operations/find.js (Payload 3.88.0)
args = await buildBeforeOperation({
  args,
  collection: args.collection.config,
  operation: "read",
  overrideAccess: args.overrideAccess,
});
const { ..., where } = args;
// ...
let fullWhere = combineQueries(where, accessResult);
```

And `buildBeforeOperation` confirms that mutating `args.where` in place — without needing to return a brand-new object — is enough for the change to persist through to the executed query:

```js
// payload/dist/collections/operations/utilities/buildBeforeOperation.js
export async function buildBeforeOperation(operationArgs) {
  const { args, collection, operation, overrideAccess } = operationArgs;
  let newArgs = args;
  if (args.collection.config.hooks?.beforeOperation?.length) {
    const hookOperation = operationToHookOperation[operation];
    for (const hook of args.collection.config.hooks.beforeOperation) {
      const hookResult = await hook({
        args: newArgs,
        collection,
        context: args.req.context,
        operation: hookOperation,
        overrideAccess,
        req: args.req,
      });
      if (hookResult !== undefined) {
        newArgs = hookResult;
      }
    }
  }
  return newArgs;
}
```

One detail worth knowing before you write the hook: Payload maps both `find` and `findByID` to the same hook operation name, `"read"` — a backward-compatible alias. That means your hook needs to distinguish the two itself. A `find` call's `args` has a `where` property; a `findByID` call's `args` has an `id` property instead. Checking `"where" in args` before doing anything else keeps the hook from running on single-document lookups where it has nothing to do.

## Implementation: searching line-item reference codes from the Orders list

With the mechanism clear, the fix is two pieces: the `listSearchableFields` config for the field that does live on `orders`, and a hook that extends the same search to `referenceCode` on `line-items`.

```ts
// File: src/collections/Orders.ts
import { searchOrdersByLineItemReference } from "./hooks/searchOrdersByLineItemReference";

export const Orders: CollectionConfig = {
  slug: "orders",
  admin: {
    useAsTitle: "orderNumber",
    listSearchableFields: ["orderNumber"],
  },
  hooks: {
    beforeOperation: [searchOrdersByLineItemReference],
  },
  fields: [
    { name: "orderNumber", type: "text", required: true },
  ],
};
```

`listSearchableFields` only lists `orderNumber` here, since that's the only field the search box can natively match on this collection. The hook is what extends the match to the related collection.

```ts
// File: src/collections/hooks/searchOrdersByLineItemReference.ts
import type { CollectionBeforeOperationHook, Where } from "payload";

const SEARCHABLE_FIELDS = ["orderNumber"];

export const searchOrdersByLineItemReference: CollectionBeforeOperationHook = async ({
  args,
  operation,
  req,
}) => {
  if (operation !== "read" || !("where" in args) || !args.where) {
    return args;
  }

  const searchOr = findSearchOrConditions(args.where);
  const term = searchOr ? extractSearchTerm(searchOr) : undefined;
  if (!term) return args;

  const lineItemMatches = await req.payload.find({
    collection: "line-items",
    depth: 0,
    overrideAccess: false,
    pagination: false,
    req,
    select: { order: true },
    where: { referenceCode: { like: term } },
  });

  const orderIds = new Set<number>();
  for (const lineItem of lineItemMatches.docs) {
    const orderId = typeof lineItem.order === "number" ? lineItem.order : lineItem.order?.id;
    if (typeof orderId === "number") orderIds.add(orderId);
  }

  if (orderIds.size > 0) {
    searchOr!.push({ id: { in: [...orderIds] } });
  }

  return args;
};

function findSearchOrConditions(where: Where): Where[] | undefined {
  if (isSearchOrConditions(where.or)) return where.or as Where[];
  for (const branch of [...(where.and ?? []), ...(where.or ?? [])]) {
    const found = findSearchOrConditions(branch);
    if (found) return found;
  }
  return undefined;
}

function isSearchOrConditions(or: unknown): or is Where[] {
  if (!Array.isArray(or) || or.length !== SEARCHABLE_FIELDS.length) return false;
  return or.every(
    (condition) =>
      condition &&
      typeof condition === "object" &&
      Object.keys(condition).length === 1 &&
      SEARCHABLE_FIELDS.some((field) => {
        const clause = (condition as Record<string, unknown>)[field];
        return clause && typeof clause === "object" && typeof (clause as Record<string, unknown>).like === "string";
      }),
  );
}

function extractSearchTerm(or: Where[]): string | undefined {
  for (const condition of or) {
    for (const field of SEARCHABLE_FIELDS) {
      const clause = (condition as Record<string, { like?: unknown }>)[field];
      if (clause && typeof clause.like === "string") return clause.like;
    }
  }
  return undefined;
}
```

Three things are doing the actual work here. `findSearchOrConditions` walks the `where` tree — through any `and`/`or` nesting `hoistQueryParamsToAnd` might have introduced — looking for the exact `or` array `mergeListSearchAndWhere` produces for this collection's `listSearchableFields`. It returns that array by reference, not a copy, which matters for the next step. `extractSearchTerm` pulls the literal search string back out of that shape. And once a term is found, the hook queries `line-items` for a matching `referenceCode`, collects the parent `order` IDs, and pushes one more condition — `{ id: { in: [...] } }` — directly into the same array reference `findSearchOrConditions` returned. Because it's the same array object, no reassignment of `where` is needed; the mutation is visible to `findOperation` once the hook returns.

If there's no search term at all — a normal list page load — `extractSearchTerm` returns `undefined` and the hook returns immediately. The sub-query against `line-items` only ever runs when someone is actually searching.

## Gotchas

The biggest one: this hook works by pattern-matching an internal shape that Payload's own admin code generates, and that shape is not a public, documented contract. A future Payload release could change how `mergeListSearchAndWhere` builds its `where` clause, and if it does, `isSearchOrConditions` would stop matching — the augmentation would silently stop firing while `listSearchableFields` itself kept working normally. Re-verify this after any Payload version bump; it's a five-minute smoke test, not a rewrite.

Two smaller things matter for correctness and performance. First, index the field you're sub-querying — `referenceCode` in this example — the same way you'd index any field you expect to filter large tables on; the extra query only costs what a normal indexed lookup costs. Second, pass `req` through to the sub-query and leave `overrideAccess` at its default (`false`), rather than setting it to `true`. That keeps the related collection's normal access control — tenant scoping, publish-state checks, whatever you have — applied to the IDs this hook is allowed to surface, instead of quietly bypassing it. If you're touching `req` inside hooks at all, it's worth understanding the transaction pitfalls that come with it — see <a href="/blog/payload-async-hooks-transaction-trap">Payload Async Hooks: Avoid the Transaction Trap</a> for two patterns that keep background work and the original request's transaction from interfering with each other.

There's more than one way to solve "search should reach into a relationship," and which one is worth it depends on how far you need to go:

| Approach | When to use | Trade-off |
|---|---|---|
| `beforeOperation` shape-detection hook (this guide) | You want the existing search box to also match a related field, with no UI changes | Depends on an undocumented internal `where` shape; can silently stop working after a Payload upgrade |
| Custom List view (`admin.components.views.list`) | You need a fundamentally different search UX — facets, a multi-field form, custom result rendering | Full reimplementation of list rendering: columns, sorting, bulk actions, pagination all become your responsibility |
| Custom endpoint + client-side search component | You want complete control and don't want to touch the built-in search box at all | More code overall, and it no longer benefits from the native search box's existing UX and keyboard behavior |

For most admin panels, the hook is the right default: it's a few dozen lines, it doesn't touch the UI, and it degrades gracefully — if the shape detection ever fails to match, you're back to exactly the behavior you had before adding it, not a broken search box.

## Frequently asked questions

**Can I just build a custom search box instead?**
Yes — Payload supports overriding List view components, including replacing the search UI entirely with your own. That's the right call if you need something the native search box fundamentally can't do, like faceted search or matching across three or more related collections with different weighting. For most single-relationship cases, though, it's a lot more code than the hook above for the same end result. If you do go that route, <a href="/blog/payload-cms-custom-admin-ui-components-guide">Payload CMS Custom Admin Fields and Views</a> covers the `@payloadcms/ui` primitives you'd build it from.

**Does this affect the REST or GraphQL API, or only the admin UI?**
Only the admin UI's generated search. `mergeListSearchAndWhere` only runs inside the admin panel's server-rendered List view — the REST and GraphQL `find` handlers never see a `search` parameter; they only ever accept an explicit `where` clause you construct yourself. This hook still runs for REST/GraphQL requests, since `beforeOperation` is a collection-level hook, but it only does anything when the incoming `where` happens to match the exact shape the admin search box produces — a hand-built API request with a different `where` shape simply passes through unaffected.

**What if I need to match against more than one related collection?**
Repeat the sub-query for each related collection, collect each set of matching parent IDs separately, and push one `{ id: { in: [...] } }` condition per collection into the same `or` array. They all get OR'd together automatically — no change to the detection logic is needed, just more conditions appended after it runs.

**Will this hook add noticeable overhead to every list page load?**
No. It only runs the related-collection sub-query when `extractSearchTerm` actually finds a term — a plain list load with no search text returns immediately after the shape check fails to match. The only added cost during an active search is one indexed `find` against the related collection.

**Is this specific to admin-only editorial search, or does it work for public-facing search too?**
This pattern is for the Payload admin panel specifically — it hooks into how the built-in editor-facing List view search works. If you're building public, storefront-facing search across a multi-tenant site, that's a different problem with a different tool: see <a href="/blog/payload-cms-multi-tenant-search-shared-index">Payload CMS Search: Build a Public Multi-Tenant Index</a> for the `@payloadcms/plugin-search`-based approach to that.

## Conclusion

`listSearchableFields` covers the common case — searching fields that live on the collection you're viewing — and does it well. It just doesn't extend to relationships, and it fails silently rather than telling you that. The fix is a `beforeOperation` hook that recognizes the exact `where` shape Payload's admin generates from a search term, resolves matching IDs on the related collection, and appends them to the same condition — no custom List view, no custom endpoint, no change to how editors already search.

I built and tested this against a production Payload 3.88.0 application, verified it against the collection sources referenced above, and confirmed the search now returns results across the relationship as expected in the live admin panel. Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

---

*I'm Matija, an independent developer building and maintaining production Next.js and Payload CMS applications, including e-commerce and multi-tenant admin panels with custom search and access-control requirements. Tested against Payload 3.88.0. Sources: `payload/dist/utilities/mergeListSearchAndWhere.js`, `payload/dist/collections/operations/find.js`, and `payload/dist/collections/operations/utilities/buildBeforeOperation.js` from the installed `payload@3.88.0` npm package. Last updated September 1, 2026.*