> ## 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.

# Cross-collection relations in OlonJS: $ref and inverses

> How OlonJS uses $ref pointers to model collection relations — stored on one side, resolved at render time, with no data duplication.

OlonJS models relations between collections using `$ref` pointers stored on one side of the relationship. The inverse direction is computed at render time by filtering — nothing is stored twice. This keeps your source data free from duplication and eliminates the synchronisation bugs that arise when both sides of a relation maintain their own lists.

## How relations work

The posts-to-tags relation is the canonical example. Each post in the `posts` collection stores its associated tags as an array of `$ref` pointers. Each pointer resolves to a single `Tag` object from the `tags` collection.

```json title="src/data/collections/posts/posts.json (excerpt)" theme={null}
{
  "designing-with-constraints": {
    "id": "designing-with-constraints",
    "title": "Designing with constraints, on purpose",
    "date": "2026-06-28",
    "author": "June Park",
    "readingTime": "6 min",
    "tags": [
      { "$ref": "../tags/tags.json#/design" },
      { "$ref": "../tags/tags.json#/process" },
      { "$ref": "../tags/tags.json#/engineering" }
    ]
  }
}
```

The `PostSchema` `tags` field is typed to accept either a fully resolved `Tag` object or a raw `CollectionPointer` — a union that lets the schema validate data at both the authored stage (pointers) and the resolved stage (full objects):

```typescript title="src/collections/posts/schema.ts (tags field)" theme={null}
const CollectionPointerSchema = z.object({
  $ref: z.string(),
});

export const PostSchema = BaseCollectionItem.extend({
  // ...other fields...

  // Relation posts -> tags via $ref pointers.
  // Authored as: { $ref: "../tags/tags.json#/<id>" }; resolved to full Tag objects at render time.
  // The inverse (tags -> posts) is computed at render time by filtering the posts collection.
  tags: z
    .array(z.union([TagSchema, CollectionPointerSchema]))
    .describe('ui:collection-ref:tags'),
});
```

The post page resolves those `$ref` values to full `Tag` objects when rendering the `related-tags` section. The tag page, in turn, computes its own post list by filtering the entire `posts` collection for entries that carry the tag's key — no tag ever maintains its own list of post IDs.

## \$ref pointer format

There are two forms of `$ref` pointer used in OlonJS.

<CodeGroup>
  ```json title="Collection-level ref" theme={null}
  {
    "type": "posts-list",
    "data": {
      "items": { "$ref": "../collections/posts/posts.json" }
    }
  }
  ```

  ```json title="Item-level ref" theme={null}
  {
    "tags": [
      { "$ref": "../tags/tags.json#/design" },
      { "$ref": "../tags/tags.json#/process" }
    ]
  }
  ```
</CodeGroup>

A **collection-level ref** points to a whole JSON file and resolves to the full `Record<string, Item>` object. A section that receives the full collection can then filter, sort, or paginate the items as needed.

An **item-level ref** appends a `#/<key>` fragment to address a single item within the collection record. The engine splits the path at `#/` and uses the fragment as the lookup key. These are the pointers stored on the post side of the relation.

Paths in both forms are relative to the file that contains the pointer.

## Build-time resolution

OlonJS resolves all `$ref` pointers as part of the build process. Every pointer is replaced with the full value from the referenced collection file before the visitor site is deployed.

The result is that visitors always receive fully resolved page bundles — no pointer resolution happens at request time for the production site. This makes page loads fast and eliminates any risk of a missing or broken pointer reaching a visitor.

Studio works differently: it shows you the raw pointer values in the Inspector and resolves them live in the preview panel. This lets you see the current data while editing without waiting for a build cycle.

## Inverse relations

The tags-to-posts direction illustrates the inverse pattern. Rather than storing a list of post IDs on each tag, the tag detail page uses the `tag-posts` section to compute the list at render time:

```json title="src/data/pages/tags/[slug].json (tag-posts section)" 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": {}
}
```

The `tag-posts` section receives the full `posts` collection and the current tag item. It filters the posts at render time, keeping only those whose `tags` array contains a pointer or resolved object that matches the current tag's key.

This means adding a tag to a post immediately makes that post appear on the tag's page — no secondary update required anywhere in the tags data.

<Note>
  This is the **one source of truth per relation** principle: store `$ref` pointers on one side of the relation only (the post side, in this case). The other side (the tag) never stores a copy of the relationship data. When you need to read the relation in reverse, filter at render time. Storing the relation in both places creates two sources of truth that can drift out of sync.
</Note>


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