Guides

Building a site for BooshCMS

The contract your Astro project has to meet. There is no manifest and no config file — the app reads your code.

For the developer. This is the contract your Astro project has to meet for the app to be able to edit it.

There is no manifest and no config file. BooshCMS reads your code — the collection schemas, the component prop types, the page markup. That is what lets it work against any Astro project. It also means the code has to be shaped right, and this document is that shape.


Two ways to build a page, and the one to choose

Recommended: a page is data. Model an editable page as a collection entry with its fields and an ordered array of blocks, and write one route that renders them. The client adds, reorders and edits blocks; nothing parses or rewrites a source file, so nothing can be lost.

The array is a z.discriminatedUnion on type. That is what turns a repeater into a page builder: every entry is one of a fixed set of sections, so the client is offered them by name and each one gets its own form.

const pages = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/pages' }),
  schema: ({ image }) => z.object({
    title: z.string(),
    intro: z.string().optional(),

    blocks: z.array(z.discriminatedUnion('type', [
      z.object({
        type: z.literal('splitContent'),
        heading: z.string(),
        description: z.string(),
        picture: image().optional(),
        imageSide: z.enum(['left', 'right']).default('left'),
      }),
      z.object({
        type: z.literal('featureGrid'),
        heading: z.string(),
        features: z.array(z.object({ title: z.string(), description: z.string() })),
      }),
      z.object({
        type: z.literal('testimonial'),
        quote: z.string(),
        attribution: z.string(),
      }),
    ])).default([]),

    seo: z.object({ metaTitle: z.string().optional() }).optional(),
  }),
})

Fields before the array, the array, fields after it — the client gets an ordinary form with a page builder in the middle of it.

---
const BLOCKS = { splitContent: SplitContent, featureGrid: FeatureGrid, testimonial: Testimonial };
const page = await getEntry('pages', Astro.params.slug);
---
<Layout title={page.data.title}>
  {page.data.blocks.map((b) => {
    const Block = BLOCKS[b.type];
    const { type, ...props } = b;
    return <Block {...props} />
  })}
</Layout>

Adding a section to the site is two steps: a member in the union, and a line in BLOCKS. Nothing else changes, and the client finds it in the Add a section menu the next time they open the page.

Making a page builder is the step-by-step version of this, with the traps written down. Read it before building one.

What the client sees

  • Add a section opens a menu of the block types, named from their type literal — splitContent reads as Split content.
  • Each row is labelled with its block’s name and carries only that block’s fields.
  • Rows reorder with the arrows and remove with ✕. The order in the file is the order on the page.
  • The discriminator is never shown. It says what the block is; it is not something anyone types.
  • A block whose type is no longer in the union is left alone and reported, rather than being rewritten with another block’s fields.

A plain z.array(z.object({…})) is still a plain repeater — one shape, an Add button, no menu. Reach for the union only when there is a real choice.

This is a build convention, and the same size of commitment as choosing Sanity. A Sanity site already accepts that a page is a document with a page-builder array, that components are registered, and that a renderer maps between them. This asks the same — and the content stays as files in the client’s own repository.

The other way: a page as source. An .astro page of components is editable too, but only in the narrow shape this app writes — one layout wrapping components. A wrapper <div>, a hand-written heading, bare text, a .map() or a <style> block makes it read-only, on purpose: rebuilding a file from a model is only safe when the model can hold the whole file.

It works, and it is fine for a simple page. It is not being extended. Editing the props on an otherwise hand-written page was considered and cut on 2026-08-27 — it would have meant carrying a parser for arbitrary developer source that has to be right every time, to deliver prop editing rather than a page builder. The reasoning is in decisions.md.

So: use the source form for a page that is genuinely just a stack of blocks. Use the data form for anything a client should really own.


The short version

  1. Anything repeatable is a content collection with a Zod schema. One file per entry.
  2. Client-editable pages are marked @client and contain only component elements inside a layout.
  3. Every component declares a typed interface Props, with no content hardcoded inside it.
  4. Blocks the client may place are marked @client in the component’s frontmatter.
  5. Page-level settings — title, description, social image — are props on the layout, not a block.

