Your First Section
Build a blog from scratch. Define fields, sync the database, add a category relationship, and read the posts from your website.
In this tutorial you build a small blog: a list of posts with a title, slug, cover photo, body and publish date, grouped by category. It takes about ten minutes and touches every core concept.
1. Create the section file
Create sections/posts.section.ts:
import { hasItemsSection } from 'nextjs-cms/core/sections'
import { textField } from 'nextjs-cms/core/fields'
const title = textField({ name: 'title', required: true })
export default hasItemsSection({
name: 'posts',
order: 1,
icon: 'newspaper',
title: { section: 'Blog', singular: 'Post', plural: 'Posts' },
headingField: title,
db: { table: 'posts' },
fields: [title],
})What each option does:
name: the section's unique key. It appears in admin URLs and in permissions.order: position in the admin sidebar.icon: any Lucide icon name, in kebab-case.title: labels for the sidebar (section) and for buttons like "New Post" (singular,plural).headingField: the field shown as each item's title in the list page and in logs.db.table: the MySQL table that stores the posts.fields: the form. Each field is also a column.
2. Create the table
pnpm nextjs-cms --dev update-sectionsThe CLI sees a section whose table doesn't exist yet and creates it. The result is roughly:
CREATE TABLE `posts` (
`id` INT(11) NOT NULL AUTO_INCREMENT, -- added automatically (the identifier)
`title` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` VARCHAR(50) NOT NULL,
`updated_by` VARCHAR(50),
PRIMARY KEY (`id`)
);Restart nothing. Open the admin: Blog is in the sidebar, and you can already create posts.
You never declared id. When db.identifier is omitted, every section gets an auto-increment id number field. The name id is therefore reserved unless you set your own identifier.
3. Add real fields
Now flesh the post out. Replace the file:
import { hasItemsSection } from 'nextjs-cms/core/sections'
import {
dateField,
fieldGroup,
photoField,
richTextField,
slugField,
textField,
} from 'nextjs-cms/core/fields'
const title = textField({ name: 'title', required: true, maxLength: 120 })
const slug = slugField({ name: 'slug', for: title, required: true })
const cover = photoField({
name: 'cover',
label: 'Cover photo',
required: true,
fileType: ['jpeg', 'png', 'webp'],
size: { width: 1600, height: 900, fit: 'cover' },
thumbnail: { width: 400, height: 225, fit: 'cover' },
})
const publishedAt = dateField({
name: 'published_at',
label: 'Publish date',
format: 'datetime',
required: true,
})
export default hasItemsSection({
name: 'posts',
order: 1,
icon: 'newspaper',
title: { section: 'Blog', singular: 'Post', plural: 'Posts' },
headingField: title,
coverPhotoField: cover,
search: { searchFields: [title] },
db: { table: 'posts', unique: [{ columns: [slug] }] },
fields: [
fieldGroup({ title: 'Content', fields: [title, slug, richTextField({ name: 'body', required: true })] }),
fieldGroup({ title: 'Media & publishing', fields: [cover, publishedAt] }),
],
})A few things are happening:
slugField({ for: title })fills itself from the title as you type ("Hello World!"becomeshello-world).db.uniquemakes the slug unique. The admin shows a friendly error when a duplicate is submitted, before MySQL does.coverPhotoFieldshows the cover as a thumbnail on the list page.search.searchFieldsenables the search box on the list page.fieldGroupsplits the form into two cards.
Run update-sections again. This time the CLI detects the new columns (slug, body, cover, published_at) and asks, for each one, whether it's a new field or a rename of an existing column. Choose new.
4. Add categories
Create a category section and link posts to it:
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: 'categories',
order: 2,
icon: 'folder-tree',
title: { section: 'Categories', singular: 'Category', plural: 'Categories' },
headingField: name,
db: { table: 'categories' },
fields: [name],
})// ...
import categoriesSection from './categories.section'
import { selectField } from 'nextjs-cms/core/fields'
const category = selectField({
name: 'category_id',
label: 'Category',
section: categoriesSection,
})
// add `category` to the "Content" field groupThe select's options now come from the categories table, labelled by its headingField, and the column is created as INT because the category identifier is a number. Run update-sections once more.
5. Read posts on your website
Your public site reads the same MySQL database. With the generated Drizzle schema (on by default) you get typed queries:
import { desc, eq } from 'drizzle-orm'
import { db } from './db'
import { PostsTable } from './cms-schema' // copied or imported from my-cms/dynamic-schemas/schema.ts
export async function getLatestPosts() {
return db.select().from(PostsTable).orderBy(desc(PostsTable.publishedAt)).limit(10)
}
export async function getPostBySlug(slug: string) {
const [post] = await db.select().from(PostsTable).where(eq(PostsTable.slug, slug)).limit(1)
return post
}See Querying Content for image URLs, localized content, and category trees.
What you learned
- A section file describes the form, the table, and the admin pages at once.
update-sectionskeeps MySQL in sync and asks before destructive changes.- Fields can be reused (
headingField,coverPhotoField,db.unique,searchFieldsall take field objects, not strings). - Sections can reference each other through
sectionon select fields.