--- title: "Join Field" aliases: [join-field, bi-directional-relationship, virtual-relationship-field] tags: [payloadcms, fields, relationships, database, schema] sources: [raw/fields__join.md] created: 2026-05-15 updated: 2026-05-15 --- ## Overview The Join field exposes [[wiki/payloadcms/fields-complex|Relationship and Upload fields]] in the **reverse direction** — it is a **virtual field** (stores no data) that surfaces related documents in the Admin UI and APIs. The foreign key lives on the related collection only; Join reads it on the fly. ```ts // On the "categories" collection: export const relatedPostsField: Field = { name: 'relatedPosts', type: 'join', collection: 'posts', // slug of the collection to join from on: 'category', // name of the relationship field in that collection } // On the "posts" collection (the actual storage): export const categoryField: Field = { name: 'category', type: 'relationship', relationTo: 'categories', } ``` Navigating to a Category now shows all Posts that reference it — without storing post IDs on the category. ## How It Works Internally - **MongoDB** — uses aggregation pipelines to join related documents. - **Postgres / SQLite** — uses SQL `JOIN` statements. - **No extra query cost** until `depth >= 1`. At `depth: 0` only IDs are returned. - **Not supported** on DocumentDB and Azure Cosmos DB (aggregation limitations). ## Schema Design Advice Store the relationship on **one side only** (single source of truth): | Do | Don't | |----|-------| | `post.category_id` (FK on post) | `post.category_id` + `category.post_ids[]` | Join makes the single-source approach ergonomic — you get bi-directional UI/API without duplicating data. ## Custom Junction Tables By default, polymorphic relationships create a `_rels` table in Postgres/SQLite. Use Join to **bypass** this: 1. Create a `categories_posts` collection with `category` and `post` relationship fields. 2. Add Join fields on both `Categories` and `Posts` pointing `on` the respective field in `categories_posts`. 3. Optionally set `admin.hidden: true` on `categories_posts` to hide it from the nav. 4. Add extra "context" fields (e.g. `featured`, `spotlight`) on the junction collection. ## Config Options | Option | Required | Description | |--------|----------|-------------| | `name` | ✅ | Property name in API responses | | `collection` | ✅ | Slug or array of slugs to join from | | `on` | ✅ | Relationship/Upload field name in that collection. Dot-notation for nested: `'myGroup.relationName'` | | `orderable` | | Drag-and-drop reordering via fractional indexing | | `where` | | Static `Where` filter merged with request `where` | | `maxDepth` | | Max population depth (default 1) | | `defaultLimit` | | Docs returned per page; `0` = all | | `defaultSort` | | Default sort field | | `hooks` | | Field-level hooks | | `access` | | Field-level access control | ### Admin Options (`admin.*`) | Option | Description | |--------|-------------| | `defaultColumns` | Columns shown in the relationship table | | `allowCreate` | Set `false` to hide "New" button | | `components.Label` | Custom Label component | | `disableRowTypes` | Hide row type badges (default `false`) | ## API Response Shape ```json { "id": "66e3431a3f23e684075aae9c", "relatedPosts": { "docs": [{ "id": "...", "category": "66e3431a3f23e684075aae9c" }], "hasNextPage": false, "totalDocs": 10 } } ``` Polymorphic (array `collection`): each `docs` entry is `{ relationTo: "posts", value: { ... } }`. ## Query Options Pass `joins: false` to suppress all join fields (performance). | Param | Description | |-------|-------------| | `limit` | Max related docs (default 10) | | `where` | Filter joined docs | | `sort` | Sort field | | `count` | Include `totalDocs` in response | ### Local API ```js await payload.find({ collection: 'categories', joins: { relatedPosts: { limit: 5, sort: 'title', where: { title: { equals: 'My Post' } } }, }, }) ``` ### REST API ``` /api/categories/{id}?joins[relatedPosts][limit]=5&joins[relatedPosts][sort]=title ``` ### GraphQL ```graphql query { Categories { docs { relatedPosts(sort: "createdAt", limit: 5, count: true) { docs { title } hasNextPage totalDocs } } } } ``` ## Key Takeaways - **Virtual field** — no column added to the collection that defines it; data stays on the related side. - **Zero overhead at `depth: 0`** — only IDs returned; full population kicks in at `depth >= 1`. - **Single source of truth** — avoids dual-sided storage; Join makes bi-directional access free. - **Custom junction tables** — use a dedicated junction collection + Join fields to escape Payload's auto `_rels` table and add context fields (e.g. `featured`). - **Polymorphic** — `collection` can be an array of slugs; response wraps each doc as `{ relationTo, value }`. - **Not supported** on DocumentDB / Azure Cosmos DB. - **`where` + array collections** — filtering inside arrays/blocks is not yet supported. ## Related - [[wiki/payloadcms/fields-complex|Fields: Complex (Relationship, Upload, Array, Blocks)]] - [[wiki/payloadcms/queries|Queries — Where, depth, pagination]] - [[wiki/payloadcms/database-postgres|Database — Postgres (junction tables, _rels)]] - [[wiki/payloadcms/collection-config|Collection Config (fractional indexing, orderable)]] - [[wiki/payloadcms/local-api|Local API]] ## Sources - `raw/fields__join.md` — official Payload CMS Join field docs