Most of this is already FEWD Studio’s house style. The rest of this document is the detail and the reasoning.


Collections

Define them in src/content.config.ts. The Zod schema is the editing form.

import { defineCollection, reference, z } from 'astro:content'
import { glob } from 'astro/loaders'

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      description: z.string(),
      pubDate: z.coerce.date(),
      cover: image().optional(),
      draft: z.boolean().default(false),
      tags: z.array(z.string()).default([]),
      author: reference('team').optional(),
    }),
})

export const collections = { blog }

What each construct becomes

Zod Control the client sees
z.string() Single line, or a textarea for names like description
z.string() named body / content The rich text editor
z.number(), z.boolean() Number field, checkbox
z.coerce.date(), z.date() Date picker
z.enum([…]) Radio group up to three options, dropdown beyond
z.array(z.string()) Repeater of single lines
z.array(z.object({…})) Repeater of grouped fields
z.array(z.discriminatedUnion('type', […])) The page builderAdd a section offers each member by name
z.object({…}) Nested group
image() Picture picker
reference('team') Dropdown of that collection’s entries
A field named …url, …href or …link Web address, with this site’s own pages offered as suggestions
.optional() / .default(x) Not required; the default is pre-filled
.describe('…') Help text under the field

Anything else is shown as a plain text field with a note, and a warning appears in the editor rather than the collection being dropped.

Rules

  • export const collections = { … } must be present. It is the index.
  • glob() and the legacy folder convention are both read, and so is file() — a single JSON or YAML file holding many entries is fully editable, and entries the client never opened are written back byte for byte.
  • Markdown collections get a writing surface; JSON and YAML ones get fields only.
  • Fields not in your schema survive. An internalRef the client never sees is read, kept and written back untouched.
  • Filenames are slugs and the client never sees them.

Components

Folder-per-component, each with its own stylesheet:

src/components/Hero/Hero.astro
src/components/Hero/Hero.css

A flat Hero.astro is picked up too, so projects not built in FEWD still work. PascalCase only — Astro requires it to use the file as a tag.

---
import './Hero.css';

interface TrustItem {
  stat: string;
  label: string;
}

interface Props {
  /** The big line at the top of the page. */
  heading: string;
  subheading?: string;
  imageSrc: string;
  imageAlt: string;
  ctaLabel: string;
  ctaHref: string;
  align?: 'left' | 'center';
  trustItems?: TrustItem[];
}

const { heading, subheading, imageSrc, imageAlt, ctaLabel, ctaHref,
        align = 'left', trustItems } = Astro.props;
---

What each type becomes

TypeScript Control
string Single line
'left' | 'center' Radio group — use string-literal unions for choices
number, boolean Number field, checkbox
Date Date picker
ImageMetadata, or a name ending …Src / …Image Picture picker
A name ending …Href / …Url / …Link Web address field
Item[] where Item is a local interface Repeater of grouped fields
A type imported from another file No field — read-only, “Set by your developer”
CollectionEntry<'blog'>, or anything the page fetches No field — read-only
An inline { … } Nested group
? Not required
A /** JSDoc */ comment Help text under the field

Nested interfaces and arrays of objects are fully resolved, up to four levels.

Rules

  • Declare interface Props or type Props. No declaration, no fields — the app warns if a component reads Astro.props without one.

  • Never hardcode content in a component. Everything the client should be able to change is a prop.

  • A component’s outermost element should be the section it represents — a <section> carrying the component’s own class, not a bare <div> waiting for something to wrap it. A page is then a flat stack of siblings, which is what the client sees in the block list, and the document outline comes out right: one <h1> from the opening component, an <h2> from each one after.

    Do not build a generic Section or Container whose job is to wrap other components, and do not place one client component inside another. Wrapping puts a <section> inside a <section> with a heading in each — wrong for screen readers and for search engines — and it makes the block list claim a hierarchy that means nothing to the client.

  • A <slot /> makes a component able to hold other blocks. Only those accept children in the page builder.

  • Declare the shapes you want editable in the same file. An imported type cannot be resolved yet, so it renders as read-only rather than as an empty box a client would type into. If a client should be able to edit it, declare the interface alongside Props.

  • interface Props extends Base is folded in.

