On this page
  1. Declare your first concept
  2. Add more fields to the schema
  3. Declare a second concept
  4. Point the adapter at your GitHub repo
  5. Add the email sender
  6. Wire up render
  7. Assemble the adapter
  8. Let a bad adapter fail loudly
  9. Reference another concept
  10. Name your own roles
  11. What’s next

Define an adapter and schema

Every cairn site has exactly one seam the engine consumes: a single module, conventionally src/lib/cairn.config.ts, that declares a site’s content concepts, its GitHub backend, and its renderer. This guide builds one from nothing, in the same shape as the real, running adapter at examples/showcase/src/theme/cairn.config.ts. Keep that file open alongside this guide; each step below builds a smaller version of what that file already does.

Declare your first concept

A concept is a first-class content kind, a directory of markdown files with a typed frontmatter schema. defineConcept declares one and validates it as you write it, not later at a build or a save.

import { defineConcept, fieldset, fields } from '@glw907/cairn-cms';

const posts = defineConcept({
  dir: 'src/content/posts',
  label: 'Posts',
  routing: 'feed',
  fields: fieldset({
    title: fields.text({ label: 'Title', required: true }),
    date: fields.date({ label: 'Date' }),
    description: fields.textarea({ label: 'Description' }),
  }),
});

dir is the repo-relative directory the concept reads and writes. label names it in the admin sidebar; leave it out and cairn title-cases the concept’s key instead. fields is a fieldset built from the fields constructors, the single source of truth for the editor form and the save validator, and the type your own code reads the frontmatter through.

routing is the one property with real consequences beyond the schema: it is a shorthand for three independent facts about a concept, resolved once at declaration.

ShorthandRoutable as a URLCarries a dateAppears in feeds
feedyesyesyes
page (default)yesnono
embeddednonono

Posts want feed. Leave routing off entirely for a plain page, and reach for embedded only for a concept nothing links to directly. Another concept pulls it in rather than giving it a URL of its own.

Add more fields to the schema

Each new field is one more key on the fieldset record. The block below adds an image field, a reference to another concept, and a tag field, each declared like the preceding title or date.

import { defineConcept, fieldset, fields } from '@glw907/cairn-cms';

const posts = defineConcept({
  dir: 'src/content/posts',
  label: 'Posts',
  routing: 'feed',
  summaryFields: ['description'],
  fields: fieldset({
    title: fields.text({ label: 'Title', required: true }),
    date: fields.date({ label: 'Date' }),
    description: fields.textarea({ label: 'Description' }),
    image: fields.image({ label: 'Hero image', seo: true }),
    author: fields.reference({ concept: 'pages', label: 'Author' }),
    topics: fields.multiselect({ label: 'Topics', creatable: true, taxonomy: true }),
  }),
});

image stores a media: reference plus alt text; seo: true also makes it the field the social card reads when a post doesn’t set one explicitly. reference stores an edge to one entry of another concept, here pages, and the editor renders it as a picker over that concept’s entries. multiselect with taxonomy: true marks a field as the concept’s tag field: its values pool across entries and show up on every list card. creatable: true lets an editor add a value that isn’t in the list yet; a site that curates a fixed tag vocabulary in site.config.yaml narrows this same field to a closed picker without changing the schema.

summaryFields names frontmatter keys a list card shows without a full per-entry read. Name anything a card displays that doesn’t already drive the title or the date, such as an excerpt, a status, or a price. Every key it names must also be a declared field.

object and array build repeatable and grouped fields (an FAQ list, a gallery, a nested address) over the same descriptors. They nest one level deep and no further; the full field vocabulary, including the container rules, is in the core reference.

Declare a second concept

Content is a fixed set of concepts you declare, not an open-ended collection, so a site names each one it needs. A plain page looks like this:

import { defineConcept, fieldset, fields } from '@glw907/cairn-cms';

const pages = defineConcept({
  dir: 'src/content/pages',
  label: 'Pages',
  fields: fieldset({
    title: fields.text({ label: 'Title', required: true }),
    robots: fields.text({ label: 'Robots' }),
  }),
});

No routing here, so it defaults to page. Permalinks default by concept id too: Pages resolve to /:slug at the site root, and any other concept resolves under its own id, /posts/:slug for a concept named posts. Override either with permalink (a root-relative pattern using :slug, :year, :month, :day) or datePrefix (the filename date-prefix granularity for a dated concept, year, month, or day, defaulting to day) when the defaults don’t fit your site. A permalink that uses :year, :month, or :day needs a date field of that name and type declared on the same concept; defineConcept enforces this and makes the field required, since the permalink cannot resolve without it.

Point the adapter at your GitHub repo

The backend is where cairn commits and reads content. githubApp builds the default one, a GitHub App scoped to a single repository and the branch it publishes to.

import { githubApp } from '@glw907/cairn-cms';

const backend = githubApp({
  owner: 'your-org',
  repo: 'your-site',
  branch: 'main',
  appId: '123456',
  installationId: '987654',
});

These four strings are the App’s non-secret identity: owner and repo name the target repository, branch is the one the backend reads from and, through a per-entry holding branch, commits to. appId and installationId come from the GitHub App’s own settings page and the organization’s installation of it. The private key never appears here: it lives as the Worker secret GITHUB_APP_PRIVATE_KEY_B64, which the engine reads at request time to mint a short-lived installation token, never from the adapter source you commit.

