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():
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:
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.
- Open your 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. 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
| Type | string |
| Required | Yes |
Unique identifier used as the database table name, API endpoint (/api/[slug]), and internal reference. Must be lowercase and URL-friendly.
fields
| Type | FieldConfig[] |
| Required | Yes |
Array of field definitions that define the document structure. See Fields for all available types.
labels
| Type | { singular?: string; plural?: string } |
| Default | Auto-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
| Type | boolean |
| Default | true |
Automatically adds createdAt and updatedAt fields. createdAt is set once on creation; updatedAt is refreshed on every save.
dbName
| Type | string |
| Default | Same as slug |
Custom database table name. Useful for legacy databases or when the slug doesn't match your naming convention.
description
| Type | string |
| Default | undefined |
Description displayed in the admin UI and used for documentation.
sanitize
| Type | boolean |
| Default | true |
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.
search
| Type | SearchConfig |
| Default | Auto-detects searchable fields |
Configure which fields are searchable in list queries.
| Property | Type | Default | Description |
|---|---|---|---|
searchableFields | string[] | All text/textarea/email fields | Fields to include in search |
minSearchLength | number | 2 | Minimum query length to trigger search |
indexes
| Type | IndexConfig[] |
| Default | undefined |
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
| Type | CustomEndpoint[] |
| Default | undefined |
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
| Type | Record<string, unknown> |
| Default | undefined |
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.
| Property | Type | Default | Description |
|---|---|---|---|
group | string | None | Sidebar group name (collections with the same group appear together) |
icon | string | None | Lucide icon name (e.g., 'FileText', 'Users') |
hidden | boolean | false | Hide from admin navigation (still accessible via URL and API) |
order | number | 100 | Sort order within sidebar group (lower = higher) |
useAsTitle | string | Document ID | Field name to display as the entry title in lists and breadcrumbs |
defaultColumns | string[] | Auto | Columns shown in the list view |
description | string | None | Help text displayed below the collection title |
pagination.defaultLimit | number | 10 | Default entries per page |
pagination.limits | number[] | [10, 25, 50, 100] | Available page size options |
isPlugin | boolean | false | Show in "Plugins" sidebar section instead of "Collections" |
sidebarGroup | string | None | Custom 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:
| Property | Type | Description |
|---|---|---|
user | { id, email } | null | The authenticated user |
roles | string[] | User's role slugs (includes inherited roles) |
permissions | string[] | Effective permissions in 'resource:action' format |
operation | 'create' | 'read' | 'update' | 'delete' | The operation being performed |
collection | string | The 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
beforeOperation-- Before any operation begins (can modify operation args)beforeValidate-- Before validation (create/update)beforeChange-- Before database write (create/update)- Database operation executes
afterChange-- After database write (create/update)afterRead-- After reading from databasebeforeDelete/afterDelete-- Around deletion
Available Hooks
| Hook | Trigger | Can Modify Data |
|---|---|---|
beforeOperation | Before any operation | Yes (args) |
beforeValidate | Before validation | Yes |
beforeChange | Before database write | Yes |
afterChange | After database write | No (side effects) |
beforeRead | Before database read | Yes (query params) |
afterRead | After database read | Yes (transform output) |
beforeDelete | Before deletion | No (can throw to prevent) |
afterDelete | After deletion | No (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 hookafter*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