nextjs-cms

Has-Items Section

A collection of items with a searchable, paginated list page, plus create, edit and delete. The section type you'll use most.

import { hasItemsSection } from 'nextjs-cms/core/sections'

When to use it

Anything you have many of: blog posts, products, events, jobs, team members, testimonials, FAQs, locations. Editors get:

  • A list page with thumbnails (from coverPhotoField), search (from search.searchFields), and pagination (12 items per page, newest first).
  • New and Edit forms generated from fields.
  • Delete, which also removes the item's uploaded files, gallery photos, translations and destination-table rows.

Minimal example

sections/testimonials.section.ts
import { hasItemsSection } from 'nextjs-cms/core/sections'
import { textAreaField, textField } from 'nextjs-cms/core/fields'

const author = textField({ name: 'author', required: true })

export default hasItemsSection({
    name: 'testimonials',
    order: 3,
    title: { section: 'Testimonials', singular: 'Testimonial', plural: 'Testimonials' },
    headingField: author,
    db: { table: 'testimonials' },
    fields: [author, textAreaField({ name: 'quote', required: true })],
})

Options

OptionTypeDefaultDescription
namestringrequiredUnique section key.
ordernumberrequiredSidebar position.
title{ section, singular, plural }requiredEach is a string or { [locale]: string }. section labels the sidebar and list page, singular is used in "New Post" / "Edit Post", plural in counts and empty states.
headingFieldtext or number fieldrequiredThe field used as each item's display name: list cards, edit page header, logs. Must also be in fields.
dbobjectrequiredtable, plus optional identifier, orderByField, primaryKey, unique, index, fulltext, foreignKeys. See Database Options.
fieldsFieldConfig[] | FieldGroupConfig[]requiredThe form.
iconLucide icon namenoneSidebar icon.
search.searchFieldsFieldConfig[]noneFields matched by the list page's search box (LIKE '%query%', any field matching). Without it, the list has no search box.
coverPhotoFieldphoto fieldnoneThumbnail shown on each list card. Must also be in fields.
browse.fieldsFieldConfig[]noneExtra columns returned by the browse API. Only useful if you build a custom list page; the built-in one ignores it.
hooksHooksnoneSee Hooks.
galleryobjectnoneMulti-photo gallery per item. See Photo Gallery.
requirePublishPermissionbooleanfalseReserved. Accepted, not implemented yet.
generateQRbooleanfalseReserved. Accepted, not implemented yet.
readonlybooleanfalseReserved. Accepted, not enforced yet.

Examples

A complete blog

Slugs, cover photos, rich text, a category, tags, SEO fields, search, and uniqueness, in one file.

sections/posts.section.ts
import { hasItemsSection } from 'nextjs-cms/core/sections'
import {
    checkboxField,
    dateField,
    fieldGroup,
    photoField,
    richTextField,
    selectField,
    slugField,
    tagsField,
    textAreaField,
    textField,
} from 'nextjs-cms/core/fields'
import categoriesSection from './categories.section'

const title = textField({ name: 'title', required: true, maxLength: 150, localized: true })
const slug = slugField({ name: 'slug', for: title, required: true, editProtection: true })
const cover = photoField({
    name: 'cover',
    required: true,
    fileType: ['jpeg', 'png', 'webp'],
    maxFileSize: { size: 5, unit: 'mb' },
    size: { width: 1600, height: 900, fit: 'cover' },
    thumbnail: { width: 480, height: 270, fit: 'cover', quality: 80 },
})
const publishedAt = dateField({ name: 'published_at', format: 'datetime', required: true })

export default hasItemsSection({
    name: 'posts',
    order: 1,
    icon: 'newspaper',
    title: {
        section: { en: 'Blog', ar: 'المدونة' },
        singular: { en: 'Post', ar: 'مقال' },
        plural: { en: 'Posts', ar: 'مقالات' },
    },
    headingField: title,
    coverPhotoField: cover,
    search: { searchFields: [title, slug] },
    db: {
        table: 'posts',
        orderByField: publishedAt, // list page: newest publish date first
        unique: [{ columns: [slug] }],
        index: [{ columns: [publishedAt] }],
    },
    fields: [
        fieldGroup({
            title: 'Content',
            fields: [
                title,
                slug,
                textAreaField({ name: 'excerpt', maxLength: 300, localized: true }),
                richTextField({ name: 'body', required: true, allowMedia: true, localized: true }),
            ],
        }),
        fieldGroup({
            title: 'Organization',
            fields: [
                selectField({ name: 'category_id', label: 'Category', section: categoriesSection, required: true }),
                tagsField({ name: 'tags', placeholder: 'Type a tag and press enter' }),
                checkboxField({ name: 'featured', label: 'Feature on homepage' }),
            ],
        }),
        fieldGroup({ title: 'Media & publishing', fields: [cover, publishedAt] }),
        fieldGroup({
            title: 'SEO',
            fields: [
                textField({ name: 'meta_title', maxLength: 70, localized: true }),
                textAreaField({ name: 'meta_description', maxLength: 160, localized: true }),
            ],
        }),
    ],
})

Products with a SKU as the identifier

By default, items get an auto-increment id. Set db.identifier to use your own key, such as a SKU. The identifier must be a text or number field, and becomes the primary key.

sections/products.section.ts
import { numberField, photoField, selectField, textField } from 'nextjs-cms/core/fields'

const sku = textField({ name: 'sku', label: 'SKU', required: true, maxLength: 32 })
const name = textField({ name: 'name', required: true })

