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

Configuration

Fields

Complete reference for every field type, their options, and usage patterns.

Fields define the shape of your content. Every collection, single, and field group is composed of fields.

Nextly exports field-helper functions from nextly. They eliminate the need to set the type property manually and give you full TypeScript autocomplete.

import { text, number, select, option } from "nextly";

const fields = [
  text({ name: "title", required: true }),
  number({ name: "price", min: 0 }),
  select({
    name: "priority",
    options: [option("Low"), option("Medium"), option("High")],
  }),
];

Status / Draft-Published is not a select field. If you want a Draft / Published lifecycle on a collection, use the built-in status: true flag on defineCollection (or the Advanced-tab toggle in the Schema Builder) -- not a hand-rolled select field. See Draft / Published status.

Source of truth: packages/nextly/src/collections/fields/types/ (one file per field type) and packages/nextly/src/collections/fields/helpers.ts (the helper exports).

Field categories

Seven categories partition the field inventory.

CategoryFields
Basictext, textarea, richText, email, password, code, number, checkbox, date
Selectionselect, radio, chips
Mediaupload
Relationshiprelationship
Layoutrepeater, group
Componentcomponent
Advancedjson

The "Common options across fields" section at the bottom lists the shared properties (name, label, required, admin, access, hooks, validate, custom).


Basic

text

Single-line text input. Supports hasMany mode for storing arrays of strings (tag-style input).

text({ name: "title", required: true })
OptionTypeDefaultDescription
namestring—Required. Unique field identifier. Lowercase, no spaces.
minLengthnumber—Minimum string length.
maxLengthnumber—Maximum string length. Also sets the DB column size.
hasManybooleanfalseAccept an array of strings.
minRowsnumber—Min items when hasMany: true.
maxRowsnumber—Max items when hasMany: true.
defaultValuestring | string[] | (data) => …—Static default or function.
admin.autoCompletestring—HTML autocomplete attribute (e.g. "name", "tel").
text({ name: "title", required: true, maxLength: 200 });

// Multiple values (tags)
text({
  name: "tags",
  hasMany: true,
  minRows: 1,
  maxRows: 10,
});

Gotchas: maxLength is the database column width — pick it once and migrate carefully if you change it.


textarea

Multi-line text input.

textarea({ name: "description", maxLength: 1000 })
OptionTypeDefaultDescription
minLengthnumber—Minimum string length.
maxLengthnumber—Maximum string length.
defaultValuestring | (data) => string—Static default or function.
admin.rowsnumber3Number of visible text rows.
admin.resize"vertical" | "horizontal" | "both" | "none""vertical"Resize behavior.
textarea({
  name: "bio",
  admin: { rows: 5, resize: "none" },
});

richText

Full-featured WYSIWYG editor powered by Lexical. Content is stored as a Lexical editor-state JSON object.

richText({ name: "content" })
OptionTypeDefaultDescription
featuresRichTextFeature[]See "Default features" belowEnabled editor features. Pass [] for plain text.
defaultValueRichTextValue | (data) => RichTextValue—Static default or function.
admin.hideToolbarbooleanfalseHide the editor toolbar (keyboard shortcuts still work).

Default features when features is omitted: bold, italic, underline, strikethrough, code, h1–h4, orderedList, unorderedList, indent, blockquote, link.

All available features (RichTextFeature union):

CategoryFeatures
Formattingbold, italic, underline, strikethrough, code, subscript, superscript
Text stylingfontFamily, fontSize, fontColor, bgColor
Headingsh1, h2, h3, h4, h5, h6
ListsorderedList, unorderedList, checkList, indent
Links and medialink, upload, relationship
Advancedtable, horizontalRule, codeBlock, align
Rich mediavideo, buttonLink, collapsible, gallery
// Simple blog editor
richText({
  name: "content",
  features: ["bold", "italic", "link", "h2", "h3", "orderedList", "unorderedList"],
});

