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
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
| Option | Type | Default | Description |
|---|---|---|---|
name | string | required | Unique section key. |
order | number | required | Sidebar position. |
title | { section, singular, plural } | required | Each a string or { [locale]: string }. |
headingField | text field | required | The category's display name, used in the tree and as the label when other sections select from this one. Must be a textField. |
db | object | required | Same options as has-items sections. See Database Options. |
fields | FieldConfig[] | FieldGroupConfig[] | required | The form. |
depth | number (> 0) | 1 | Maximum number of levels. 1 is flat, 2 allows one level of sub-categories, and so on. |
allowRecursiveDelete | boolean | false | Offer "also delete children" in the delete dialog. Also requires sections.category.allowRecursiveDelete in cms.config.ts. See below. |
icon | Lucide icon name | none | Sidebar icon. |
hooks | Hooks | none | See Hooks. |
gallery | object | none | See 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:
| Column | Type | Meaning |
|---|---|---|
parent_id | Same type as the identifier (INT by default) | The parent category's identifier, NULL for top-level categories. |
level | INT | 1 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.
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 │ 2Choose 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)
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:
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 │ 3This 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:
export default defineConfig({
sections: { category: { allowRecursiveDelete: true } }, // global switch
})export default categorySection({
// ...
allowRecursiveDelete: true, // per-section switch
})When enabled, the delete dialog shows an extra warning and a "delete children too" switch.
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
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
}