---
title: "6. Relations and media"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

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

# 6. Relations and media

A post has an author, a category and any number of tags, and it has a cover
image. Three field types cover all of it.

## The target resources must exist

A relation field offers options for a **resource**, not a model. `authorId`
cannot be picked until a resource manages `User` in the same panel, so create
the three targets first — they are small:

```ts
import { BaseResource, F } from '@adonia/core'
import type { SchemaBuilder, SchemaComponent } from '@adonia/core'
import Tag from '#models/tag'

export default class TagResource extends BaseResource<typeof Tag> {
  static override model = Tag
  static override slug = 'tags'
  static override labels = { singular: 'Tag', plural: 'Tags' }
  static override navigationIcon = 'tag'
  static override navigationGroup = 'Taxonomy'
  static override navigationSort = 21
  static override recordTitle = 'name'
  static override policy = () => import('#policies/tag_policy')

  schema(s: SchemaBuilder): SchemaComponent[] {
return [
  s
    .section('Tag')
    .components([
      F.text('name').label('Name').required().maxLength(60).live().sets('slug', 'slugify'),
      F.text('slug').label('Slug').required().maxLength(80).unique().readonlyOn('edit'),
    ]),
]
  }
}
```

<small>`examples/blog-admin/app/adonia/resources/tag_resource.ts` — `category_resource.ts`
and `user_resource.ts` are the same shape</small>

`navigationGroup` collapses categories and tags into one sidebar group;
`navigationSort` orders the top level (posts 0, Taxonomy 20, users 30).
`recordTitle` is how one row is *named* — the relation picker falls back to it
when a field declares no `optionLabel`.

## belongsTo

<!-- @sample-preamble-reset -->
<!-- @sample-preamble
import { F } from '@adonia/core'
-->

```ts
F.belongsTo('authorId')
  .label('Author')
  .resource('users')
  .optionLabel('fullName')
  .searchable()
  .required(),
F.belongsTo('categoryId')
  .label('Category')
  .resource('categories')
  .optionLabel('name')
  .preload(50)
  .nullable()
```

The field binds the **foreign key column**, and the relation name is the
attribute with its `Id` suffix stripped — `authorId` → `author`, which must be
a real Lucid relation on `Post`. `.resource('users')` names the target
explicitly; without it Adonia infers it by finding the registered resource
whose `static model` is the related model.

The two loading strategies differ on purpose:

- **`.searchable()`** ships *no* options in the descriptor and attaches a
  capability URL instead — `/admin/posts/field/authorId/options` — which the
  client queries as you type. That is the right default for users.
- **`.preload(50)`** ships the first 50 options inline **and** still attaches
  the URL. Right for a closed set like categories.

Both go through the same resolver, which requires `viewList` on the target
resource and applies its `static query()` scope — so the options a user is
offered and the keys the validator accepts cannot disagree.

`optionLabel('fullName')` does three jobs: it labels the option, it is the
column `?q=` searches, and it is what makes the field contribute the derived
index column `author.fullName`. Without it a relation field is not tabulatable
at all.

## belongsToMany

```ts
F.belongsToMany('tags').label('Tags').resource('tags').optionLabel('name').searchable()
```

State is a plain array of ids — `[3, 7]` — validated with **one** batched
`WHERE id IN (…)` rather than one query per id, and hydrated from the preloaded
relation (never a lazy query).

The pivot is written **after** the row is saved, inside the same transaction,
as a `sync(keys, true)` — a **diff**, not a rebuild. Editing `[typescript,
adonisjs]` into `[typescript, react]` detaches only `adonisjs`; untouched pivot
rows keep their surrogate ids and any extra columns. Add extra columns to the
form with `.pivotFields([...])`, which widens each element from a bare key to
`{ id, …pivot }` and rides as the node's children.

## The image field

An image field stores a Drive object **key** — never bytes, never a URL. Uploads
therefore need Drive:

```sh
node ace add @adonisjs/drive     # select "Local filesystem"
```

That writes `config/drive.ts` (an `fs` disk under `storage/`, served at
`/uploads`), registers the provider, and adds `DRIVE_DISK=fs` to your env. Then:

```ts
F.image('coverImage')
  .label('Cover image')
  // MUST precede dimensions(): the rule set is filtered against the accepted
  // types at call time, and the default set includes image/avif, whose header
  // Adonia cannot measure.
  .acceptedTypes(['image/png', 'image/jpeg', 'image/webp'])
  .dimensions({ minWidth: 640, ratio: '16:9' })
  .directory('posts/covers')
  .maxSize(4)
  .previewWidths([320, 640, 1280])
  .nullable()
```

The ordering comment is not decoration: `dimensions()` reads the accepted set
at call time and throws `E_ADONIA_INVALID_CONFIG` if any accepted type has a
header Adonia cannot measure. AVIF is in the default image set and is one of
those, so a bare `F.image('cover').dimensions({…})` fails at boot.

`maxSize(4)` can only tighten `config/adonia.ts`'s `uploads.maxSizeMb`, never
lift it.

### The upload round trip

The node carries `urls.upload`. One multipart part named `file` goes in:

```sh
curl -X POST /admin/posts/field/coverImage/upload -F file=@cover.png
```

```json
{ "key": "adonia/tmp/6f1c…-a3/cover-photo.png", "url": "/uploads/adonia/tmp/…" }
```

The bytes are stored under the temp prefix **first** and verified from the disk
afterwards — magic bytes, then size, then dimensions — because verifying a
buffer you have not stored tells you nothing about the object you will serve. A
rejection deletes the temp object and answers `422`; the response body never
says *which* check failed, since that is only interesting to somebody probing
the sniffer. The filename is sanitized (`cover photo.png` →
`cover-photo.png`), and a UUID segment makes the key unguessable.

The form then submits that key like any other string. On save the temp object
is **promoted** into the field's directory:

```text
posts/covers/6f1c….png
```

and the column holds that key. Keys dropped from the field are deleted after
the transaction commits. A save that does not change the field never touches
Drive at all — which is why an app with no uploads never needs the peer
installed.

Next: [reactivity](/guide/tutorial/reactivity).

Source: https://adonia.pages.dev/guide/tutorial/relations/index.mdx