// Full-featured editor
richText({
  name: "body",
  features: [
    "bold", "italic", "underline", "strikethrough", "code",
    "h1", "h2", "h3", "blockquote",
    "orderedList", "unorderedList", "checkList",
    "link", "upload", "table", "codeBlock",
  ],
});

Gotchas: the value is a JSON object ({ root: { children: […] } }) — not an HTML string. Use the REST endpoint's ?richTextFormat=html query param if you want pre-rendered HTML.


email

Specialised text input with built-in email-format validation.

email({ name: "email", required: true, unique: true })
OptionTypeDefaultDescription
defaultValuestring | (data) => string—Static default or function.
admin.autoCompletestring"email"HTML autocomplete attribute.

Custom validate runs in addition to the built-in email-format check, so you can layer on domain restrictions without losing format validation.

email({
  name: "workEmail",
  validate: (value) =>
    !value || value.endsWith("@company.com")
      ? true
      : "Must be a company email address",
});

password

Masked text input for passwords. Values should be hashed before storage using a beforeChange hook, and read access should return false so hashed values don't escape the database.

password({
  name: "password",
  required: true,
  minLength: 8,
})
OptionTypeDefaultDescription
minLengthnumber—Minimum password length. (Helper-level default is none; documented recommended minimum is 8.)
maxLengthnumber—Maximum password length. Most hashing algorithms cap around 72–128 bytes.
defaultValuestring | (data) => string—Static default. Setting one is rarely a good idea security-wise.
admin.autoComplete"new-password" | "current-password" | "off""new-password"HTML autocomplete attribute.
admin.showStrengthIndicatorbooleanfalseShow a visual password-strength meter.

Full working example:

password({
  name: "password",
  required: true,
  minLength: 8,
  access: { read: () => false },
  hooks: {
    beforeChange: [
      async ({ value }) => (value ? await bcrypt.hash(value, 10) : value),
    ],
  },
});

Gotchas: there is no built-in hashing — the field type stores whatever you give it. Always pair with a hashing hook and a read: () => false field-level access rule.


code

Code editor with syntax highlighting. Supports 29 languages.

code({
  name: "snippet",
  admin: { language: "javascript" },
})
OptionTypeDefaultDescription
defaultValuestring | (data) => string—Static default or function.
admin.languageCodeLanguage"plaintext"Syntax-highlighting language.
admin.editorOptions.lineNumbersbooleantrueShow line numbers.
admin.editorOptions.wordWrapbooleanfalseEnable word wrapping.
admin.editorOptions.tabSizenumber2Tab width in spaces.
admin.editorOptions.useTabsbooleanfalseUse tabs instead of spaces.
admin.editorOptions.minHeightnumber200Minimum editor height in px.
admin.editorOptions.maxHeightnumber—Maximum editor height in px.
admin.editorOptions.fontSizenumber14Font size in px.
admin.editorOptions.fontFamilystring"monospace"Editor font family.
admin.editorOptions.foldingbooleantrueEnable code folding.
admin.editorOptions.matchBracketsbooleantrueHighlight matching brackets.
admin.editorOptions.autoCloseBracketsbooleantrueAuto-close brackets and quotes.

Supported CodeLanguage values: javascript, typescript, jsx, tsx, html, css, scss, less, json, markdown, yaml, xml, sql, graphql, python, ruby, php, java, c, cpp, csharp, go, rust, swift, kotlin, shell, bash, powershell, dockerfile, plaintext.

code({
  name: "snippet",
  admin: {
    language: "javascript",
    editorOptions: { lineNumbers: true, minHeight: 300 },
  },
});

number

Numeric input for integers or decimals. Supports hasMany mode for arrays of numbers.

