nextjs-cms

Category Section

Hierarchical content. Categories can have sub-categories up to the depth you choose, and other sections can use them as select options.

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

When to use it

  • Product categories (Electronics › Phones › Android)
  • Blog topics, documentation chapters, nested menus
  • Locations (Country › City › District)
  • Any taxonomy that other sections should pick from

A category section behaves like a has-items section whose items form a tree. The admin shows one level at a time: click a category to see and add its children.

Minimal example: flat categories

sections/topics.section.ts
import { categorySection } from 'nextjs-cms/core/sections'
import { textField } from 'nextjs-cms/core/fields'

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

export default categorySection({
    name: 'topics',
    order: 2,
    title: { section: 'Topics', singular: 'Topic', plural: 'Topics' },
    headingField: name,
    db: { table: 'topics' },
    fields: [name],
})

depth defaults to 1: a flat list with no sub-categories.

Options

OptionTypeDefaultDescription
namestringrequiredUnique section key.
ordernumberrequiredSidebar position.
title{ section, singular, plural }requiredEach a string or { [locale]: string }.
headingFieldtext fieldrequiredThe category's display name, used in the tree and as the label when other sections select from this one. Must be a textField.
dbobjectrequiredSame options as has-items sections. See Database Options.
fieldsFieldConfig[] | FieldGroupConfig[]requiredThe form.
depthnumber (> 0)1Maximum number of levels. 1 is flat, 2 allows one level of sub-categories, and so on.
allowRecursiveDeletebooleanfalseOffer "also delete children" in the delete dialog. Also requires sections.category.allowRecursiveDelete in cms.config.ts. See below.
iconLucide icon namenoneSidebar icon.
hooksHooksnoneSee Hooks.
galleryobjectnoneSee Photo Gallery.

Category sections have no search, coverPhotoField, or browse options.

Nested categories

Set depth above 1 and the section gains two columns, added automatically:

ColumnTypeMeaning
parent_idSame type as the identifier (INT by default)The parent category's identifier, NULL for top-level categories.
levelINT1 for top-level categories, 2 for their children, and so on.

You don't add these to fields, and editors never see them: the admin sets them based on where the category is created.

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

const name = textField({ name: 'name', required: true, localized: true })
const slug = slugField({ name: 'slug', for: name, required: true })
const position = numberField({ name: 'position', minValue: 0, maxValue: 10_000, defaultValue: 0 })

export default categorySection({
    name: 'categories',
    order: 3,
    icon: 'folder-tree',
    title: {
        section: { en: 'Categories', ar: 'التصنيفات' },
        singular: { en: 'Category', ar: 'تصنيف' },
        plural: { en: 'Categories', ar: 'التصنيفات' },
    },
    headingField: name,
    depth: 3, // Category › Sub-category › Sub-sub-category
    db: {
        table: 'categories',
        orderByField: position,
        unique: [{ columns: [slug] }],
    },
    fields: [
        name,
        slug,
        photoField({ name: 'icon', fileType: ['png', 'webp'], size: { width: 256, height: 256, strict: true } }),
        position,
    ],
})

Resulting rows for a small tree:

id │ name        │ parent_id │ level
───┼─────────────┼───────────┼──────
 1 │ Electronics │ NULL      │ 1
 2 │ Phones      │ 1         │ 2
 3 │ Android     │ 2         │ 3
 4 │ Laptops     │ 1         │ 2
Warning:

Choose depth up front. Raising it from 1 is safe: update-sections adds parent_id and level. Lowering it back to 1 removes those columns from the config, so update-sections offers to drop them, and the hierarchy is lost if you accept.

Using categories in other sections

This is the main reason category sections exist. Point a selectField or selectMultipleField at the section: options come from its table, labels from its headingField, values from its identifier.

Single category, flat (depth: 1)

sections/posts.section.ts
import { selectField } from 'nextjs-cms/core/fields'
import topicsSection from './topics.section'

const topic = selectField({
    name: 'topic_id',
    label: 'Topic',
    section: topicsSection,
    required: true,
})

topic_id is created as INT (matching the topics identifier) and holds the chosen topic's id.

Multi-level pick (depth > 1)

For a nested section, the form renders one dropdown per level: pick "Electronics", then a second dropdown offers its children, and so on. Every chosen level is submitted. Store them with destinationDb, which writes one row per level:

sections/products.section.ts
import { selectField } from 'nextjs-cms/core/fields'
import categoriesSection from './categories.section'

const category = selectField({
    name: 'category',
    label: 'Category',
    section: categoriesSection, // depth: 3
    required: true,
    destinationDb: {
        table: 'product_categories',
        itemIdentifier: 'product_id',
        selectIdentifier: 'category_id',
    },
})

Picking Electronics › Phones › Android for product 42 stores:

product_categories
product_id │ category_id
───────────┼────────────
42         │ 1
42         │ 2
42         │ 3

This makes "all products in Electronics, including sub-categories" a single indexed lookup on your website.

Several categories per item

import { selectMultipleField } from 'nextjs-cms/core/fields'

const topics = selectMultipleField({
    name: 'topics',
    section: topicsSection,
    destinationDb: { table: 'post_topics', itemIdentifier: 'post_id', selectIdentifier: 'topic_id' },
})

More patterns in the Relationships guide.

Text identifiers

Categories can use a text identifier (for example a stable code). parent_id then becomes a VARCHAR(255) text column instead of INT:

const code = textField({ name: 'code', required: true, maxLength: 20 })
const name = textField({ name: 'name', required: true })

export default categorySection({
    name: 'regions',
    order: 7,
    title: { section: 'Regions', singular: 'Region', plural: 'Regions' },
    headingField: name,
    depth: 2,
    db: { table: 'regions', identifier: code },
    fields: [code, name],
})

Deleting categories with children

Deleting a category that has children is a two-key decision:

cms.config.ts
export default defineConfig({
    sections: { category: { allowRecursiveDelete: true } }, // global switch
})
sections/categories.section.ts
export default categorySection({
    // ...
    allowRecursiveDelete: true, // per-section switch
})

When enabled, the delete dialog shows an extra warning and a "delete children too" switch.

Warning:

In the current version, the recursive-delete switch is displayed but children are not deleted automatically. Delete child categories first (deepest level upwards), or clean them up in an afterDelete hook.

Querying a tree on your website

website/lib/categories.ts
import { asc, eq, isNull } from 'drizzle-orm'
import { db } from './db'
import { CategoriesTable } from './cms-schema'

export const getTopLevel = () =>
    db.select().from(CategoriesTable).where(isNull(CategoriesTable.parentId)).orderBy(asc(CategoriesTable.position))

export const getChildren = (parentId: number) =>
    db.select().from(CategoriesTable).where(eq(CategoriesTable.parentId, parentId)).orderBy(asc(CategoriesTable.position))

/** Load the whole tree in one query and nest it in memory. */
export async function getTree() {
    const rows = await db.select().from(CategoriesTable).orderBy(asc(CategoriesTable.level), asc(CategoriesTable.position))
    type Node = (typeof rows)[number] & { children: Node[] }
    const byId = new Map<number, Node>(rows.map((r) => [r.id, { ...r, children: [] }]))
    const roots: Node[] = []
    for (const node of byId.values()) {
        if (node.parentId == null) roots.push(node)
        else byId.get(node.parentId)?.children.push(node)
    }
    return roots
}