Add the email sender

Cairn’s default auth sends a magic link by email, so the adapter also names the address it sends from.

const email = { from: 'cms@your-site.com' };

from is the only required key. An optional replyTo gives editors a real address to write back to instead of the sending one.

Wire up render

rendering.render is the site’s one renderer: the editor preview and every public page call it. createRenderer builds the default markdown pipeline, GFM tables and footnotes, cairn’s own figures and link resolution included, and returns a renderMarkdown function your render delegates to.

import { createRenderer } from '@glw907/cairn-cms';
import type { SiteRender } from '@glw907/cairn-cms';

const { renderMarkdown } = createRenderer();

const render: SiteRender = ({ body, resolve, resolveMedia }) =>
  renderMarkdown(body, { resolve, resolveMedia });

Called with no arguments, createRenderer renders plain markdown and nothing else. Pass it a component registry once your site declares its own directive-based components (callouts, pull quotes, a video embed), which is a step of its own and not this guide’s job; the render section of the core reference covers SiteRender, createRenderer’s options, and the registry it optionally takes.

Assemble the adapter

defineAdapter takes the four groups covered so far, plus the media and editor groups a site adds when it needs them (six in all), and returns the same object, narrowed so every concept’s concrete field types survive for your own code to read.

import { defineAdapter, defineConcept, fieldset, fields, githubApp, createRenderer } from '@glw907/cairn-cms';

const { renderMarkdown } = createRenderer();

export const cairn = defineAdapter({
  content: {
    posts: defineConcept({
      dir: 'src/content/posts',
      label: 'Posts',
      routing: 'feed',
      fields: fieldset({
        title: fields.text({ label: 'Title', required: true }),
        date: fields.date({ label: 'Date' }),
        description: fields.textarea({ label: 'Description' }),
      }),
    }),
    pages: defineConcept({
      dir: 'src/content/pages',
      label: 'Pages',
      fields: fieldset({
        title: fields.text({ label: 'Title', required: true }),
      }),
    }),
  },
  backend: githubApp({ owner: 'your-org', repo: 'your-site', branch: 'main', appId: '123456', installationId: '987654' }),
  email: { from: 'cms@your-site.com' },
  rendering: {
    render: ({ body, resolve, resolveMedia }) => renderMarkdown(body, { resolve, resolveMedia }),
  },
});

This is a complete, working adapter. A site with more concepts, richer schemas, R2-backed media, or custom admin screens grows this same object: you add another key under content, or one of the media and editor groups the next section covers.

Let a bad adapter fail loudly

defineConcept checks its concept’s URL policy the moment the module loads, so a typo fails at import—in your editor or dev server—rather than as a silently wrong URL later. The identifier in the error is the concept’s label:

cairn: concept "Posts" permalink "blog/:slug" must start with "/"
cairn: concept "Pages" is not dated, so permalink "/:year/:slug" cannot use the date token ":year"

defineAdapter checks that the island wiring is consistent at the same load-time moment: every component that declares client hydration is listed in rendering.islands, and no islands entry names a component that doesn’t. A wiring mistake between the two fails at load rather than as a component that silently never hydrates.

One check happens later, not at either declaration: composeRuntime catches a reference field naming a concept you haven’t declared under content when it folds your adapter into the runtime the admin actually serves from, since that is the first point where it knows the full set of declared concepts.

Reference another concept

The author field in the earlier schema pointed a posts entry at one pages entry. array(fields.reference(...)) does the same for a many-edge, a post’s list of related posts:

import { fields } from '@glw907/cairn-cms';

const related = fields.array(fields.reference({ concept: 'posts', label: 'Related post' }), {
  label: 'Related posts',
});

The editor renders either as a picker over the target concept’s entries, and a build-time check verifies every stored edge still resolves, failing loudly if an entry gets deleted out from under a reference. Piggybacking the author field on pages, the way the earlier example does, works, but a dedicated concept reads better once a site has more than one or two authors. Add authors to your site walks that dedicated-concept version of the same pattern end to end.

Name your own roles

The engine hard-codes three capability levels, owner, editor, and none, but not what your own roster calls its people. Skip roles entirely and cairn keeps the implicit owner/editor pair it has always had. Add it only when your site’s people use their own names, or when a role needs its own admin screen with no engine content access at all.

import { defineAdapter, defineRoles } from '@glw907/cairn-cms';

const roles = defineRoles({
  owner: 'owner',
  'club-admin': 'editor',
  instructor: { capability: 'none', home: '/admin/classes' },
});

export const cairn = defineAdapter({
  // ...content, backend, email, rendering...
  roles,
});

Each name on the left maps onto one of the three levels on the right. instructor here lands on its own screen after sign-in and never touches content at all. Roles in the core reference covers the full validation rules, the typed CairnRolesRegister augmentation, and the helpers a custom admin route calls to gate itself against the same vocabulary.

What’s next

The adapter also has two smaller groups this guide didn’t need: media, which turns on R2-backed uploads, and editor, which carries the preview frame’s stylesheets, the nav-editing menu, and your own admin screens’ sidebar entries. The core reference documents both alongside everything covered here. If you’re bringing content in from another system rather than starting from an empty directory, Migrate existing content covers mapping it onto concepts like these. And if an adapter that used to load stops loading, or a save fails in a way this guide didn’t predict, Troubleshooting is built to start from the symptom.

Edit this page on GitHub(opens in a new tab)