number({ name: "price", required: true, min: 0, admin: { step: 0.01 } })
OptionTypeDefaultDescription
minnumber—Minimum allowed value.
maxnumber—Maximum allowed value.
hasManybooleanfalseAccept an array of numbers.
minRowsnumber—Min items when hasMany: true.
maxRowsnumber—Max items when hasMany: true.
defaultValuenumber | number[] | (data) => …—Static default or function.
admin.stepnumber1Increment step for spinner buttons.
admin.placeholderstring—Placeholder text.
number({ name: "rating", min: 1, max: 5, admin: { step: 1 } });

checkbox

Boolean true/false toggle.

checkbox({ name: "published", defaultValue: false })
OptionTypeDefaultDescription
defaultValueboolean | (data) => boolean—Initial value. If required: true and unset, defaults to false.
checkbox({
  name: "featured",
  label: "Featured Post",
  admin: { position: "sidebar" },
});

// Required terms-acceptance
checkbox({
  name: "termsAccepted",
  required: true,
  validate: (value) =>
    value === true ? true : "You must accept the terms to continue",
});

date

Date and/or time picker. Dates are stored in UTC as ISO 8601 strings.

date({ name: "publishedAt" })
OptionTypeDefaultDescription
defaultValuestring | Date | (data) => string | Date—Static default or function.
admin.date.pickerAppearance"dayOnly" | "dayAndTime" | "timeOnly" | "monthOnly""dayOnly"Picker mode.
admin.date.displayFormatstring—Display format string (date-fns format).
admin.date.monthsToShow1 | 21Months visible in the picker (max 2).
admin.date.minDateDate | string—Earliest selectable date.
admin.date.maxDateDate | string—Latest selectable date.
admin.date.minTimeDate | string—Earliest selectable time (when picker includes time).
admin.date.maxTimeDate | string—Latest selectable time.
admin.date.timeIntervalsnumber30Time step in minutes.
admin.date.timeFormatstring"h:mm aa"Time display format.
// Date and time
date({
  name: "eventStart",
  admin: {
    date: { pickerAppearance: "dayAndTime", timeIntervals: 15 },
  },
});

// Time only
date({
  name: "openingTime",
  admin: {
    date: { pickerAppearance: "timeOnly", timeFormat: "HH:mm" },
  },
});

// Month only (e.g., card expiration)
date({
  name: "expirationMonth",
  admin: {
    date: { pickerAppearance: "monthOnly", displayFormat: "MM/yyyy" },
  },
});

Gotchas: values are stored in UTC; format display in the client when displaying to users.


Selection

select

Dropdown for choosing from predefined options. Supports single or multi-select with searchable input.

import { select, option } from "nextly";

select({
  name: "priority",
  options: [option("Low"), option("Medium"), option("High")],
})

Use select for arbitrary categorical options (priority, region, theme, etc.). For a collection's Draft / Published lifecycle, prefer the built-in status: true flag on defineCollection -- see Draft / Published status.

OptionTypeDefaultDescription
optionsSelectOption[]—Required. Array of { label, value } objects.
hasManybooleanfalseAllow multiple selections.
enumNamestringAutoCustom SQL enum name.
interfaceNamestring—TypeScript interface name for code generation.
filterOptions(args) => SelectOption[]—Dynamically filter options based on data, sibling data, and user.
defaultValuestring | string[] | (data) => …—Default value(s).
admin.isClearablebooleanfalseShow a clear button.
admin.isSortablebooleanfalseEnable drag-and-drop reorder (multi-select only).

The option() helper auto-generates the value from the label by lowercasing it and replacing spaces with underscores. Pass an explicit second arg if you want a different value.

option("Draft");        // { label: "Draft", value: "draft" }
option("In Review");    // { label: "In Review", value: "in_review" }
option("Active", "active");
// Multi-select
select({
  name: "categories",
  hasMany: true,
  options: [option("Tech"), option("Business"), option("Design")],
  admin: { isClearable: true, isSortable: true },
});

// Cascading dropdown
select({
  name: "subcategory",
  options: allSubcategories,
  filterOptions: ({ data }) =>
    allSubcategories.filter((s) => s.parentId === data.category),
});

