---
title: "2. The Post resource"
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.

# 2. The Post resource

A resource is one class that binds a Lucid model to a tree of fields. The
generator writes the first draft by reading the table.

```sh
node ace adonia:resource Post
```

```text
[ success ] create app/adonia/resources/post_resource.ts
[ info ] Next: start the dev server (`node ace serve --hmr`) — the
     indexAdoniaResources() hook picks the resource up and regenerates
     .adonisjs/adonia/.
```

## What it generated

Verbatim, from the blog-admin schema (SQLite, migrations run):

```ts
import { BaseResource, C, F } from '@adonia/core'
import type { SchemaBuilder, SchemaComponent, TableBuilder } from '@adonia/core'
import Post from '#models/post'

/**
 * Post resource (TECH_SPEC §6.1), generated by `node ace adonia:resource Post`
 * from the `Post` model's column metadata.
 *
 * Primary keys, timestamps and `serializeAs: null` attributes are omitted from
 * the form on purpose — see the generator docs in docs/generators.md.
 */
export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'
  static override labels = { singular: 'Post', plural: 'Posts' }

  schema(s: SchemaBuilder): SchemaComponent[] {
return [
  s.section('Post').components([
    F.number('authorId').label('Author id').required(),
    F.textarea('body').label('Body').required().rows(6),
    F.number('categoryId').label('Category id').nullable(),
    F.text('coverImage').label('Cover image').nullable().maxLength(255),
    F.datetime('publishedAt').label('Published at').nullable(),
    F.text('slug').label('Slug').required().maxLength(255),
    F.select('status')
      .label('Status')
      .options({ draft: 'Draft', review: 'Review', published: 'Published' })
      .required(),
    F.textarea('subtitle').label('Subtitle').nullable().rows(6),
    F.text('title').label('Title').required().maxLength(255),
  ]),
]
  }

  override table(t: TableBuilder): TableBuilder {
return t.columns([
  C.number('authorId').label('Author id').sortable(),
  C.text('body').label('Body').sortable().searchable(),
  C.number('categoryId').label('Category id').sortable(),
  C.text('coverImage').label('Cover image').sortable().searchable().link('detail'),
  C.date('publishedAt').label('Published at').sortable(),
])
  }
}
```

Everything in it is a fact about the database: `status` is an enum, so it became
a `select` whose options were read out of the `CHECK` constraint;
`published_at` is nullable, so the field is `.nullable()`; `id`, `created_at`
and `updated_at` are excluded because primary keys and timestamps are never
form fields. The order is the introspection order (alphabetical here), the
column list is capped at five, and the row link landed on the first string
column — which is why `coverImage` is currently the clickable one.

That is a scaffold, not a design. The rest of this chapter reshapes it.

<!-- @sample-preamble
import { BaseResource, F } from '@adonia/core'
import type { FieldOption, SchemaBuilder, SchemaComponent } from '@adonia/core'
import Post from '#models/post'
declare const s: SchemaBuilder
-->

## Layout: a grid, a section, an aside

Editors read a post form as "the writing" and "the settings". Say exactly that:

```ts
s.grid().columns(3).components([
  s.section('Content').columnSpan(2).components([
// title, subtitle, slug, body, cover image
  ]),
  s.aside().components([
// status, published at, author, category, tags
  ]),
])
```

<small>`examples/blog-admin/app/adonia/resources/post_resource.ts`</small>

Layout components come from `s`, the builder handed to `schema()`. They carry
no state: a `grid` is a `grid` node with `key: null`, and the field keys inside
it stay **flat** — `state.title`, never `state.content.title`. That is a
protocol rule, not a convention, and it is what lets you move a field between
sections without breaking a validator, a fill set or a client.

A single `grid()` returned as the sole root is used as the tree root verbatim.
Sections, asides, tabs, wizards and fieldsets nest through `.components([…])`.

## Fields

State-bearing fields come from `F`, imported from `@adonia/core`. The finished
content section:

```ts
s.section('Content').columnSpan(2).components([
  F.text('title').label('Title').required().maxLength(200),
  F.text('subtitle')
.label('Subtitle')
.helper('Only used for special posts.')
.maxLength(200),
  F.text('slug').label('Slug').required().maxLength(220).unique(),
  F.textarea('body').label('Body').rows(12).required(),
])
```

and the settings aside, minus the relations chapter 6 adds:

```ts
const STATUS_OPTIONS: readonly FieldOption[] = [
  { value: 'draft', label: 'Draft', color: 'gray' },
  { value: 'review', label: 'In review', color: 'amber' },
  { value: 'published', label: 'Published', color: 'green' },
]

s.aside().components([
  F.select('status').label('Status').options(STATUS_OPTIONS).default('draft').required(),
  F.datetime('publishedAt').label('Published at'),
])
```

Three things are happening that are worth naming.

**Every field is three concerns at once.** `F.text('title')` declares the state
contract (which attribute it reads and writes), the projection rules (which
contexts it appears in), and the validation contract (`required`,
`maxLength(200)`). One object, compiled three ways: the create/edit form, the
detail page, and — unless you declare a table — the index columns.

**Options with a `color` change the projection.** Because `STATUS_OPTIONS`
carries colours, `status` renders as a badge on the detail page and as a badge
cell in the table instead of plain text. Drop the colours and it degrades to
text. Nothing else changes.

**`default('draft')` prefills the form.** It is not a server-side fallback: a
submission that omits a required key is still invalid. The client sends the
prefilled value back like any other.

The field types used here are a fraction of what ships — `number`, `slider`,
`radio`, `checkbox`, `toggle`, `checkboxList`, `date`, `time`, `color`,
`hidden`, `repeater`, `keyValue`, `json`, `code`, `markdown`, `richText`,
`file`, `image`, `belongsTo`, `belongsToMany`. Each has a page in the
[fields reference](/reference/fields).

## Registering it

Nothing to register. The codegen hook scans `app/adonia/resources/`, so saving
the file regenerates `.adonisjs/adonia/resources.ts`:

```ts no-check
const registry = {
  posts: () => import('#adonia/resources/post_resource'),
} satisfies AdoniaResourceRegistry
```

and the panel — which already imports that registry — serves
`/admin/posts`, `/admin/posts/create`, `/admin/posts/:id`,
`/admin/posts/:id/edit` and the four JSON callbacks behind them.

A resource is instantiated **per request** and holds no request state; the
compiled descriptors and validators are what get cached. Keep the constructor
free of work.

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

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