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
CollectionConfiginterface anddefineCollection()helper live inpackages/nextly/src/collections/config/define-collection.ts. Hooks and access-control types are imported frompackages/nextly/src/shared/types/access.tsandpackages/nextly/src/hooks/types.ts.
Defining a collection
Use defineCollection() from nextly:
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:
import { defineConfig } from "nextly";
import Posts from "./src/collections/posts";
export default defineConfig({
collections: [Posts],
});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.)
- Open the admin panel and navigate to Builder > Collections.
- Click Create Collection.
- Enter a slug (e.g.
posts) and display labels. - Add fields using the drag-and-drop field editor.
- Configure admin options, access control, and hooks through the UI.
- 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-generatedcreatedAt- set on insert (whentimestamps: true, the default)updatedAt- refreshed on every save (whentimestamps: true)
Conditionally auto-injected (declared definition wins)
defineCollection() adds these as text NOT NULL columns only if you don't declare them yourself:
titleslug
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:
- Uniqueness on
slug- the auto-injectedslugis a plaintext NOT NULLcolumn with no unique constraint. To require unique slugs (the typical case for URL-routed content), declare your own field withunique: true. - Admin form visibility - Nextly's admin builds the create/edit form from your
fieldsarray. The auto-injected columns are database-only and don't appear in the form. If you don't declaretitle, the admin shows no title input. - Validation and hooks - auto-injected columns have no field config. Declared fields can attach
required,minLength,beforeChangehooks (e.g.auto-slugto derive slug from title), defaults, and so on. - Custom UI - placeholders, descriptions, field ordering,
admin: { readOnly: true }all live on the field declaration. - 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.
// 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
namefor the user-facing label and don't declaretitle, the auto-injectedtitlecolumn still exists alongsidename- you'll have a redundant database column you have to populate via API on every create. Picktitle(matches the auto-inject and is what most callers expect) or be intentional about why you're usingname.
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 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,
indexesis 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.
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.
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
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.
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.
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 hooks interleave. On a write:
beforeOperationbeforeValidate(collection)beforeValidate(each field that declares one)- Validation - the schema's declared rules are enforced
beforeChange(collection)beforeChange(each field that declares one)- Database write
afterChange(collection), thenafterChange(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.
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
slugis re-sanitised, so a hook returning"Changed By Hook"storeschanged-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
dataobject, 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.
datais the record so far, at the field's own level. On an ordinary create it is the submitted document with defaults,slugand 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 asundefined. For a field inside a group or a repeater it is the containing row, not the top-level document. The field hooks section says what each write path adds and when.
Hook handler signature
Every handler receives a HookContext object:
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 - every field type and validation option
- Singles - single-document content like site settings
- Field Groups - reusable field groups for collections and singles
- Visual Schema Builder - create collections visually with drag-and-drop
- Direct API - query collections from server-side code