Gotchas: option value strings should not contain hyphens or special characters — that's a GraphQL enum constraint. Underscores are fine.


radio

Radio button group for single selection. Best when all options should be visible at once.

radio({
  name: "priority",
  options: [option("Low"), option("Medium"), option("High")],
})
OptionTypeDefaultDescription
optionsSelectOption[]—Required. Array of { label, value } objects.
enumNamestringAutoCustom SQL enum name.
interfaceNamestring—TypeScript interface name for code generation.
defaultValuestring | (data) => string—Default value (must match an option).
admin.layout"horizontal" | "vertical""horizontal"Button layout direction.
radio({
  name: "size",
  options: [option("S"), option("M"), option("L"), option("XL")],
  admin: { layout: "horizontal" },
});

chips

Free-form multi-value string field that stores an array of unique strings. Renders as interactive chips/tags. Duplicate entries are automatically prevented.

chips({ name: "tags" })
OptionTypeDefaultDescription
defaultValuestring[] | (data) => string[]—Initial chips.
maxChipsnumber—Maximum number of chips allowed. The add input is hidden once reached.
minChipsnumber—Minimum number of chips required (validation).
admin.placeholderstring"Type and press Enter to add"Placeholder text for the input.
chips({
  name: "keywords",
  required: true,
  minChips: 1,
  maxChips: 10,
});

Media

upload

Reference files from upload-enabled collections. Works like relationship but with media-specific UI (thumbnails) and a richer filterOptions query schema.

upload({ name: "featuredImage", relationTo: "media" })
OptionTypeDefaultDescription
relationTostring | string[]—Required. Upload-collection slug or array of slugs (polymorphic).
hasManybooleanfalseAllow multiple file selections.
minRowsnumber—Min uploads when hasMany: true.
maxRowsnumber—Max uploads when hasMany: true.
maxDepthnumber1Population depth for the related document.
maxFileSizenumber—Max file size in bytes (rejected before upload).
mimeTypesstring—Comma-separated allowed MIME types (e.g. "image/*" or "image/png,application/pdf").
filterOptionsUploadFilterQuery | (args) => boolean | UploadFilterQuery | Promise<…>—Filter available uploads. Static query or dynamic function.
defaultValueSee type union—Static default ID, array of IDs, polymorphic ref, or function.
admin.allowCreatebooleantrueAllow uploading new files from the field.
admin.allowEditbooleantrueAllow editing upload metadata.
admin.isSortablebooleantrueDrag-and-drop reorder when hasMany: true.
admin.displayPreviewbooleaninherited from collectionShow thumbnail preview.

filterOptions static-query operators: equals, not_equals, contains, in, not_in, exists (strings); equals, not_equals, greater_than, greater_than_equal, less_than, less_than_equal, exists (numbers). Available filterable keys: mimeType, filesize, width, height, filename, alt, plus any custom field on the upload collection.

// Gallery with constraints
upload({
  name: "gallery",
  relationTo: "media",
  hasMany: true,
  maxRows: 10,
  filterOptions: { mimeType: { contains: "image" } },
});

// Polymorphic uploads
upload({
  name: "attachment",
  relationTo: ["media", "documents"],
});

// Dynamic filter — require HD images for hero
upload({
  name: "heroImage",
  relationTo: "media",
  filterOptions: () => ({
    mimeType: { contains: "image" },
    width: { greater_than_equal: 1920 },
    height: { greater_than_equal: 1080 },
  }),
});

Relationship

relationship

Reference documents from other collections. Supports single, multi, and polymorphic relationships.

