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:

typescript
import 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: true on name — this field is shown as the entry title in the sidebar
  • required: true — the CMS prevents saving when the field is empty
  • format: 'markdown' — stored as a companion .md file, rendered with react-markdown
  • format: 'image' — stores a media entry UUID, resolved to { src, alt, width, height, blurDataURL } by query()

See Field types for all available formats.


Step 2: Generate Types#

Create the content directory and regenerate TypeScript types:

bash
mkdir -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 dev instead of npm run dev — it watches cms/octocms.config.ts and regenerates types automatically when you change the schema.


Step 3: Create Content in the Admin#

  1. Start the dev server: npm run dev
  2. Open http://localhost:3000/cms and sign in
  3. Navigate to Team Members in the sidebar
  4. Click Add entry
  5. Fill in the fields:
    • Name: Alice Johnson
    • Role: Engineering Lead
    • Bio: Write a short markdown bio
    • Avatar: Upload or select an image
  6. Click Save
  7. 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

tsx
import { 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.avatar is a resolved ResolvedImageField with src, alt, width, height, and optional blurDataURL — pass them directly to next/image
  • MarkdownContent renders a markdown string to React using react-markdown + remark-gfm + rehype-sanitize
  • member.sys.id is the unique entry ID, used as the React key

Step 5: Add Metadata#

Add a generateMetadata export for SEO:

typescript
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Our Team',
  description: 'Meet the people behind the project.',
};

What is Happening Under the Hood#

  1. Content lives as JSON files in cms/content/teamMember/. Markdown fields are stored in companion .md files alongside the JSON.
  2. 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.
  3. 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:


Next Steps#

  • Add more field typesField types reference covers select, boolean, number, datetime, reference, slug, URL, color, JSON, and more
  • Dynamic routes — Add a slug field and create src/app/team/[slug]/page.tsx with generateStaticParams(). 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.