nextjs-cms

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:

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

Terminal
pnpm nextjs-cms --dev update-sections

The 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.

Good to know:

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:

sections/posts.section.ts
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!" becomes hello-world).
  • db.unique makes the slug unique. The admin shows a friendly error when a duplicate is submitted, before MySQL does.
  • coverPhotoField shows the cover as a thumbnail on the list page.
  • search.searchFields enables the search box on the list page.
  • fieldGroup splits 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:

sections/categories.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: 'categories',
    order: 2,
    icon: 'folder-tree',
    title: { section: 'Categories', singular: 'Category', plural: 'Categories' },
    headingField: name,
    db: { table: 'categories' },
    fields: [name],
})
sections/posts.section.ts
// ...
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 group

The 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:

website/lib/posts.ts
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-sections keeps MySQL in sync and asks before destructive changes.
  • Fields can be reused (headingField, coverPhotoField, db.unique, searchFields all take field objects, not strings).
  • Sections can reference each other through section on select fields.