relationship({ name: "author", relationTo: "users", required: true })
OptionTypeDefaultDescription
relationTostring | string[]—Required. Target collection slug or array of slugs (polymorphic).
hasManybooleanfalseAllow multiple document references.
minRowsnumber—Min selections when hasMany: true.
maxRowsnumber—Max selections when hasMany: true.
maxDepthnumber1Population depth for nested relationships.
filterOptionsRelationshipFilterQuery | (args) => boolean | RelationshipFilterQuery | Promise<…>—Filter available documents. Static query or dynamic function.
defaultValueSee type union—Static default ID, array of IDs, polymorphic ref, or function.
admin.allowCreatebooleantrueAllow creating new documents from the field.
admin.allowEditbooleantrueAllow editing related documents.
admin.isSortablebooleantrueEnable drag-and-drop reorder (multi only).
admin.sortOptionsstring | Record<string, string>—Default sort. Prefix with - for descending. Per-collection mapping for polymorphic.
admin.appearance"select" | "drawer""select"Picker UI style. Use "drawer" for large lists.
// Has many
relationship({
  name: "categories",
  relationTo: "categories",
  hasMany: true,
  maxRows: 5,
});

// Polymorphic
relationship({
  name: "relatedContent",
  relationTo: ["posts", "pages", "products"],
  hasMany: true,
});

// Self-reference, exclude self
relationship({
  name: "parent",
  relationTo: "pages",
  filterOptions: ({ id }) =>
    id ? { id: { not_equals: id } } : true,
});

// Drawer picker for big lists
relationship({
  name: "featuredProducts",
  relationTo: "products",
  hasMany: true,
  admin: { appearance: "drawer", sortOptions: "-sales" },
});

Gotchas: when both filterOptions and validate are set, the filter is not automatically re-validated server-side — include the same constraint in validate if you need it enforced.


Layout

repeater

Repeatable sets of fields. Each row contains the same field structure. Rows can be added, removed, and reordered.

repeater({
  name: "links",
  labels: { singular: "Link", plural: "Links" },
  fields: [
    text({ name: "label", required: true }),
    text({ name: "url", required: true }),
  ],
})
OptionTypeDefaultDescription
fieldsFieldConfig[]—Required. Field definitions for each row.
minRowsnumber—Minimum rows.
maxRowsnumber—Maximum rows. The Add button is disabled when reached.
labels{ singular?: string; plural?: string }—Custom row labels (e.g. "Link" / "Links").
interfaceNamestring—TypeScript interface name for code generation.
dbNamestringAutoCustom database table name.
virtualbooleanfalseSkip database storage; field exists only in API.
defaultValueRepeaterRowValue[] | (data) => RepeaterRowValue[]—Static default rows.
admin.initCollapsedbooleanfalseRender rows initially collapsed.
admin.isSortablebooleantrueEnable drag-and-drop reorder.
admin.components.RowLabelReact.ComponentType<RepeaterRowLabelProps>—Custom row-label component.
// FAQ with custom row labels
repeater({
  name: "faq",
  labels: { singular: "Question", plural: "Questions" },
  fields: [
    text({ name: "question", required: true }),
    richText({ name: "answer", required: true }),
  ],
  admin: {
    initCollapsed: true,
    // RowLabel component must be provided as a path string when using
    // a server-rendered config; React component in client contexts.
  },
});

Note: Use repeater() to define this field. The internal type is "repeater". (Some other CMSes call this concept "array".)

Cross-links: Field Groups covers the alternative repeatable field-group pattern when you want a typed schema instead of inline fields.


group

Organize related fields together. Named groups create nested data; groups without a name are presentational only.

group({
  name: "seo",
  fields: [
    text({ name: "metaTitle", maxLength: 60 }),
    textarea({ name: "metaDescription", maxLength: 160 }),
  ],
})
// Stored data: { seo: { metaTitle: "...", metaDescription: "..." } }
OptionTypeDefaultDescription
namestring—If provided, fields are nested under this property. If omitted, the group is purely visual.
fieldsFieldConfig[]—Required. Nested field definitions.
interfaceNamestring—TypeScript interface name.
dbNamestringAutoCustom database column/table name.
virtualbooleanfalseSkip database storage.
defaultValueRecord<string, unknown> | (data) => …—Default values for nested fields (named groups only).
admin.hideGutterbooleanfalseRemove the left-side visual gutter.
// Presentational group — no data nesting; fields stored at parent level
group({
  label: "Display Settings",
  fields: [
    checkbox({ name: "showTitle", defaultValue: true }),
    checkbox({ name: "showDate", defaultValue: true }),
  ],
  admin: { hideGutter: true },
});

