# Build your first cairn site This tutorial builds a working cairn site from an empty directory: a public site rendering markdown content, an admin where editors write, and at the end, a deploy to Cloudflare. Everything is built by hand so you can see what every file does. (A `create-cairn-site` scaffolder that produces the same result in one command is planned; today, this page is the path.) If you'd rather see a cairn site run before building one, the [showcase](../../examples/showcase/README.md) is this tutorial's finished result: clone the repo, `npm install` in `examples/showcase`, and `npm run dev`. You'll need Node (see the [supported toolchain](../reference/supported-toolchain.md) for the version) and a free [Cloudflare](https://www.cloudflare.com/) account for the final milestone. Nothing else: the admin runs locally against a development backend, so you need no GitHub App, no database, and no email setup to run the editor locally. Those arrive when you take a site to production, and the closing milestone points at the guides that wire them. Sending magic-link email to your editors needs a paid Cloudflare plan. [Choose a Workers plan](../guides/configure-auth-and-d1.md#choose-a-workers-plan) explains why, and what to do instead. ## Milestone 0: What you will build A small site with two kinds of content: posts (dated, listed newest-first) and pages (standing, like About). Editors sign in at `/admin` and write markdown in cairn's editor; the public site renders that markdown through one function you write. You'll save and publish an entry in milestone 8, in a real admin running on your machine. By milestone 10 the public site is live on a `workers.dev` URL. Content lives as markdown files in a git repository. The admin edits those files through cairn's commit pipeline, and your site renders them with your own design. Everything you build here survives into a production site unchanged. ## Milestone 1: Create the project Scaffold a fresh SvelteKit project with the current toolchain, `sv create`, then install cairn and its Cloudflare adapter: ```sh npx sv create cairn-tutorial --template minimal --types ts --no-add-ons --install npm cd cairn-tutorial npm install @glw907/cairn-cms npm install -D @sveltejs/adapter-cloudflare wrangler ``` Point the scaffolded `svelte.config.js` at the Cloudflare adapter: ```js // svelte.config.js import adapter from '@sveltejs/adapter-cloudflare'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; export default { preprocess: vitePreprocess(), kit: { adapter: adapter(), }, }; ``` Then declare the three Cloudflare bindings the admin will need: ```jsonc // wrangler.jsonc { "name": "cairn-tutorial", "compatibility_date": "2026-05-28", "compatibility_flags": ["nodejs_compat"], "main": ".svelte-kit/cloudflare/_worker.js", "assets": { "directory": ".svelte-kit/cloudflare", "binding": "ASSETS" }, // Email Sending binding for magic links (arbitrary recipients). "send_email": [{ "name": "EMAIL" }], // cairn-cms self-owned magic-link auth store (editor allowlist, sessions, tokens). "d1_databases": [ { "binding": "AUTH_DB", "database_name": "cairn-tutorial-auth", "database_id": "00000000-0000-0000-0000-000000000000" } ], // R2 bucket backing the media library. "r2_buckets": [{ "binding": "MEDIA_BUCKET", "bucket_name": "cairn-tutorial-media" }], "vars": { // Canonical origin for magic-link confirmation links, never read from a request header. "PUBLIC_ORIGIN": "http://localhost:4173" } } ``` The bindings in `wrangler.jsonc` are declared now and used later: D1 backs the admin's sessions, the email binding sends sign-in links, and R2 holds images. Locally, the development backend supplies doubles for the database and the bucket and replaces the email loop by signing you in directly, so declaring all three costs nothing today and saves a deploy-day surprise. ## Milestone 2: Define the adapter and schema The adapter is your site's declaration: what kinds of content exist and what fields each carries, where commits go, and how markdown becomes HTML. It's one TypeScript file, and it's the most load-bearing file in a cairn site. ```ts // src/lib/cairn.config.ts import { defineAdapter, defineConcept, fieldset, fields, githubApp } from '@glw907/cairn-cms'; import type { SiteConfig } from '@glw907/cairn-cms'; // A minimal inline site config, standing in until Milestone 7 replaces it with the real, // git-committed site.config.yaml (siteName, nav, and the rest the editor owns). export const siteConfig: SiteConfig = { siteName: 'Cairn Tutorial' }; export const cairn = defineAdapter({ content: { posts: defineConcept({ dir: 'src/content/posts', label: 'Posts', summaryFields: ['description'], routing: 'feed', fields: fieldset({ title: fields.text({ label: 'Title', required: true }), date: fields.date({ label: 'Date' }), tags: fields.multiselect({ label: 'Tags', creatable: true, taxonomy: true }), description: fields.textarea({ label: 'Description' }), }), }), pages: defineConcept({ dir: 'src/content/pages', label: 'Pages', routing: 'page', fields: fieldset({ title: fields.text({ label: 'Title', required: true }), description: fields.textarea({ label: 'Description' }), }), }), }, backend: githubApp({ owner: 'your-username', repo: 'cairn-tutorial', branch: 'main', appId: '1', installationId: '1' }), email: { from: 'cms@example.com' }, rendering: { // A placeholder: it returns the raw markdown untouched. Milestone 4 replaces this with the // real render pipeline. render: async ({ body }) => body, }, }); ``` The concepts are a fixed set you declare, and each one's schema is typed, so a wrong field name or type fails at compile time rather than in an editor's face. The GitHub block names where production commits land; the development backend ignores it until then, so any owner and repo name work today. ## Milestone 3: Add content Content is markdown files with frontmatter, one directory per concept. ```md --- title: First Race date: 2026-06-15 tags: - race-reports description: Notes from the start line of my first race this season. --- ## How it went The gun went off and I forgot every plan I'd made. - Cold at the start, warm by the second lap - Legs held up better than expected - Next time: eat breakfast earlier ``` Save that as `src/content/posts/2026-06-15-first-race.md`. ```md --- title: About description: Who runs this site and why. --- I write about racing and training. This site is where the notes live. ``` Save that as `src/content/pages/about.md`. The filename is the entry's identity: the stem becomes the id, and for dated concepts the leading date is stripped to form the slug, while the entry's date comes from the `date` frontmatter field. You'll never rename these by hand once editors exist, because addresses are promises, but it's useful to have seen the shape once. ## Milestone 4: Configure rendering Your site owns its look, and cairn asks for exactly one thing: a function from markdown to HTML. The editor's preview and your public pages both call it, which is why what editors see is what readers get. `createRenderer` builds that function. Replace the placeholder from Milestone 2 with the real pipeline: ```ts // src/lib/cairn.config.ts import { defineAdapter, defineConcept, fieldset, fields, githubApp, createRenderer } from '@glw907/cairn-cms'; import type { SiteConfig } from '@glw907/cairn-cms'; export const siteConfig: SiteConfig = { siteName: 'Cairn Tutorial' }; const { renderMarkdown } = createRenderer(); export const cairn = defineAdapter({ content: { posts: defineConcept({ dir: 'src/content/posts', label: 'Posts', summaryFields: ['description'], routing: 'feed', fields: fieldset({ title: fields.text({ label: 'Title', required: true }), date: fields.date({ label: 'Date' }), tags: fields.multiselect({ label: 'Tags', creatable: true, taxonomy: true }), description: fields.textarea({ label: 'Description' }), }), }), pages: defineConcept({ dir: 'src/content/pages', label: 'Pages', routing: 'page', fields: fieldset({ title: fields.text({ label: 'Title', required: true }), description: fields.textarea({ label: 'Description' }), }), }), }, backend: githubApp({ owner: 'your-username', repo: 'cairn-tutorial', branch: 'main', appId: '1', installationId: '1' }), email: { from: 'cms@example.com' }, rendering: { render: ({ body, resolve, resolveMedia }) => renderMarkdown(body, { resolve, resolveMedia }), }, }); ``` **First payoff:** run the dev server and render an entry in a scratch route now, if you want to see it. Or wait one milestone: the delivery surface renders everything properly there. ## Milestone 5: Add a custom component Markdown covers prose. For anything richer, cairn uses components: framed blocks editors insert through a guided form. Declaring one takes a schema (what the form asks) and a template (what renders). ```ts // src/lib/cairn.config.ts (the added component, registered on the renderer) import { defineComponent, defineRegistry, fields, createRenderer } from '@glw907/cairn-cms'; import { h } from 'hastscript'; const callout = defineComponent({ name: 'callout', label: 'Callout', description: 'A highlighted note.', build: (ctx) => h('aside', { className: ['callout', `callout-${String(ctx.attributes.tone ?? 'note')}`] }, [ h('p', { className: ['callout-title'] }, ctx.slot('title')), h('div', { className: ['callout-body'] }, ctx.slot('body')), ]), attributes: { tone: fields.select({ label: 'Tone', required: true, options: ['note', 'tip', 'warning'] }), }, slots: [ { name: 'title', label: 'Title', kind: 'inline', required: true }, { name: 'body', label: 'Body', kind: 'markdown' }, ], }); const registry = defineRegistry({ components: [callout] }); const { renderMarkdown } = createRenderer(registry); ``` Add `components: registry` to the adapter's `rendering` block alongside `render`, so the editor's insert palette and the render pipeline both see it. Editors never see this code. They see a "Callout" entry in the insert menu, a form asking for a title and a tone, and a live preview. The `:::callout` text it writes into their draft is yours to render however the site's design wants. ## Milestone 6: Wire the delivery surface The public site reads the same content the admin edits. cairn gives you the pieces as data and a route factory: a concept index for listings, and `createPublicRoutes` for the entry pages. One content layer builds the typed indexes every route reads: ```ts // src/lib/content.ts import { createSiteIndexes } from '@glw907/cairn-cms/delivery'; import { cairn, siteConfig } from './cairn.config.js'; const postsRaw = import.meta.glob('/src/content/posts/*.md', { query: '?raw', import: 'default', eager: true, }) as Record; const pagesRaw = import.meta.glob('/src/content/pages/*.md', { query: '?raw', import: 'default', eager: true, }) as Record; const indexes = createSiteIndexes(cairn, siteConfig, { posts: postsRaw, pages: pagesRaw }); export const site = indexes.site; export const posts = indexes.posts; export const ORIGIN = 'http://localhost:5173'; export const SITE_DESCRIPTION = 'A small cairn site.'; ``` The public pages need a root layout and a home page: ```svelte {@render children()} ``` ```ts // src/routes/(site)/+page.server.ts import type { PageServerLoad } from './$types'; import { posts } from '$lib/content.js'; export const prerender = true; export const load: PageServerLoad = () => ({ posts: posts.all() }); ``` ```svelte