Icons, and anything else that is markup

Markup lives in the component. Content names it.

An icon is a picture, not content. Keep icons as .svg files — src/icons/ is a good home — and import them into the component that draws them. Astro renders an imported SVG as a component (5.7+), so there is no dependency, no sprite sheet and no runtime:

---
import bolt from '../../icons/bolt.svg'
import chart from '../../icons/chart.svg'

/** Declared here, not imported: an imported type cannot be resolved. */
type IconName = 'bolt' | 'chart'
const ICONS = { bolt, chart }

interface FeatureItem {
  /** Picture shown above the title. */
  icon?: IconName
  title: string
  description: string
}
interface Props { items: FeatureItem[] }
---

{items.map(item => { const Icon = ICONS[item.icon ?? 'bolt']; return <Icon width={24} /> })}

The client gets a repeater for items and a picker on each row’s icon, offering exactly the icons you drew. They can add a feature and choose its picture, and there is no way for them to supply anything else.

Never accept markup as a prop. A component taking an iconHtml string and rendering it with set:html costs three things at once, and the third is the one people miss:

Every page using it carries thousands of characters of SVG and a fresh copy per use
Anything editing that content can inject markup into the site set:html does not ask questions
The page becomes hard to edit safely a string full of quotes has to be re-quoted to be written back, and what cannot be proved lossless is refused

The union has to be written in the component’s own file. An imported type resolves to “Set by your developer”, which leaves the client a read-only field instead of a picker.


Choosing which blocks the client can place

The marker goes on the component, once, in its own file. Nothing goes in the page — a page is simply the result of what the client placed.

The parser looks for the token @client anywhere in the component’s frontmatter. A comment is the conventional place for it.

Marked — offered in the palette

---
/** @client */
import './Hero.css';

interface Props {
  heading: string;
  ctaLabel: string;
  ctaHref: string;
}

const { heading, ctaLabel, ctaHref } = Astro.props;
---

Unmarked — kept out of the client’s way

---
import './PostCard.css';

interface Props {
  title: string;
  href: string;
}

const { title, href } = Astro.props;
---

PostCard is a perfectly good component — the blog listing uses it in a loop. It is just not something a client should drop onto a page on its own.

A component that appears on every page is not a block. Mark it and it is kept out of the palette:

---
/** @header — the site header, edited on its own rather than placed on a page. */
---

@footer likewise. Marked components are never offered as blocks, including in a project that has marked nothing @client — which is the one case where the palette otherwise offers everything. Without that, a client eventually puts a second header halfway down a page.

Their props are still read, because something has to edit them. What that surface looks like in the app is not built yet; today, put anything a client should change into a collection the header and footer read — a navigation collection for the menu, a settings entry holding one row for the telephone number, opening hours and social links. That is where it belongs anyway: content typed into a component template can only be changed by you.

The rule

Any component marked Only the marked ones appear in the palette
No component marked Every component appears

That second row is what keeps the app working against a project which has never heard of the marker. Adding your first @client is the moment restriction turns on, so mark every block you want available in the same pass.

Restricting never hides an existing block. A page already using an unmarked component still edits normally, with all its fields — it just cannot be added again from the palette.

Two things to know

/** @client */ on its own is enough. Anything after it is for whoever reads the file next:

/** @client — the client may place this block on a page. */

The match is against the whole frontmatter, not comments specifically. So do not put the literal string @client in a prop name, a default value or an import path, or that component will count as marked.


How a page is kept safe — the four layers

Worth having in one place, because each layer answers a different question and none of them is the whole answer.

