# Collections

Define content types with multiple entries using collections.

A collection is a content type that stores multiple entries - blog posts, products, users, media files. Each collection maps to a database table, gets automatic REST endpoints under `/admin/api/collections/[slug]/entries`, and appears in the admin panel with list and edit views.

> **Source of truth:** the `CollectionConfig` interface and `defineCollection()` helper live in `packages/nextly/src/collections/config/define-collection.ts`. Hooks and access-control types are imported from `packages/nextly/src/shared/types/access.ts` and `packages/nextly/src/hooks/types.ts`.

**Code-first**

## Defining a collection

Use `defineCollection()` from `nextly`:

```typescript title="src/collections/posts.ts"
import {
  defineCollection,
  text,
  richText,
  relationship,
  date,
} from "nextly";

export default defineCollection({
  slug: "posts",
  labels: {
    singular: "Post",
    plural: "Posts",
  },
  fields: [
    text({ name: "title", required: true }),
    text({ name: "slug", unique: true }),
    richText({ name: "content" }),
    relationship({ name: "author", relationTo: "users" }),
    date({ name: "publishedAt" }),
  ],
  // Built-in Draft/Published lifecycle (replaces the user-defined
  // `select({ name: "status" })` pattern). See the dedicated guide:
  // /docs/guides/draft-published-status.
  status: true,
  timestamps: true,
  admin: {
    group: "Content",
    icon: "FileText",
    useAsTitle: "title",
    pagination: { defaultLimit: 25 },
    description: "Blog posts and articles",
  },
  access: {
    read: true,
    create: ({ permissions }) => permissions.includes("posts:create"),
    update: ({ permissions }) => permissions.includes("posts:update"),
    delete: ({ permissions }) => permissions.includes("posts:delete"),
  },
  hooks: {
    beforeChange: [
      async ({ data, operation }) => {
        if (operation === "create" && data?.title && !data.slug) {
          return {
            ...data,
            slug: data.title.toLowerCase().replace(/\s+/g, "-"),
          };
        }
        return data;
      },
    ],
  },
});
```

Then register it in your config:

```typescript title="nextly.config.ts"
import { defineConfig } from "nextly";
import Posts from "./src/collections/posts";

export default defineConfig({
  collections: [Posts],
});
```

**Visual Schema Builder**

## Creating a collection in the Visual Schema Builder

