162 lines
5.4 KiB
Markdown
162 lines
5.4 KiB
Markdown
---
|
|
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
|