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 -- think blog posts, products, users, or media files. Each collection maps to a database table, gets automatic REST API endpoints, and appears in the admin panel with list and edit views.

Defining a Collection

Create a file for each collection and use defineCollection():

src/collections/posts.ts
import {
  defineCollection,
  text,
  richText,
  select,
  relationship,
  date,
  option,
} from '@nextlyhq/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' }),
    select({
      name: 'status',
      options: [option('Draft'), option('Published'), option('Archived')],
      defaultValue: 'draft',
    }),
    relationship({ name: 'author', relationTo: 'users' }),
    date({ name: 'publishedAt' }),
  ],
  timestamps: true,
  admin: {
    group: 'Content',
    icon: 'FileText',
    useAsTitle: 'title',
    defaultColumns: ['title', 'status', 'author', 'createdAt'],
    pagination: {
      defaultLimit: 25,
    },
    description: 'Blog posts and articles',
  },
  access: {
    read: true,
    create: ({ roles }) => roles.includes('admin') || roles.includes('editor'),
    update: ({ roles }) => roles.includes('admin') || roles.includes('editor'),
    delete: ({ roles }) => roles.includes('admin'),
  },
  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 '@nextlyhq/nextly';
import Posts from './src/collections/posts';
import Media from './src/collections/media';

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

Creating a Collection in the Schema Builder

The Nextly admin panel includes a visual Schema Builder for creating collections without writing code.

  1. Open your 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. You can export a Schema Builder-created collection to TypeScript at any time.

Collection Options

Only slug and fields are required. Everything else has sensible defaults.

slug

Typestring
RequiredYes

Unique identifier used as the database table name, API endpoint (/api/[slug]), and internal reference. Must be lowercase and URL-friendly.

fields

TypeFieldConfig[]
RequiredYes

Array of field definitions that define the document structure. See Fields for all available types.

labels

Type{ singular?: string; plural?: string }
DefaultAuto-generated from slug

Display names in the admin UI. If omitted, labels are derived from the slug (e.g., blog-posts becomes singular: "Blog Posts", plural: "Blog Postss").

timestamps

Typeboolean
Defaulttrue

Automatically adds createdAt and updatedAt fields. createdAt is set once on creation; updatedAt is refreshed on every save.

dbName

Typestring
DefaultSame as slug

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

description

Typestring
Defaultundefined

Description displayed in the admin UI and used for documentation.

sanitize

Typeboolean
Defaulttrue

When enabled, HTML tags are automatically stripped from plain-text fields (text, textarea, email) before storage. Only disable if the collection intentionally stores HTML in text fields.

TypeSearchConfig
DefaultAuto-detects searchable fields

Configure which fields are searchable in list queries.

PropertyTypeDefaultDescription
searchableFieldsstring[]All text/textarea/email fieldsFields to include in search
minSearchLengthnumber2Minimum query length to trigger search

indexes

TypeIndexConfig[]
Defaultundefined

Compound database indexes for query performance. For single-field indexes, use index: true on the field itself. The id, createdAt, and updatedAt fields are indexed automatically.

indexes: [
  { fields: ['authorId', 'createdAt'] },
  { fields: ['slug', 'locale'], unique: true, name: 'slug_locale_unique' },
]

endpoints

TypeCustomEndpoint[]
Defaultundefined

Custom REST API endpoints mounted at /api/[slug]/[path].

endpoints: [
  {
    path: '/publish',
    method: 'post',
    handler: async (req) => {
      const { id } = await req.json();
      // Publish logic
      return Response.json({ success: true });
    },
  },
]

custom

TypeRecord<string, unknown>
Defaultundefined

Arbitrary metadata accessible by 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 with the same group appear together)
iconstringNoneLucide icon name (e.g., 'FileText', 'Users')
hiddenbooleanfalseHide from admin navigation (still accessible via URL and API)
ordernumber100Sort order within sidebar group (lower = higher)
useAsTitlestringDocument IDField name to display as the entry title in lists and breadcrumbs
defaultColumnsstring[]AutoColumns shown in the list view
descriptionstringNoneHelp text displayed below the collection title
pagination.defaultLimitnumber10Default entries per page
pagination.limitsnumber[][10, 25, 50, 100]Available page size options
isPluginbooleanfalseShow in "Plugins" sidebar section instead of "Collections"
sidebarGroupstringNoneCustom sidebar group slug

Preview URLs

Add a "Preview" button to the entry form that opens a preview URL:

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

The url function receives the current entry data and returns a URL string or null if preview is not available.

Custom Admin Components

Override default admin views or inject custom components:

admin: {
  components: {
    views: {
      Edit: { Component: '@nextlyhq/plugin-form-builder/admin#FormBuilderView' },
    },
    BeforeListTable: '@nextlyhq/plugin-form-builder/admin#CreateFormButton',
  },
}

Available injection points: BeforeListTable, AfterListTable, BeforeEdit, AfterEdit.

Access Control

Control who can perform CRUD operations on the collection. Each operation accepts a boolean or a function that receives an AccessControlContext.

access: {
  create: ({ roles }) => roles.includes('admin') || roles.includes('editor'),
  read: true,
  update: ({ roles }) => roles.includes('admin') || roles.includes('editor'),
  delete: ({ roles }) => roles.includes('admin'),
}

The AccessControlContext contains:

PropertyTypeDescription
user{ id, email } | nullThe authenticated user
rolesstring[]User's role slugs (includes inherited roles)
permissionsstring[]Effective permissions in 'resource:action' format
operation'create' | 'read' | 'update' | 'delete'The operation being performed
collectionstringThe collection slug

Rules:

  • Code-defined access always takes precedence over database role/permission checks
  • Omitting an operation falls back to database role/permission checks
  • Super-admin always bypasses all access checks

Hooks

Hooks let you run custom logic at specific points in a document's lifecycle. Each hook property accepts an array of handler functions.

Hook Execution Order

  1. beforeOperation -- Before any operation begins (can modify operation args)
  2. beforeValidate -- Before validation (create/update)
  3. beforeChange -- Before database write (create/update)
  4. Database operation executes
  5. afterChange -- After database write (create/update)
  6. afterRead -- After reading from database
  7. beforeDelete / afterDelete -- Around deletion

Available Hooks

HookTriggerCan Modify Data
beforeOperationBefore any operationYes (args)
beforeValidateBefore validationYes
beforeChangeBefore database writeYes
afterChangeAfter database writeNo (side effects)
beforeReadBefore database readYes (query params)
afterReadAfter database readYes (transform output)
beforeDeleteBefore deletionNo (can throw to prevent)
afterDeleteAfter deletionNo (side effects)

Hook Handler

Every hook handler receives a HookContext object:

hooks: {
  beforeChange: [
    async ({ data, operation, collection, user, context, req }) => {
      if (operation === 'create') {
        return { ...data, slug: slugify(data.title) };
      }
      return data;
    },
  ],
  afterChange: [
    async ({ data, req }) => {
      // Use req.nextly for Direct API access within 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
  • after* hooks: return value is ignored (use for side effects)
  • Throwing an error aborts the operation and rolls back the transaction

Next Steps

  • Fields -- All field types and validation options
  • Singles -- Single-document content like site settings
  • Components -- Reusable field groups for collections and singles
  • Schema Builder -- Create collections visually with drag-and-drop
  • Direct API -- Query collections from server-side code