Gotchas: named groups create real nested objects in your data — keep that in mind for queries and migrations. Presentational groups are pure UI; their fields share the parent's data namespace.


Component

component

Embed reusable field groups inside collections, singles, or other field groups. Three usage modes are supported.

// Single fixed type
fieldGroup({ name: "seo", component: "seo" })

// Dynamic zone
fieldGroup({
  name: "layout",
  components: ["hero", "cta", "content-block"],
  repeatable: true,
})

// Repeatable single type
fieldGroup({
  name: "features",
  component: "feature-card",
  repeatable: true,
  minRows: 1,
  maxRows: 12,
})
OptionTypeDefaultDescription
componentstring—Single mode: one specific component slug. Mutually exclusive with components.
componentsstring[]—Dynamic-zone mode: array of allowed component slugs. Mutually exclusive with component.
repeatablebooleanfalseAllow multiple instances (array mode).
minRowsnumber—Min instances when repeatable: true.
maxRowsnumber—Max instances when repeatable: true.
admin.initCollapsedbooleanfalseStart instances collapsed.
admin.isSortablebooleantrueDrag-and-drop reorder when repeatable: true.

Gotchas: component and components are mutually exclusive; you'll get a startup error if both are set. Component nesting is capped at depth 3.

Cross-links: see Field Groups for the defineFieldGroup() reference and the three modes explained side-by-side.


Advanced

json

Store arbitrary JSON data with an in-browser code editor. Supports JSON Schema validation.

json({ name: "metadata" })
OptionTypeDefaultDescription
jsonSchemaJSONSchemaDefinition—Inline JSON Schema for validation and editor hints.
defaultValueAny valid JSON value or function—Default value.
admin.editorOptions.heightnumber | string300Editor height.
admin.editorOptions.minHeightnumber100Minimum height.
admin.editorOptions.maxHeightnumber—Maximum height.
admin.editorOptions.lineNumbersbooleantrueShow line numbers.
admin.editorOptions.foldingbooleantrueEnable code folding.
admin.editorOptions.wordWrapbooleanfalseEnable word wrapping.
admin.editorOptions.minimapbooleanfalseShow code minimap.
admin.editorOptions.tabSizenumber2Tab size in spaces.
admin.editorOptions.formatOnBlurbooleantrueAuto-prettify on blur.
admin.editorOptions.validateOnChangebooleantrueReal-time syntax validation.

Database storage: PostgreSQL uses JSONB, MySQL uses JSON, SQLite uses TEXT. Schema validation happens at the application level so behavior is consistent across adapters.

json({
  name: "settings",
  jsonSchema: {
    type: "object",
    properties: {
      theme: { type: "string", enum: ["light", "dark", "system"] },
      maxItems: { type: "number", minimum: 1 },
    },
    required: ["theme"],
  },
});

Common options across fields

Every field type inherits the following from BaseFieldConfig. Where a specific field's reference table above doesn't repeat them, they still apply.

Identity

OptionTypeDefaultDescription
namestring—Required. Unique identifier. Lowercase, underscores or numbers — no hyphens, no spaces, no SQL reserved words.
typeFieldTypeSet by helperField type literal. The helper functions (text, number, etc.) set this for you.
labelstringAuto from nameDisplay label in the admin UI (e.g. user_name → User Name).
requiredbooleanfalseWhether the field must have a non-null, non-empty value.
uniquebooleanfalseEnforce database-level uniqueness.
indexbooleanfalseCreate a single-field database index.
localizedbooleanfalseReserved for a future release. When implemented, will store separate values per locale.
customRecord<string, unknown>—Arbitrary metadata for plugins or hooks. Not persisted.
virtualbooleanfalseOn a collection, Single or component: store nothing. The field gets no column, a value sent for it on create or update is dropped before the write, write validation skips it (so required and validate do not apply), and it is never localized. Compute its value in an afterRead hook. Group and repeater also accept options.virtual. Custom user fields are always stored, so user config validation refuses virtual on them. Needs the release after 0.0.2-alpha.66.