Fails how
1. Build to convention frontmatter, a layout, components
2. @client, and editablePages May the client edit this page — and does this project edit page source at all? Not offered for editing at all
3. The parser Is the page the shape the model holds? View only, with the reason
4. The verifier Would saving this file keep everything? View only

Layer 1 makes a page eligible. Layer 4 is what keeps it intact.

That ordering is not the obvious one, and it was arrived at the hard way — see below. The temptation is to say “build to convention and your pages are safe.” That is not true, and saying it would put the weight on the wrong layer.

2 is structural rather than a flag anyone can forget to check: an unmarked page never produces a document, and every write path needs one. Nothing to save means nothing to damage. editablePages: false says the same thing for a whole project, so a marker arriving by accident cannot switch the path back on. What neither can do is protect a page they were right to approve.

3 refuses anything it cannot model. It is a fast path, not the guarantee — its completeness cannot be proved by reading it, and on 2026-08-27 it was wrong four times in a day.

4 is why that is survivable. Opening a page serialises it back unchanged and compares the result against what is on disk, looking for both loss and transposition. It shares no code with the parser, so it does not inherit its blind spots, and it does not need to know what went wrong — anything the parser mishandled shows up as a difference. Nothing is written, so a failure costs the client an editor they expected, never a broken site.

Run against every Astro project to hand — 31 real pages across 7 sites, most of them never built with this app in mind — 23 were refused by the parser and all 8 it accepted verified clean.

The 8 are the number that matters. The 23 are pages nobody would ever mark; the 8 are exactly the shape a developer builds when they do intend a page for a client. Before the fixes of 2026-08-27, 3 of those 8 would have been damaged.

Which is the point layers 1 to 3 cannot cover.

Those three pages were built to this convention. Hubspace’s index.astro is a layout wrapping components with nothing hand-written in it — written that way years before this app existed, because it is good Astro. The parser was right to accept it. The developer was right to mark it. Nothing about the page was wrong.

The fault was in this app’s own component cataloguing: components/hero/hero.astro imported as Hero was catalogued by its filename, dropped for not being capitalised, and the page’s <Hero /> was left with nothing behind it.

So a conforming page, correctly approved twice, would still have been broken. “Build to convention and your pages are safe” is not a true statement. The convention is what makes a page editable at all; the verifier is what makes editing it survivable. Only layer 4 was between that bug and a broken site.

Stay inside the convention and hand pages over deliberately — that is what gets a page in front of a client at all, and it keeps the other three layers working on the cases they are good at. Just do not read it as a guarantee that the file is safe. Nothing in layers 1 to 3 checks what a save would actually do to it.


Pages

A page is read-only until you mark it @client. Two things have to be true before a client can edit a page, and they answer different questions:

@client in the frontmatter should this page be theirs — your decision
The structure below can it be edited safely — the app’s decision

The marker first, because a developer told about a stray <div> would remove the <div> and still not be able to edit the page.

---
/** @client — this page is open for editing in Boosh CMS. */
import Layout from '../layouts/Layout.astro';
import Hero from '../components/Hero/Hero.astro';
---

Any comment form works — /** @client */, // @client, or @client inside a longer block comment. It is inert to Astro: a comment nothing reads, which changes nothing about the build output.

There is no fallback, and this is deliberately unlike the component rule. An unmarked project offers every component, so the app still works against a site that has never heard of the marker. Pages get the stricter treatment because the two cases are not the same shape: an unmarked component that gets offered can be removed from a page again, whereas a page opened by accident is a client editing something nobody meant them to have.

The cost, stated plainly: a project that has never used the marker has no editable pages until you add it. Bringing BooshCMS to an existing site, you open pages one at a time, deliberately. A page the app creates marks itself.

A client-editable page is then a layout wrapping component elements. Nothing else.

---
import Layout from '../layouts/Layout.astro';
import Hero from '../components/Hero/Hero.astro';
import Cards from '../components/Cards/Cards.astro';
---

<Layout title="Bramble & Co" description="Garden design and upkeep.">
  <Hero heading="Gardens that look after themselves" ctaHref="/contact" … />
  <Cards heading="What we do" cards={[…]} />
