Show navigationHide navigation
Tutorial: Your First Content Page
This tutorial walks you through creating a content schema, adding entries in the admin, and rendering them on a public page. By the end you will have a /team page that lists team members from the CMS.
Prerequisites: Complete the Getting started guide first — you need a running Next.js project with OctoCMS installed and the GitHub App configured.
Step 1: Define the Schema#
Open cms/octocms.config.ts and add a teamMember collection:
typescriptimport type { Config } from 'octocms/types'; import { defineConfig } from 'octocms/config'; const _typedConfigOctoCMS = defineConfig({ projectName: 'My Site', git: { baseBranch: 'main' }, contentFolder: 'cms/content', mediaFolder: 'public/media', mediaAllowedFormats: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif'], collections: { teamMember: { label: 'Team Member', hasMany: true, fields: { name: { label: 'Name', format: 'string', entryTitle: true, required: true }, role: { label: 'Role', format: 'string' }, bio: { label: 'Bio', format: 'markdown' }, avatar: { label: 'Avatar', format: 'image' }, }, }, }, }); export const configOctoCMS: Config = _typedConfigOctoCMS; export type OctoConfig = typeof _typedConfigOctoCMS;
Key points:
hasMany: true— multiple team member entries are allowed (each gets a UUID filename)entryTitle: trueonname— this field is shown as the entry title in the sidebarrequired: true— the CMS prevents saving when the field is emptyformat: 'markdown'— stored as a companion.mdfile, rendered withreact-markdownformat: 'image'— stores a media entry UUID, resolved to{ src, alt, width, height, blurDataURL }byquery()
See Field types for all available formats.
Step 2: Generate Types#
Create the content directory and regenerate TypeScript types:
bashmkdir -p cms/content/teamMember npx octocms types:gen
This writes typed interfaces and a query() binding to cms/__generated__/. You will get autocomplete for query('teamMember') and entry.fields.name, entry.fields.role, etc.
Tip: Use
npx octocms devinstead ofnpm run dev— it watchescms/octocms.config.tsand regenerates types automatically when you change the schema.
Step 3: Create Content in the Admin#
- Start the dev server:
npm run dev - Open http://localhost:3000/cms↗ and sign in
- Navigate to Team Members in the sidebar
- Click Add entry
- Fill in the fields:
- Name: Alice Johnson
- Role: Engineering Lead
- Bio: Write a short markdown bio
- Avatar: Upload or select an image
- Click Save
- Repeat to create a second team member
Each save writes a JSON file to cms/content/teamMember/. You can see them on disk:
cms/content/teamMember/ teamMember-abc123.json teamMember-abc123.bio.md # companion file for the markdown field teamMember-def456.json teamMember-def456.bio.md
Step 4: Build the Page with an Inline Cached Helper#
Define the cached fetcher as a private async helper at the top of the page file and mark it 'use cache'. Co-locating it with the page that uses it keeps each route self-contained and lets generateMetadata and the default export share the same cache entry.
src/app/team/page.tsx
tsximport { cacheLife, cacheTag } from 'next/cache'; import Image from 'next/image'; import { MarkdownContent } from 'octocms/components/public'; import { query } from 'cms/__generated__/query'; async function getTeamMembers() { 'use cache'; cacheTag('team'); cacheLife('hours'); return query('teamMember').toArray(); } export default async function TeamPage() { const members = await getTeamMembers(); return ( <main style={{ maxWidth: 800, margin: '0 auto', padding: '2rem' }}> <h1>Our Team</h1> <div style={{ display: 'grid', gap: '2rem', marginTop: '1rem' }}> {members.map((member) => ( <article key={member.sys.id} style={{ display: 'flex', gap: '1.5rem' }}> {member.fields.avatar && ( <Image src={member.fields.avatar.src} alt={member.fields.avatar.alt} width={120} height={120} style={{ borderRadius: '50%', objectFit: 'cover' }} {...(member.fields.avatar.blurDataURL ? { placeholder: 'blur' as const, blurDataURL: member.fields.avatar.blurDataURL } : {})} /> )} <div> <h2>{member.fields.name}</h2> {member.fields.role && <p style={{ color: '#666' }}>{member.fields.role}</p>} {member.fields.bio && <MarkdownContent content={member.fields.bio} />} </div> </article> ))} </div> </main> ); }
Key patterns:
member.fields.avataris a resolvedResolvedImageFieldwithsrc,alt,width,height, and optionalblurDataURL— pass them directly tonext/imageMarkdownContentrenders a markdown string to React usingreact-markdown+remark-gfm+rehype-sanitizemember.sys.idis the unique entry ID, used as the React key
Step 5: Add Metadata#
Add a generateMetadata export for SEO:
typescriptimport type { Metadata } from 'next'; export const metadata: Metadata = { title: 'Our Team', description: 'Meet the people behind the project.', };
What is Happening Under the Hood#
- Content lives as JSON files in
cms/content/teamMember/. Markdown fields are stored in companion.mdfiles alongside the JSON. query('teamMember')reads those files (from disk in dev, from the GitHub API in production), resolves image UUIDs to full image objects, loads markdown companion files, and excludes draft/archived entries.- The
'use cache'directive +cacheTag('team')means Next.js caches the result. When you save an entry in the CMS, the cache is automatically invalidated so the next page load shows fresh content.
For deeper details:
- Content model — entry format,
sysfields, status lifecycle - Query API — filtering, sorting, pagination,
.includeDrafts() - Public content — cache tags, revalidation, Draft Mode
- Next.js integration — every App Router and Pages Router pattern
Next Steps#
- Add more field types — Field types reference covers select, boolean, number, datetime, reference, slug, URL, color, JSON, and more
- Dynamic routes — Add a
slugfield and createsrc/app/team/[slug]/page.tsxwithgenerateStaticParams(). See Next.js integration. - Search — Add full-text search to your site with the
<SearchBox />component. See Search. - References — Link entries to each other (e.g. team members → projects). See Field types: reference.