The Nextly admin panel ships with a Visual Schema Builder for creating collections without writing code. (In subsequent prose we'll just call it the Schema Builder.)

1. Open the admin panel and navigate to **Builder > Collections**.
2. Click **Create Collection**.
3. Enter a slug (e.g. `posts`) and display labels.
4. Add fields using the drag-and-drop field editor.
5. Configure admin options, access control, and hooks through the UI.
6. Click **Save** to generate the collection.

The Schema Builder produces the same `CollectionConfig` that code-first collections use. To move a builder-created collection into code, run `nextly db:sync --promote <slug>`. It appends the collection's slug and fields to `nextly.config.ts` as a commented-out snippet, and removes the builder's record; the table and its content stay. Nothing else is carried over: add the labels, admin options, access rules, hooks and options such as `status` back in code before you move the snippet into your `collections` array. There is no supported way back: once a code-first collection has been synced, the Schema Builder shows it read-only, and its structure is edited in code.

## System fields

Collections have three categories of fields. Knowing which is which avoids redundant declarations and column-shape surprises.

### Always auto-injected (you cannot opt out)

Every collection row has these. Never declare them yourself.

* `id` - primary key, auto-generated
* `createdAt` - set on insert (when `timestamps: true`, the default)
* `updatedAt` - refreshed on every save (when `timestamps: true`)

### Conditionally auto-injected (declared definition wins)

`defineCollection()` adds these as `text NOT NULL` columns *only if you don't declare them yourself*:

* `title`
* `slug`

If your `fields` array includes a field named `title` or `slug`, your definition replaces the auto-inject - same column, your shape. If you don't declare them, Nextly adds bare-bones columns with no validation, no uniqueness, no admin form input.

### Should you redeclare `title` and `slug`?

Almost always **yes**, even though Nextly will inject them anyway. Five reasons:

1. **Uniqueness on `slug`** - the auto-injected `slug` is a plain `text NOT NULL` column with no unique constraint. To require unique slugs (the typical case for URL-routed content), declare your own field with `unique: true`.
2. **Admin form visibility** - Nextly's admin builds the create/edit form from your `fields` array. The auto-injected columns are database-only and don't appear in the form. If you don't declare `title`, the admin shows no title input.
3. **Validation and hooks** - auto-injected columns have no field config. Declared fields can attach `required`, `minLength`, `beforeChange` hooks (e.g. `auto-slug` to derive slug from title), defaults, and so on.
4. **Custom UI** - placeholders, descriptions, field ordering, `admin: { readOnly: true }` all live on the field declaration.
5. **Self-documentation** - a contributor reading the collection file sees the full surface in one place, without having to know Nextly's reserved-column behavior.

You can legitimately skip declaring them when the collection is internal-only (e.g. an `audit-log`) and you don't need form visibility, validation, or unique slugs.

```ts
// Good: declared explicitly so slug can be unique and the admin shows inputs.
import { defineCollection, text, textarea } from "nextly/config";

export const Categories = defineCollection({
  slug: "categories",
  fields: [
    text({ name: "title", required: true }),
    text({ name: "slug", required: true, unique: true }),
    textarea({ name: "description" }),
  ],
});
```

> **Don't mix conventions.** If you declare a field named `name` for the user-facing label and don't declare `title`, the auto-injected `title` column still exists alongside `name` - you'll have a redundant database column you have to populate via API on every create. Pick `title` (matches the auto-inject and is what most callers expect) or be intentional about why you're using `name`.

## Collection options

Only `slug` and `fields` are required.

### `slug`

|              |          |
| ------------ | -------- |
| **Type**     | `string` |
| **Required** | Yes      |

Unique identifier used as the database table name (unless `dbName` is set), the REST path (`/admin/api/collections/[slug]/entries`), and the internal reference. Must be lowercase, URL-friendly, and not a reserved SQL keyword.

### `fields`

|              |                 |
| ------------ | --------------- |
| **Type**     | `FieldConfig[]` |
| **Required** | Yes             |

Array of field definitions. See [Fields](https://nextlyhq.com/docs/configuration/fields.md) for the full inventory.

### `labels`

|             |                                          |
| ----------- | ---------------------------------------- |
| **Type**    | `{ singular?: string; plural?: string }` |
| **Default** | Auto-generated from slug                 |

Display names in the admin UI. If omitted, the singular label is derived from the slug (`blog-posts` → `Blog Posts`) and the plural is generated from the singular.

### `timestamps`

|             |           |
| ----------- | --------- |
| **Type**    | `boolean` |
| **Default** | `true`    |

When `true`, every entry gets `createdAt` (set on insert) and `updatedAt` (refreshed on every save) columns.

### `dbName`

|             |                |
| ----------- | -------------- |
| **Type**    | `string`       |
| **Default** | Same as `slug` |

Custom database table name. Useful for legacy schemas or when the slug doesn't match your naming convention.

### `description`

|             |             |
| ----------- | ----------- |
| **Type**    | `string`    |
| **Default** | `undefined` |

Description displayed in the admin UI. Falls back to `admin.description` when reading from the type.

### `sanitize`

|             |           |
| ----------- | --------- |
| **Type**    | `boolean` |
| **Default** | `true`    |

When enabled, the global sanitization hook strips HTML tags from plain-text fields (`text`, `textarea`, `email`) before storage. Set to `false` only if the collection intentionally stores HTML in those fields.

### `search`

|             |                                               |
| ----------- | --------------------------------------------- |
| **Type**    | `SearchConfig`                                |
| **Default** | Auto-detects `text`/`textarea`/`email` fields |

| Property           | Type       | Default                              | Description                              |
| ------------------ | ---------- | ------------------------------------ | ---------------------------------------- |
| `searchableFields` | `string[]` | All `text`/`textarea`/`email` fields | Fields included in search queries.       |
| `minSearchLength`  | `number`   | `2`                                  | Minimum query length before search runs. |

### `indexes`

> **The rules below need the release after 0.0.2-alpha.66.** On 0.0.2-alpha.66, `indexes` is accepted and validated but never applied to the database.

|             |                 |
| ----------- | --------------- |
| **Type**    | `IndexConfig[]` |
| **Default** | `undefined`     |

Compound database indexes. For single-field indexes, use `index: true` directly on the field. `id` is the primary key and `createdAt` is indexed on its own automatically.

An index names top-level fields, and may also name `id`, `createdAt` and `updatedAt`. A field nested in a group or repeater cannot be indexed: its parent stores all its children in one JSON column, so the child has no column of its own, and naming one (`"seo.title"`) is refused when the collection is defined.

```typescript
indexes: [
  { fields: ["authorId", "createdAt"] },
  { fields: ["slug", "locale"], unique: true, name: "uq_slug_locale" },
]
```

A `name` is optional, and must start with `idx_` (or `uq_` for a unique index):
those are the names schema changes manage, so an index you remove from the
config is dropped from the database. Without one, a name is derived from the
table and columns.

Development push creates the indexes a collection declares, whether the change
arrives through a hot reload, `nextly db:sync`, or the boot that creates the
collection's table, and `migrate:create` writes them into a migration for
production. `migrate:check` and the drift warning at boot count a declared
index the database lacks as a pending change. Collections made in the Schema
Builder cannot declare compound indexes.

Every index is checked against PostgreSQL, MySQL and SQLite when the schema
is built from the config (development push, `db:sync`, `migrate:create`),
whichever one you run. A JSON field, or a text field with no bounded width
such as a textarea, cannot be part of one. MySQL also limits a whole index key
to 3,072 bytes, where a text field counts 4 bytes for each character of its
width (1,020 at the default 255), so an index over four text fields is
refused; index fewer, or narrower, fields. An explicit `name` may be at most
63 characters, the longest PostgreSQL keeps.

### `endpoints`

|             |                    |
| ----------- | ------------------ |
| **Type**    | `CustomEndpoint[]` |
| **Default** | `undefined`        |

`defineCollection()` accepts and type-checks `endpoints`, each `{ path, method, handler }`, but no route serves them: neither the REST handler nor any other route calls these handlers, so a request to their path is answered as if they were not there. For logic of your own, add a Next.js route handler in your app and read and write through the [Direct API](https://nextlyhq.com/docs/api-reference/direct-api.md).

### `custom`

|             |                           |
| ----------- | ------------------------- |
| **Type**    | `Record<string, unknown>` |
| **Default** | `undefined`               |

Arbitrary metadata for hooks, plugins, or custom code. Not persisted to the database.

## Admin options

Configure how the collection appears in the admin panel via the `admin` property.

| Property                  | Type                        | Default             | Description                                                                            |
| ------------------------- | --------------------------- | ------------------- | -------------------------------------------------------------------------------------- |
| `group`                   | `string`                    | None                | Sidebar group name; collections sharing a group appear together.                       |
| `icon`                    | `string`                    | None                | Lucide icon name (e.g. `"FileText"`, `"Users"`).                                       |
| `hidden`                  | `boolean`                   | `false`             | Hide from sidebar navigation (still reachable via direct URL and API).                 |
| `order`                   | `number`                    | `100`               | Sort order within sidebar group (lower = higher).                                      |
| `sidebarGroup`            | `string`                    | None                | Custom sidebar group slug; moves the entry from its default section to a custom group. |
| `isPlugin`                | `boolean`                   | `false`             | Render under the "Plugins" sidebar section instead of "Collections".                   |
| `useAsTitle`              | `string`                    | Document ID         | Field name used as the entry title in lists and breadcrumbs.                           |
| `pagination.defaultLimit` | `number`                    | `10`                | Default entries per page.                                                              |
| `pagination.limits`       | `number[]`                  | `[10, 25, 50, 100]` | Available page-size options.                                                           |
| `description`             | `string`                    | None                | Help text below the collection title.                                                  |
| `preview`                 | `CollectionPreviewConfig`   | None                | Adds a "Preview" button to the entry form. See below.                                  |
| `components`              | `CollectionAdminComponents` | None                | Override default views and inject custom React components. See below.                  |

### Preview URLs

```typescript
admin: {
  preview: {
    url: (entry) => `/preview/posts/${entry.slug}`,
    openInNewTab: true,         // default true
    label: "Preview Post",      // default "Preview"
  },
}
```

The `url` function receives the current entry data (which may include unsaved changes) and returns either a URL string or `null` to hide the button for that entry.

### Custom admin components

Override default admin views or inject components at specific positions. Each entry uses the `"package-name/path#ExportName"` component-path format.

```typescript
admin: {
  components: {
    views: {
      Edit: { Component: "@nextlyhq/plugin-form-builder/admin#FormBuilderView" },
      List: { Component: "@nextlyhq/plugin-form-builder/admin#FormsListView" },
    },
    BeforeListTable: "@nextlyhq/plugin-form-builder/admin#CreateFormButton",
    AfterListTable: "@nextlyhq/plugin-form-builder/admin#FormsFooter",
    BeforeEdit: "@nextlyhq/plugin-form-builder/admin#FormBuilderHeader",
    AfterEdit: "@nextlyhq/plugin-form-builder/admin#FormBuilderFooter",
  },
}
```

Available view overrides: `Edit`, `List`. Available injection points: `BeforeListTable`, `AfterListTable`, `BeforeEdit`, `AfterEdit`.

## Access control

Each CRUD operation accepts a function (returning `boolean | Promise<boolean>`), a literal `boolean`, or can be omitted to fall back to the database role/permission system.

A declared rule is the whole answer for a signed-in user: the role's permission is not checked beside it. So for staff and API keys a rule should check the permission for its operation itself, then narrow from there; a plugin-defined person, who holds no permissions, is admitted by a branch of its own. Don't decide by a role's name: a custom role holding the permission, and an API key whose roles carry none of the names, would be refused. See [Roles and Permissions](https://nextlyhq.com/docs/permissions.md#collection-access).

```typescript
access: {
  create: ({ permissions }) => permissions.includes("posts:create"),
  read: true,
  update: ({ permissions }) => permissions.includes("posts:update"),
  delete: ({ permissions }) => permissions.includes("posts:delete"),
}
```

The `AccessControlContext` passed to functions has the following shape:

| Property      | Type                                         | Description                                                                                                     |
| ------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `user`        | `MinimalUser \| null`                        | The authenticated user (or `null` for unauthenticated requests).                                                |
| `roles`       | `string[]`                                   | The user's role slugs (resolved from DB, includes inherited roles). Don't decide by these: check `permissions`. |
| `permissions` | `string[]`                                   | Effective permissions in `"resource:action"` format.                                                            |
| `operation`   | `"create" \| "read" \| "update" \| "delete"` | The operation being checked.                                                                                    |
| `collection`  | `string`                                     | The collection slug.                                                                                            |

**Rules:**

* Code-defined access is what a signed-in user is judged by when it is declared, so it includes the permission check itself.
* Omitting an operation falls back to the database role/permission system.
* Super-admin always bypasses all access checks.

## Hooks

Hooks let you run custom logic at specific points in a document's lifecycle. Every hook property is an array of handlers - they run in array order and can modify the data flowing through (for `before*` hooks).

### Eight available hooks

| Hook              | Triggers on                                                              | Can modify                |
| ----------------- | ------------------------------------------------------------------------ | ------------------------- |
| `beforeOperation` | Before any operation begins.                                             | Yes (operation arguments) |
| `beforeValidate`  | Before validation during create/update.                                  | Yes (data)                |
| `beforeChange`    | After validation passes, before the database write during create/update. | Yes (data)                |
| `afterChange`     | After the database write during create/update.                           | No (side effects)         |
| `beforeRead`      | Before reading from the database.                                        | Yes (query parameters)    |
| `afterRead`       | After reading from the database.                                         | Yes (transform output)    |
| `beforeDelete`    | Before deletion. Throw to prevent.                                       | No                        |
| `afterDelete`     | After deletion.                                                          | No (side effects)         |

### Execution order

Collection-level and [field-level](https://nextlyhq.com/docs/configuration/fields.md#hooks) hooks
interleave. On a write:

1. `beforeOperation`
2. `beforeValidate` (collection)
3. `beforeValidate` (each field that declares one)
4. **Validation** - the schema's declared rules are enforced
5. `beforeChange` (collection)
6. `beforeChange` (each field that declares one)
7. **Database write**
8. `afterChange` (collection), then `afterChange` (fields). On a **Single** this
   pair is reversed: the field hooks run first, then the entity hook.

A direct read runs `beforeOperation`, `beforeRead`, the query, then `afterRead`
at the collection level and then per field. A row reached through relationship
expansion is different: only the target collection's field-level `afterRead`
runs on it, not its collection-level one, and it runs before the source
collection's own `afterRead`. A delete runs `beforeOperation`, `beforeDelete`,
the delete, then `afterDelete`.

The validation gate is what separates the two write hooks. Use `beforeValidate`
to supply or repair a value you want the rules applied to; use `beforeChange` to
derive the value that gets stored, knowing the document has already passed them.
What a `beforeChange` handler returns is written without being re-validated.

> [!WARNING]
> **The field-level `beforeChange` is the last hook before the write.** A
> top-level field hook is handed `data` alongside its own `value`, so it can
> change other fields, not only its own. That means a collection-level
> `beforeChange` is not the final word: a field hook registered later, by a
> plugin or by the application, runs after it and can overwrite what it set.
> There is no collection-level phase after the field hooks. This matches
> Payload's ordering, so hooks written against that model behave the same here.
>
> Four things bound what a field hook can guarantee:
>
> * **It is the last phase, not the last step.** After every hook has run, core
>   still normalises the record before the write. On a collection, a `slug` is
>   re-sanitised, so a hook returning `"Changed By Hook"` stores
>   `changed-by-hook`; on a Single it is not, and that value is stored as
>   written. On both, password fields are hashed and relationship and upload
>   values are reduced to ids. A hook whose output must survive exactly cannot
>   rely on being last.
> * **A later field hook can still change a sibling.** Field hooks run in
>   registration order over one shared `data` object, so a hook on an earlier
>   field that sets a sibling can be overwritten by that sibling's own hook.
>   What no longer follows is a collection-level phase.
> * **It runs only when its field is in the data.** An update whose patch omits
>   the field, or an earlier hook that deleted the key, skips that field's hooks
>   entirely. A field hook is not a point that runs on every write.
> * **`data` is the record so far, at the field's own level.** On an ordinary
>   create it is the submitted document with defaults, `slug` and
>   title already applied (a bulk or transactional create applies no defaults);
>   on an update it is the submitted patch, not the stored row, so an unchanged
>   sibling reads as `undefined`. For a field inside a group or a repeater it is
>   the containing row, not the top-level document. The
>   [field hooks](https://nextlyhq.com/docs/configuration/fields.md#hooks) section says what each
>   write path adds and when.

### Hook handler signature

Every handler receives a `HookContext` object:

```typescript
hooks: {
  beforeChange: [
    async ({ data, operation, collection, user, context, req }) => {
      if (operation === "create" && !data.slug) {
        return { ...data, slug: slugify(data.title) };
      }
      return data;
    },
  ],
  afterChange: [
    async ({ data, req }) => {
      // Use req.nextly for the Direct API inside hooks
      await req?.nextly?.create({
        collection: "activity-logs",
        data: { action: "post_updated", postId: data.id },
      });
    },
  ],
}
```

**Return-value behavior:**

* `before*` hooks: return modified data to pass to the next hook (or the DB).
* `after*` hooks: return value is ignored; use these for side effects.
* Throwing aborts the operation and rolls back the transaction.

### When to use which hook

* `beforeOperation` - validate the request, short-circuit before reading args (rate-limit metadata, audit logging entry).
* `beforeValidate` - coerce inputs (trim strings, normalize emails, derive slugs).
* `beforeChange` - final mutations before the row is persisted (hash passwords, stamp metadata).
* `afterChange` - outbound side effects (cache busting, webhooks, queue jobs).
* `beforeRead` - modify query filters (multi-tenant scoping, soft-delete filtering).
* `afterRead` - shape the response (compute virtual fields, redact sensitive properties).
* `beforeDelete` - block deletes when there are dependents.
* `afterDelete` - cascade cleanup (delete files, revoke tokens, audit log).

## Next steps

* [Fields](https://nextlyhq.com/docs/configuration/fields.md) - every field type and validation option
* [Singles](https://nextlyhq.com/docs/configuration/singles.md) - single-document content like site settings
* [Field Groups](https://nextlyhq.com/docs/configuration/field-groups.md) - reusable field groups for collections and singles
* [Visual Schema Builder](https://nextlyhq.com/docs/schema-builder.md) - create collections visually with drag-and-drop
* [Direct API](https://nextlyhq.com/docs/api-reference/direct-api.md) - query collections from server-side code