admin

Shared UI options:

OptionTypeDefaultDescription
position"sidebar"—Place the field in the sidebar instead of the main content area.
width"25%" | "33%" | "50%" | "66%" | "75%" | "100%""100%"Field width in the form layout grid.
descriptionstring—Help text below the field label.
placeholderstring—Placeholder text when empty.
readOnlybooleanfalseMake the field read-only in the UI.
disabledbooleanfalseDisable the input.
hiddenbooleanfalseHide the field from the UI entirely (still settable via API).
conditionFieldCondition—Conditionally show/hide based on another field's value.
classNamestring—Custom CSS class on the wrapper.
styleRecord<string, string>—Inline styles on the wrapper.
components.FieldReact.ComponentType<FieldComponentProps>—Custom form-field renderer.
components.CellReact.ComponentType<CellComponentProps>—Custom list/table cell renderer.
components.FilterReact.ComponentType<FilterComponentProps>—Custom list-view filter UI.

Conditional logic

text({
  name: "externalUrl",
  admin: {
    condition: {
      field: "linkType",
      equals: "external",
    },
  },
});

Available FieldCondition operators: equals, notEquals, contains, exists.

access

Field-level access control — granular permissions per CRUD operation.

text({
  name: "internalNotes",
  access: {
    create: ({ permissions }) => permissions.includes("users:update"),
    read: ({ permissions }) => permissions.includes("users:update"),
    update: ({ permissions }) => permissions.includes("users:update"),
  },
});

Each function receives { req, id?, data?, permissions, roles, principal } and returns boolean | Promise<boolean>. permissions is the caller's, as resource:action; decide by it rather than by a role's name. Field-level access is checked in addition to collection/single access.

hooks

Field-level lifecycle hooks. Each hook is an array of handlers run in order. On a collection write and on a direct read, each phase runs after the collection-level hook of the same name; see the execution order for how the two levels interleave. Two exceptions: a Single's field-level afterChange runs before its entity-level afterUpdate, so the entity hook sees what the field hooks produced and not the other way round; and a row reached through relationship expansion runs only the target's field-level afterRead, with no collection-level afterRead before it.

HookWhen it runs
beforeValidateBefore validation, after the collection's beforeValidate. Can transform the value.
beforeChangeAfter the collection's beforeChange. The last hook before the database write.
afterChangeAfter the database write.
afterReadAfter reading from the database. Can transform the returned value.

Every handler receives its own value and a data object at the field's own nesting level: for a field inside a group or a repeater, the containing row rather than the top-level document.

Before the write (beforeValidate, beforeChange), data is the record as transformed so far, not a copy of what the caller sent. On an ordinary create that means the request with field defaults applied before any hook runs, constant and function alike (see defaultValue), so a defaulted field's hook runs on a value nobody submitted, and a slug and title generated after the collection-level beforeValidate and before the first field-level hook, so a field hook can read them and a collection hook in that phase cannot. Field-level access.create and access.update rules are applied just before the field hooks, and a denied field is removed from the record, so none of this reaches a field the caller may not write: not its default, and not a generated slug or title. A bulk create (createMany) and a create inside a transaction start from the submitted body: no defaults are applied, an omitted defaulted field stays absent, and its hooks do not run. On an update it is the patch, not the stored row, so an unchanged sibling reads as undefined. Do not read a key's presence as proof the caller supplied it. A beforeChange handler can read and change siblings at that level. No collection-level phase runs after it, but a sibling's own hook registered later in the same phase still can. Return the value you want stored; touch siblings only when that is the intent. These hooks run only when their field is present in the data: an update that omits it, or an earlier hook that deletes the key, skips them.

