You're reading docs for Nextly Alpha. APIs may change between releases.

Configuration

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.

Defining a collection

Use defineCollection() from nextly:

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:

nextly.config.ts
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.)

  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.

// 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

Typestring
RequiredYes

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

TypeFieldConfig[]
RequiredYes

Array of field definitions. See Fields for the full inventory.

labels

Type{ singular?: string; plural?: string }
DefaultAuto-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

Typeboolean
Defaulttrue

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

dbName

Typestring
DefaultSame as slug

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

description

Typestring
Defaultundefined

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

sanitize

Typeboolean
Defaulttrue

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.

TypeSearchConfig
DefaultAuto-detects text/textarea/email fields
PropertyTypeDefaultDescription
searchableFieldsstring[]All text/textarea/email fieldsFields included in search queries.
minSearchLengthnumber2Minimum 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.

TypeIndexConfig[]
Defaultundefined

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

TypeCustomEndpoint[]
Defaultundefined

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

TypeRecord<string, unknown>
Defaultundefined

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.

PropertyTypeDefaultDescription
groupstringNoneSidebar group name; collections sharing a group appear together.
iconstringNoneLucide icon name (e.g. "FileText", "Users").
hiddenbooleanfalseHide from sidebar navigation (still reachable via direct URL and API).
ordernumber100Sort order within sidebar group (lower = higher).
sidebarGroupstringNoneCustom sidebar group slug; moves the entry from its default section to a custom group.
isPluginbooleanfalseRender under the "Plugins" sidebar section instead of "Collections".
useAsTitlestringDocument IDField name used as the entry title in lists and breadcrumbs.
pagination.defaultLimitnumber10Default entries per page.
pagination.limitsnumber[][10, 25, 50, 100]Available page-size options.
descriptionstringNoneHelp text below the collection title.
previewCollectionPreviewConfigNoneAdds a "Preview" button to the entry form. See below.
componentsCollectionAdminComponentsNoneOverride 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:

PropertyTypeDescription
userMinimalUser | nullThe authenticated user (or null for unauthenticated requests).
rolesstring[]The user's role slugs (resolved from DB, includes inherited roles). Don't decide by these: check permissions.
permissionsstring[]Effective permissions in "resource:action" format.
operation"create" | "read" | "update" | "delete"The operation being checked.
collectionstringThe 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

HookTriggers onCan modify
beforeOperationBefore any operation begins.Yes (operation arguments)
beforeValidateBefore validation during create/update.Yes (data)
beforeChangeAfter validation passes, before the database write during create/update.Yes (data)
afterChangeAfter the database write during create/update.No (side effects)
beforeReadBefore reading from the database.Yes (query parameters)
afterReadAfter reading from the database.Yes (transform output)
beforeDeleteBefore deletion. Throw to prevent.No
afterDeleteAfter deletion.No (side effects)

Execution order

Collection-level and field-level 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.

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