> ## Documentation Index
> Fetch the complete documentation index at: https://docs.olonjs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Model cross-collection relations with $ref pointers

> Link content across OlonJS collections using $ref pointers, resolve forward relations on post pages, and compute inverse relations on tag pages.

OlonJS uses JSON `$ref` pointers to express relations between collections — for example, a post referencing its tags. Relations have a single source of truth on one side and are computed in the other direction at render time. This guide shows you how to set up bidirectional navigation between any two collections.

***

## Storing a relation

The source of truth lives on the **owning** side of the relation — in the Inkwell demo, that is the post. Each post entry in `posts.json` stores its tags as an array of `$ref` pointers:

```json src/data/collections/posts/posts.json (excerpt) theme={null}
{
  "designing-with-constraints": {
    "id": "designing-with-constraints",
    "title": "Designing with constraints, on purpose",
    "excerpt": "Every strong interface we have shipped started with a constraint we refused to negotiate away.",
    "date": "2026-06-28",
    "author": "June Park",
    "readingTime": "6 min",
    "tags": [
      { "$ref": "../tags/tags.json#/design" },
      { "$ref": "../tags/tags.json#/process" }
    ]
  }
}
```

A `$ref` value has two parts separated by `#`:

* **Path** — a relative path from the current JSON file to the target collection file, e.g. `../tags/tags.json`.
* **Fragment** (optional) — `#/<key>` selects a single item from the target collection by its record key, e.g. `#/design`.

Nothing is stored on the tag side. The `tags.json` file contains only tag attributes (name, description, accent color) with no back-references to posts.

***

## Forward resolution (post → tags)

When a visitor opens a post page, OlonJS resolves each `$ref` pointer in the post's `tags` array to a full `Tag` object during the bake and render step. The `related-tags` section receives the resolved tags and renders them as clickable chips.

Wire the `related-tags` section in your dynamic post page template like this:

```json src/data/pages/posts/[slug].json (excerpt) theme={null}
{
  "id": "post-detail-related-tags",
  "type": "related-tags",
  "data": {
    "label": "Relations demo",
    "title": "Filed under",
    "emptyLabel": "This post has no topics yet.",
    "item": { "$ref": "collection:current" },
    "tags": { "$ref": "../../collections/tags/tags.json" }
  },
  "settings": {}
}
```

* `"item": { "$ref": "collection:current" }` — injects the current post item (resolved from the URL slug).
* `"tags": { "$ref": "../../collections/tags/tags.json" }` — passes the full tags collection so the section can match each `$ref` in the post's `tags` array to its display data.

The section component handles the matching internally; you do not need to pre-join the data.

***

## Inverse relation (tag → posts)

A tag page needs to show all posts that belong to it — but the tag entry itself stores no post references. OlonJS computes the inverse at render time by filtering the entire posts collection for entries whose `tags` array includes the current tag's key.

Wire the `tag-posts` section in your dynamic tag page template:

```json src/data/pages/tags/[slug].json (excerpt) theme={null}
{
  "id": "tag-detail-posts",
  "type": "tag-posts",
  "data": {
    "title": "Posts on this topic",
    "emptyLabel": "No posts carry this tag yet.",
    "item": { "$ref": "collection:current" },
    "posts": { "$ref": "../../collections/posts/posts.json" }
  },
  "settings": {}
}
```

* `"item": { "$ref": "collection:current" }` — the current tag item (e.g. the `design` tag).
* `"posts": { "$ref": "../../collections/posts/posts.json" }` — the full posts collection. The `tag-posts` section filters this collection at render time, keeping only posts whose `tags` array contains a `$ref` fragment matching the current tag key.

Because the filter runs at render time, adding a new tag to a post in `posts.json` automatically updates the tag page — no changes to `tags.json` needed.

***

## Adding a relation to your schema

To add a relation field to your own collection schema, accept either a resolved entity object **or** a raw `$ref` pointer. This union type lets OlonJS store pointers in authored JSON while the bake step resolves them to full objects at build time.

```typescript src/collections/posts/schema.ts theme={null}
import { z } from 'zod';
import { BaseCollectionItem, ImageSelectionSchema } from '@olonjs/core';
import { TagSchema } from '@/collections/tags/schema';

/**
 * Raw authored pointer written into collection JSON.
 * At bake/render time, OlonJS expands this to a full Tag object.
 */
const CollectionPointerSchema = z.object({
  $ref: z.string(),
});

export const PostSchema = BaseCollectionItem.extend({
  title: z.string().describe('ui:text'),
  excerpt: z.string().describe('ui:textarea'),
  body: z.string().describe('ui:textarea'),
  image: ImageSelectionSchema.optional(),
  date: z.string().describe('ui:text'),
  author: z.string().describe('ui:text'),
  readingTime: z.string().describe('ui:text'),
  // Relation: posts → tags.
  // Authored value: array of $ref pointers.
  // Resolved value: array of Tag objects.
  // Studio: renders a multi-select dropdown scoped to the tags collection.
  tags: z
    .array(z.union([TagSchema, CollectionPointerSchema]))
    .describe('ui:collection-ref:tags'),
});

export const PostsCollectionSchema = z.record(z.string(), PostSchema);
```

The descriptor `'ui:collection-ref:tags'` tells Studio to render the `tags` field as a multi-select dropdown populated with entries from the registered `tags` collection.

To relate to a different collection, change the suffix: `'ui:collection-ref:products'`, `'ui:collection-ref:authors'`, and so on. The suffix must match the key you used when registering the collection in `CollectionRegistry.ts`.

***

## Testing the relation

<Steps>
  ### Build the project

  ```bash theme={null}
  npm run build
  ```

  The build step bakes all `$ref` pointers, generates per-item pages for both `posts/[slug]` and `tags/[slug]`, and copies everything to `public/`.

  ### Open a post page

  Start the dev server and navigate to any post:

  ```bash theme={null}
  npm run dev
  ```

  Visit `http://localhost:3000/posts/designing-with-constraints`. You should see the **Filed under** section at the bottom of the post, listing the resolved tag chips for `Design` and `Process`.

  ### Follow a tag chip

  Click the **Design** chip. You should land on `http://localhost:3000/tags/design` and see the **Posts on this topic** section listing all posts that carry the `design` tag — including the one you just came from.
</Steps>

<Note>
  If the tag page shows zero posts after adding a new relation, run `npm run build` again. The inverse filter is computed during the bake step, so it requires a rebuild to reflect new data.
</Note>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.