</Layout>

One line of hand-written markup makes the page View only. A <main>, a stray <h1>, a loop — any of it. This is deliberate: half-parsing a page and writing it back would corrupt your work, so the app refuses instead. Listing pages, dynamic routes and anything that assembles itself from data belong to you, and the client is told so in plain language.

[slug].astro and other dynamic routes are not shown to the client at all.

A listing page can be a client page — move the loop

“Never mark a page containing a loop” is a true rule and a dead end as written. The loop does not have to be in the page.

The demo site’s Classes page was a getCollection and a .map in pages/classes/index.astro, and its own comment said the page therefore belonged to the developer. Both halves were true; the conclusion was not. Move the fetch and the loop into a component and nothing about them changes — but the page around them becomes a stack of blocks:

---
/** @client — this page is open for editing in Boosh CMS. */
import Layout from '../../layouts/Layout.astro';
import PageHero from '../../components/PageHero/PageHero.astro';
import ClassTimetable from '../../components/ClassTimetable/ClassTimetable.astro';
import CallToAction from '../../components/CallToAction/CallToAction.astro';
---

<Layout title="Classes" description="The weekly timetable.">
  <PageHero heading="Classes" sub="Everything we run each week, and who runs it." />
  <ClassTimetable />
  <CallToAction heading="Not sure which one to start with?" … />
</Layout>

ClassTimetable calls getCollection('classes') in its own frontmatter. The client can edit the hero, put anything above or below it, and reorder the lot; the timetable stays yours, and appears in the inspector as a block with whatever props you gave it.

Give it a prop or two while you are there. label, heading, limit, variant — the loop stays the developer’s, while what it is called and how much of it shows becomes the client’s. A block with no props is offered as “This block has nothing to fill in”, which works but wastes the opportunity.

The same page also shows the other half: the block may already exist. What people say rebuilt to PageHero + Testimonials + FAQAccordion + CallToAction, all four already in the site, and needed no new component at all. A page that assembles itself from two collections is often two blocks that each assemble themselves from one.

What stays out of reach is a page whose shape is the data — a paginated archive, a dynamic route, anything where the page itself is generated. Those are yours, and [slug].astro is never offered to the client anyway.

The structural test, in one line

Once a page is marked, this is what decides whether the app will touch it.

Can the model hold the whole file? The model is a layout, an ordered list of components, and their props. When that is everything the file contains, the app can discard the file and rebuild it, and nothing is lost — so the client gets the full page builder: add, reorder, remove, rename, edit. One <div> and the answer is no, so it will not touch the page at all.

There is no middle setting, and that is on purpose. A writer that edited part of a file it only partly understood is the one failure mode that would put a client’s mistake somewhere the client cannot see it.

Inside the rule you have more room than it first sounds:

Components nested inside components fine
Other code in the frontmatter — a const, an import, a side-effect import fine, preserved verbatim
A prop whose value is an expression fine — shown to the client, locked
No layout at all, just components fine
A comment above a block fine — it becomes the block’s name

Both of Astro’s comment syntaxes are read — <!-- … --> and {/* … */}. The app writes the first, so a page saved from it comes back with its comments in that form.

And four things it refuses, which are easy to write by accident:

<Hero {...rest} /> spreading props onto a block
<Hero heading=hello /> an unquoted value
import Hero, { helper } from '…' a block imported alongside something else
a block outside the layout <Layout>…</Layout> with a sibling beside it

All four are refused rather than half-read, and the page says which. A spread is the one worth knowing about: the app cannot see what is inside rest, so it cannot show it, keep it, or write it back.

The third looks pedantic and is not. The app regenerates the import line for every block on the page, so a line that declares a block and something else can be neither dropped nor kept — dropping loses helper, keeping declares Hero twice. Give a block its own import line and the page is fine.

The fourth is the same fault seen from another angle: a layout is modelled as the wrapper, so a layout standing beside a block is not something the model holds — its import would be dropped and never put back.