Posts

``` The `(site)` folder is a route group: it's invisible in the URL, and it keeps your public pages separate from `/admin`, which arrives in Milestone 8. Every entry, of either concept, renders through one catch-all, built on `createPublicRoutes`: ```ts // src/routes/(site)/[...path]/+page.server.ts import type { PageServerLoad, EntryGenerator } from './$types'; import { createPublicRoutes } from '@glw907/cairn-cms/delivery'; import { site, ORIGIN, SITE_DESCRIPTION } from '$lib/content.js'; import { cairn, siteConfig } from '$lib/cairn.config.js'; export const prerender = true; const routes = createPublicRoutes({ site, render: cairn.rendering.render, origin: ORIGIN, siteName: siteConfig.siteName, description: SITE_DESCRIPTION, }); export const entries: EntryGenerator = () => routes.entries(); export const load: PageServerLoad = ({ url }) => routes.entryLoad({ url }); ``` ```svelte

{data.entry.title}

{@html data.html}
``` **Payoff:** `npm run dev`, open the listing, click through to your first post. That page just traveled the whole pipeline: markdown file, frontmatter schema, your render function, your design. **Worth deciding here:** your adapter can carry one more field alongside `content`, `backend`, and `rendering`: `aiPosture`, set to `'decline'` or `'invite'`. Your site's `robots.txt` route (the [delivery-surface guide](../guides/wire-the-delivery-surface.md) covers building it) passes that value straight to `robotsResponse`. Now is a reasonable time to decide it, since you're already choosing what this site exposes to the world. [Choose an AI posture](../guides/choose-an-ai-posture.md) covers what each direction actually does, what it doesn't, and the Cloudflare interaction that determines what a crawler actually sees. Setting `'decline'` adds a `Disallow: /` line for each training crawler in cairn's table, plus a `Content-Signal` line stating you'd rather your content stayed out of training data. That works only on a crawler that reads and honors robots.txt. OpenAI's `ChatGPT-User` and Perplexity's `Perplexity-User` are exempt from it by their own operators' design, so a fully declining site can still answer a live fetch the moment someone asks an assistant about it. Setting `'invite'` states the opposite preference and skips the `Disallow` lines entirely, since no robots directive can summon a crawler. A site can decline credibly. No site can make one arrive. Leaving `aiPosture` unset states nothing. cairn writes only the stance you give it, and guessing one on your behalf is exactly what this field exists to avoid. Set it whenever you decide, in either direction. Nothing else about your adapter changes. ## Milestone 7: Add the nav menu Site structure that editors shouldn't edit by accident (the nav, the site name) lives in a YAML config file, read at build time. ```yaml # src/lib/site.config.yaml siteName: Cairn Tutorial menus: primary: - label: Home url: / - label: About url: /about ``` Parse it in one small module, separate from `cairn.config.ts`: ```ts // src/lib/site-config.ts import { parseSiteConfig, extractMenu } from '@glw907/cairn-cms'; import siteYaml from './site.config.yaml?raw'; export const siteConfig = parseSiteConfig(siteYaml); export const nav = extractMenu(siteConfig, 'primary', 2); ``` `cairn.config.ts` grows into your site's full adapter as later milestones add to it, so it's the wrong module for a layout to read just to get the nav. `cairn.config.ts` still re-exports `siteConfig` under its own name, since Milestone 8's admin wiring imports it from there: ```ts // src/lib/cairn.config.ts (replacing the Milestone 2 siteConfig stub) import { siteConfig } from './site-config.js'; export { siteConfig }; ``` Then read the menu on the server, and hand it to the layout as data: ```ts // src/routes/(site)/+layout.server.ts import type { LayoutServerLoad } from './$types'; import { nav } from '$lib/site-config.js'; export const load: LayoutServerLoad = () => ({ nav }); ``` ```svelte
{@render children()}
``` The load runs on the server, so nothing from `site-config.ts` reaches the client bundle except the plain array of nav nodes the layout renders. A client script that imported `site-config.ts` directly would ship the YAML parser to every visitor, and one that imported `cairn.config.ts` would ship the whole adapter, all for the sake of a nav array. A nav node's `url` is optional: a node with only a `label` is a grouping header for nested menus. This flat header renders entries that carry a `url`. If your menu grows grouping nodes, filter them out before the `{#each}`, the way the showcase's `SiteHeader.svelte` does. ## Milestone 8: Run the admin locally Everything so far was the site. The admin takes six small files and one build-config line, all of them mounting machinery cairn provides. The `(site)` group and the bare root layout from Milestone 6 already do the one thing the admin needs from your side: they keep your site's own chrome from wrapping `/admin`, so nothing here has to move. One composer builds the runtime once and wraps it in the single-mount facade: ```ts // src/lib/cairn.server.ts import { composeRuntime } from '@glw907/cairn-cms'; import { createCairnAdmin } from '@glw907/cairn-cms/sveltekit'; import { cairn, siteConfig } from './cairn.config.js'; export const runtime = composeRuntime({ adapter: cairn, siteConfig }); export const admin = createCairnAdmin(runtime); ``` The shared shell layout wraps every `/admin/**` route in cairn's chrome: ```ts // src/routes/admin/+layout.server.ts import { admin } from '$lib/cairn.server.js'; export const load = admin.shellLoad; ``` ```svelte {@render children()} ``` The catch-all serves every admin view: ```ts // src/routes/admin/[...path]/+page.server.ts import { admin } from '$lib/cairn.server.js'; export const prerender = false; export const load = admin.load; export const actions = admin.actions; ``` ```svelte ``` `CairnAdminShell` and `CairnAdmin` ship as `.svelte` files, so tell Vite to bundle the package for the server instead of treating it as an external dependency: ```ts // vite.config.ts import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [sveltekit()], ssr: { noExternal: ['@glw907/cairn-cms'] }, }); ``` Last, wire the development backend. It stands in for the GitHub App and the magic-link auth loop, so `/admin` runs locally with no cloud accounts. The backend must never reach production, so it sits behind a build-time flag. Declare that flag as a Vite `define`, which substitutes it as a literal into every file that names it: ```ts // vite.config.ts import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig(({ command }) => ({ plugins: [sveltekit()], ssr: { noExternal: ['@glw907/cairn-cms'] }, define: { __CAIRN_DEV_BUILD__: JSON.stringify(command === 'serve') }, })); ``` Give it a type, once: ```ts // src/app.d.ts declare global { const __CAIRN_DEV_BUILD__: boolean; } export {}; ``` Now name the flag directly in the branch that loads the backend, and import the package dynamically: ```ts // src/hooks.server.ts import { createAuthGuard } from '@glw907/cairn-cms/sveltekit'; import type { Handle } from '@sveltejs/kit'; let handle: Handle; if (__CAIRN_DEV_BUILD__ && process.env.CAIRN_DEV_BACKEND === '1') { const { devBackendHandle } = await import('@glw907/cairn-cms-dev'); handle = devBackendHandle(); } else { handle = createAuthGuard(); } export { handle }; ``` Name the define at each call site rather than exporting one shared constant from a helper module. A production build substitutes `false` in the text of every branch that names it, so the bundler drops the branch along with its `import()`. A shared constant folds inside its own chunk but doesn't propagate across the module boundary, which leaves the branch in place and ships the development backend in the deployed Worker. `@glw907/cairn-cms-dev` is a separate package, and only a `devDependency`; install it before starting the server: ```sh npm install -D @glw907/cairn-cms-dev CAIRN_DEV_BACKEND=1 npm run dev ``` Open `/admin`, and the development backend signs you in without any email loop. Create a post, write a paragraph, and **save** it through the real pipeline. Then **publish** it and reload the public listing. What just happened is the model the whole system runs on: ```mermaid %%{init: {"theme": "neutral"}}%% flowchart LR E[Editor saves] --> H[Holding branch\none per entry] H -->|Publish| M[main] M -->|Deploy| L[Live site] ``` Every save is a commit on a holding branch named for the entry, private until published. Publish copies the entry to `main` with the editor as author, and in production, the push deploys the site. The development backend simulates the branches locally, which is why none of this needed GitHub today. ## Milestone 9: Confirm the internal link and regenerate the manifest An internal link addresses an entry, not a URL, and cairn writes that address as `cairn:/`. Add one to the post from Milestone 3: ```md --- title: First Race date: 2026-06-15 tags: - race-reports description: Notes from the start line of my first race this season. --- ## How it went The gun went off and I forgot every plan I'd made. - Cold at the start, warm by the second lap - Legs held up better than expected - Next time: eat breakfast earlier Read more on the [about page](cairn:pages/about). ``` Save that over `src/content/posts/2026-06-15-first-race.md`, then reload the post: the link resolves to `/about`, the live permalink for that entry, resolved by id rather than hard-coded. The engine also keeps a committed manifest of your whole corpus, a build-time link graph the `cairnManifest()` Vite plugin verifies on every build. It doesn't regenerate on its own; you run it after any hand-edit to content outside the admin, which is exactly what you just did. Wire the plugin into your Vite config: ```ts // vite.config.ts import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; import { cairnManifest } from '@glw907/cairn-cms/vite'; export default defineConfig({ plugins: [ sveltekit(), cairnManifest({ configModule: '/src/lib/cairn.config.ts', content: { posts: '/src/content/posts/*.md', pages: '/src/content/pages/*.md' }, manifestPath: '/src/content/.cairn/index.json', }), ], ssr: { noExternal: ['@glw907/cairn-cms'] }, }); ``` Add the regenerate script and run it: ```jsonc // package.json (the "scripts" block) { "scripts": { "cairn:manifest": "cairn-manifest" } } ``` ```sh npm run cairn:manifest ``` The command writes `src/content/.cairn/index.json` and exits zero. From here on, run it any time you edit content by hand; the admin keeps the manifest current on its own whenever an editor publishes. Because the link resolves by id, it survives a rename of the file. The manifest is how the editor's pickers know what exists without a per-request repo crawl; the public build reads its content from bundled imports, and the manifest additionally verifies the internal-link graph at build time. ## Milestone 10: Deploy The public site is deployable now. The admin needs its production trio (the GitHub App, the D1 database, the email sender) before editors can sign in on the live site, and those are each a guide of their own rather than a tutorial detour. ```sh npm run build npx wrangler deploy ``` Wrangler prints your `*.workers.dev` URL when the deploy finishes. The public pages are already live; `/admin` deploys too, but no one can sign in until that trio exists, which the guides below wire up. **Final payoff:** your site, on its `workers.dev` URL, rendering the content you wrote in milestone 3 and the post you published in milestone 8. ## Where to go next Production admin: [set up the GitHub App](../guides/set-up-the-github-app.md), [configure auth and D1](../guides/configure-auth-and-d1.md), and [deploy to Cloudflare](../guides/deploy-to-cloudflare.md) finish what milestone 10 started. Your editors' own front door is [Welcome, editors](../guides/editor-welcome.md), and the [writing guide](../guides/write-in-the-editor.md) covers the editor at working depth. When something misbehaves, [troubleshooting](../guides/troubleshooting.md) maps symptoms to fixes. And [why cairn](../explanation/why-cairn.md) explains the reasoning behind everything you just built.