After the write (afterChange) and on a read (afterRead), data is the row, persisted or loaded, and a hook runs for every field present on that row, whether or not the field changed. The rule that decides the rest: a field whose key is absent from the row when the hooks run has no hook run, and these are the things that remove a key first.

  • Password fields are stripped from every read before afterRead, on collections and Singles alike, so a password field's afterRead never runs.
  • Denied fields. On a read, collection or Single, field-level access.read rules are applied before the hooks, so a field the caller may not read is gone by then, and again after them, so a value a hook put back is removed.
  • Localized fields live in the companion table and are merged into the afterChange row only when the write carried them, so a localized field omitted from a create, or unchanged on an update, has no afterChange run.
  • Field groups are stored in their own table and removed from the row before the main-table write, and afterChange runs on the returned row without them, on collections and Singles and inside a caller's transaction. Their beforeValidate and beforeChange do run. The one write where a field group's afterChange runs is a working-draft update through the ordinary update path: on a collection with versions: { drafts: true }, an update to a published entry that names no status is held as a draft and afterChange runs over that assembled draft. A collection is not eligible to hold drafts when it or one of its field groups has a password field, or when a field group's schema cannot be resolved; a status-less update there writes the live row.

A value returned from afterChange reshapes the response and does not change what was stored.

Core still normalises the record between the last pre-write hook and the write: passwords are hashed and relationship and upload values reduced to ids on collections and Singles alike, and a collection's slug is re-sanitised, which a Single's is not. See the execution order for what runs where.

text({
  name: "slug",
  hooks: {
    beforeValidate: [
      async ({ value, data }) => {
        if (!value && data?.title) {
          return data.title.toLowerCase().replace(/\s+/g, "-");
        }
        return value;
      },
    ],
  },
});

validate

Custom validation function. Receives the typed field value plus { data, req } and returns either true (valid) or an error-message string. Runs after the field's built-in validation.

text({
  name: "username",
  validate: (value) => {
    if (value && !/^[a-z0-9_]+$/.test(String(value))) {
      return "Lowercase letters, numbers, and underscores only";
    }
    return true;
  },
});

defaultValue

Static value or (data) => value function. The function form receives the document data being created so you can compute defaults from sibling fields. Each field type's signature accepts the value type appropriate for that field.

Both forms are applied on the server, so a default holds for every write path that applies defaults, not only for the admin form. On an ordinary collection create through the REST or Direct API, an omitted field takes its default before any hook runs, at every depth: a group's children and each supplied repeater row's children. A new field-group instance also fills its own children's defaults, but LATER: a field group's rows are written in their own pass after the entry's hooks have run, so the parent's hooks see such a child as absent and cannot read or derive from the value that will be stored. A function default is read from the live config, since a collection's stored definition cannot hold a function, and it receives the data built so far at its own level: the document for a top-level field, the group or the row for a nested one. Rows are never invented; minRows is a validation rule, not an instruction to create rows.

A field-group child takes its default the same way, constant or function. Its fields are read from a stored definition, which cannot carry a function, so the function form is read from the live config exactly as a collection's is. It resolves against the instance being built, so a child may compute from a sibling that was defaulted before it.

A Single's first read creates its row with the same defaults, top-level and nested, with two exceptions: a default on a fieldGroup field itself is evaluated and then dropped because that field has no column on the row, and a password field's default, at any depth, is refused because that path never hashes.

Bulk creates (createMany) and creates inside a transaction apply no defaults to the entry's own fields; a new field-group instance on those writes still takes its own defaults.


Helper utilities

option(label, value?)

Creates a { label, value } option for select and radio fields. Auto-generates the value from the label by lowercasing and replacing spaces with underscores.

import { option } from "nextly";

option("Draft");          // { label: "Draft", value: "draft" }
option("In Progress");    // { label: "In Progress", value: "in_progress" }
option("Active", "active");

Next steps