The rule underneath all four: nothing is ever half-modelled. Every part of the file is either the app’s to regenerate or the developer’s to keep, and anything that would be both is refused. That is what makes the list above short and safe to extend, rather than a list of everything that could go wrong.

Everything else about the page is yours. This is a build convention, and whether to stay inside it is your call on any given page — the app states its answer in plain language either way.

Locking a prop

A prop whose value is an expression is preserved verbatim and shown to the client as read-only:

<Reviews items={reviews} heading="What people say" />

The client can edit heading and cannot touch items. This is how you wire a block to a collection: they edit the reviews in one place, and the block picks them up wherever it is placed.

A list or an object written out in the page is the opposite case — it is content, so the client gets an editor for it:

<Features items={[{ icon: 'bolt', title: 'Fast' }, { icon: 'chart', title: 'Measured' }]} />

Your formatting is kept. The app writes the property back exactly as you wrote it unless the client actually changes something in it, at which point it is re-written from the new value. So opening a page never reformats it, and a page that says a thing one way does not come back saying it another.

An expression it cannot evaluate — a name, a call, a map over data — takes the locked path above instead. If you want a list kept out of the client’s hands, give it a name in the frontmatter and pass the name.


Page settings and SEO

Page-level metadata belongs on the layout, not in a block. It renders into <head>, which a block in the <body> cannot reach.

Declare it as props on the layout and it appears to the client as Page settings whenever no block is selected:

---
interface Props {
  /** Shown in the browser tab and in search results. */
  title: string;
  /** The sentence search engines and social cards show under the title. */
  description?: string;
  /** Picture used when the page is shared on social media. */
  socialImage?: string;
  /** Hide this page from search engines. */
  noindex?: boolean;
  /** Structured data, wired up by your developer. */
  jsonLd?: object;
}
---

The same type rules apply, so socialImage gets a picture picker and noindex a checkbox. Structured data is best passed as an expression, which locks it.

Every layout prop is read and written back — a client editing the title cannot destroy the rest.

SEO on a blog post is different

A post is not a page: the client never opens [slug].astro, so its layout props are not theirs to fill in. Post metadata belongs in the collection schema, alongside the title and the body — and it then appears in the Inspector like any other field:

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      description: z.string(),          // doubles as the meta description
      pubDate: z.coerce.date(),
      cover: image().optional(),        // doubles as the social image
      socialImage: image().optional(),  // …or a separate one, if you prefer
      noindex: z.boolean().default(false),
      canonicalUrl: z.string().url().optional(),
    }),
})

The template is where you join them up — the post’s own fields are passed to the layout, and the layout puts them in <head>:

---
const { post } = Astro.props;
---

<Layout
  title={post.data.title}
  description={post.data.description}
  socialImage={post.data.socialImage ?? post.data.cover}
  noindex={post.data.noindex}
  jsonLd={articleSchema(post)}
>

So there is no SEO component in either case. For a page, the fields live on the layout; for a post, they live in the collection schema. Structured data is best built in the template from fields the client already fills in — a title, a date, an author — rather than asked of them as JSON.


Images

Two path shapes, and they are not interchangeable.

Collection image() fields get a path relative to the entry file, because that is what Astro resolves:

cover: ../../assets/van.webp

Component props get a project-relative string, which you resolve yourself. The demo site uses an eager glob, which gives Astro the metadata it needs to emit optimised output:

const assets = import.meta.glob<{ default: ImageMetadata }>(
  '/src/assets/**/*.{webp,avif,png,jpg,jpeg,svg}', { eager: true },
);
const asset = assets[imageSrc.startsWith('/') ? imageSrc : `/${imageSrc}`]?.default;

The app writes the right shape for the context. Images are resized and re-encoded to WebP on the way in, so what reaches the repo is already small.


Preview

The app runs astro dev itself, reading the port from its output. It runs the CLI under Electron’s bundled Node, so the client’s machine does not need Node installed — but it cannot conjure node_modules. If those are missing and npm is unavailable, preview reports itself unavailable and editing carries on regardless. Preview is never allowed to block the actual job.