export default hasItemsSection({
    name: 'products',
    order: 2,
    title: { section: 'Products', singular: 'Product', plural: 'Products' },
    headingField: name,
    search: { searchFields: [sku, name] },
    db: { table: 'products', identifier: sku },
    fields: [
        sku,
        name,
        numberField({ name: 'price', format: 'float', minValue: 0, required: true }),
        numberField({ name: 'stock', minValue: 0, maxValue: 100_000, defaultValue: 0 }),
        selectField({
            name: 'status',
            options: [
                { value: 'active', label: 'Active' },
                { value: 'draft', label: 'Draft' },
                { value: 'discontinued', label: 'Discontinued' },
            ],
            defaultValue: 'draft',
            required: true,
        }),
        photoField({ name: 'image', thumbnail: { width: 300, height: 300, fit: 'contain' } }),
    ],
})

On the edit form, the identifier is shown read-only: an item's key can't change after creation.

Manually ordered lists

For content editors arrange by hand (team members, slider slides, FAQ), add a number field and make it the orderByField:

sections/team.section.ts
import { numberField, photoField, textField } from 'nextjs-cms/core/fields'

const position = numberField({ name: 'position', label: 'Sort position', minValue: 0, maxValue: 10_000, defaultValue: 0 })
const name = textField({ name: 'name', required: true })

export default hasItemsSection({
    name: 'team',
    order: 6,
    icon: 'users',
    title: { section: 'Team', singular: 'Member', plural: 'Members' },
    headingField: name,
    db: { table: 'team_members', orderByField: position },
    fields: [
        name,
        textField({ name: 'role' }),
        photoField({ name: 'portrait', size: { width: 600, height: 600, fit: 'cover' } }),
        position,
    ],
})
Good to know:

The admin list sorts orderByField descending. Your website decides its own order when it queries, so ORDER BY position ASC there is perfectly fine.

Job listings with dates and conditional fields

sections/jobs.section.ts
import {
    dateRangeField,
    numberField,
    richTextField,
    selectField,
    textField,
} from 'nextjs-cms/core/fields'

const title = textField({ name: 'title', required: true })
const workplace = selectField({
    name: 'workplace',
    required: true,
    options: [
        { value: 'onsite', label: 'On-site' },
        { value: 'hybrid', label: 'Hybrid' },
        { value: 'remote', label: 'Remote' },
    ],
})
const salaryVisible = selectField({
    name: 'salary_visibility',
    options: [
        { value: 'hidden', label: 'Hidden' },
        { value: 'range', label: 'Show range' },
    ],
    defaultValue: 'hidden',
})

export default hasItemsSection({
    name: 'jobs',
    order: 4,
    icon: 'briefcase',
    title: { section: 'Careers', singular: 'Job', plural: 'Jobs' },
    headingField: title,
    search: { searchFields: [title] },
    db: { table: 'jobs' },
    fields: [
        title,
        workplace,
        textField({
            name: 'office_city',
            required: true,
            // Only asked for (and required) when the job isn't fully remote.
            conditionalRules: [{ field: workplace, condition: 'notEquals', value: 'remote' }],
        }),
        dateRangeField({ startName: 'opens_on', endName: 'closes_on', label: 'Application window', required: true }),
        salaryVisible,
        numberField({
            name: 'salary_min',
            minValue: 0,
            maxValue: 10_000_000,
            conditionalRules: [{ field: salaryVisible, condition: 'equals', value: 'range' }],
        }),
        numberField({
            name: 'salary_max',
            minValue: 0,
            maxValue: 10_000_000,
            conditionalRules: [{ field: salaryVisible, condition: 'equals', value: 'range' }],
        }),
        richTextField({ name: 'description', required: true }),
    ],
})

Many-to-many: products in several collections

Store multiple selections in a join table with destinationDb. update-sections creates the table for you.

sections/products.section.ts
import { selectMultipleField } from 'nextjs-cms/core/fields'
import collectionsSection from './collections.section'

const collections = selectMultipleField({
    name: 'collections',
    section: collectionsSection,
    destinationDb: {
        table: 'product_collections', // join table
        itemIdentifier: 'product_id', // → this product's identifier
        selectIdentifier: 'collection_id', // → the selected collection's identifier
    },
})

Each selected collection becomes one row in product_collections, and the products table gets no collections column. Read the Relationships guide for every option.

sections/properties.section.ts
const title = textField({ name: 'title', required: true })

export default hasItemsSection({
    name: 'properties',
    order: 2,
    title: { section: 'Properties', singular: 'Property', plural: 'Properties' },
    headingField: title,
    db: { table: 'properties' },
    gallery: {
        db: { tableName: 'property_photos' },
        thumbnail: { width: 400, height: 300, fit: 'cover', quality: 80 },
    },
    fields: [title /* ... */],
})

Editors can upload, reorder, and delete any number of photos per property. See Photo Gallery.

Search in depth

search.searchFields builds one LIKE '%query%' condition per field, joined with OR:

search: { searchFields: [title, sku, authorName] }
  • Use text-like fields (textField, slugField, textAreaField). Searching richTextField works but matches HTML markup too.
  • For localized fields, the search also looks in the translation table for the locale being browsed.
  • For large tables, add an index (or a fulltext index) on the searched columns. See Database Options.

Permissions

Each admin is granted create (C), update (U) and delete (D) rights per section. Without C, the "New" button is hidden and create requests are rejected. See Admins & Permissions.