studio.config.json

Optional, committed at the repo root. A site without it works exactly as before.

{
  "name": "Bramble & Co",
  "editablePages": false,
  "publish": {
    "mode": "review",
    "branch": "content-updates",
    "note": "Chris checks everything before it goes live."
  }
}
name Shown instead of the folder name
editablePages false turns off .astro page-source editing for the whole project. Absent means true
publish.mode direct — straight to the live site. review — to a branch you merge
publish.branch Branch used in review mode. Default content-updates
publish.note Shown to the client when they publish, so they know what happens next

Set it on any project that composes its pages as collection entries, which is the recommended convention and therefore most of them.

Editing an .astro page rewrites the file from a model of it. That is the only mechanism in this app that has ever lost a developer’s work, and a project built the recommended way never needs it. Turning it off means the code cannot run on your repository at all.

@client already keeps an unmarked page out of the client’s hands, and for a page you never mark that is enough. This is for a marker arriving by accident — a copied file, a generated page, another tool working in the same repo. A habit does not survive those; a line in a committed file does.

What it changes: pages are still listed and still previewable, always View only, and the client is told “This website is set up so that pages are not edited here.” Collections are untouched. Opening, saving, creating, renaming and deleting a page are all refused — the save gate matters most, and deleting had no gate at all before this.

Only a literal false turns it off. "false" as a string, or a typo, leaves pages editable and reports a warning, because silently disabling editing would look like a bug rather than a setting.

In review mode the Publish button reads Send for review, and the client is told their developer puts it live. Their work is committed on the branch and pushed there; the branch is reused, so a second publish adds to the same review rather than starting another one.

The file is how the app recognises your sites. After signing in, a client is shown repositories containing studio.config.json on their own, under the name you give it — so they see “Bramble & Co”, not “acme-client-site-v2”. Everything else they can reach is tucked behind a “show everything” link. It does not list which components are blocks — that stays as the @client marker, where it cannot go stale.

Checking your work before the client sees it

node --import ./scripts/ts-resolve.mjs scripts/check-content.ts /path/to/site

Renaming a prop does not lose the client’s content — the value stays in the page file — but no field renders for it, so it becomes unreachable and the page renders wrong. You caused it; the client should not be the one to find it.

The check reports pages whose settings no longer match their component, suggesting what you probably renamed it to; blocks whose component has gone; and entries that would fail their own schema. It exits non-zero, so it works on a pre-commit hook or in CI.

  ✗ src/pages/index.astro
      <Hero> has "heading", which the component no longer declares.
      Value: "Gardens that look after themselves"
      Did you rename it to: title?

If one slips through anyway, the client is not stuck: the block shows the orphaned value with Use as title and Discard.

Setting up a client’s repository

Either topology works, and the second is the better story:

You own the repository. Add the client as an outside collaborator on that one repository, with write access. Simple, and you keep continuity.

The client owns the repository. Their account owns the site, you are a collaborator. Handover is already done and they can revoke you — which is the product’s own pitch made literal.

Whichever you choose:

  1. One repository per client site. Access is granted per repository, so two clients in one repository can both reach all of it.
  2. Write access, not admin. Write is enough to publish; admin lets them change settings and delete the repository.
  3. If you use an organisation, set base permissions to No permission (Organisation → Settings → Member privileges) and grant per repository. The default is more permissive than most people expect — this is the setting that catches people out.
  4. Keep secrets out of the repository. The client can read every file in it. Form endpoints and API keys belong in your host’s environment variables.

A client signing in reaches exactly the repositories their account has been given — nothing more. Somebody editing several sites sees all of them, which is intended. Security has the detail.

A worked example

fixtures/demo-site in this repository is a complete site built to this contract: two collections, five components, block-built home and about pages, a journal with a listing and post template, and an SEO-bearing layout. It builds to seven pages. Copy it as a starting point.