Content files
Content is YAML in your repository. The folder structure mirrors your App Router segments, so finding the file behind a page never requires a lookup table.
Folder layout
Both flat and nested forms work — pick what suits the project:
content/
_index.yml → /
contact.yml → /contact (flat)
about/
_index.yml → /about (nested)
team.yml → /about/team
_global.yml → scope="_global"
_collections/
posts/
hello-world.yml
about.yml and about/_index.yml both existing is a build-time error, not
a silent preference — copy-ink check reports it as ambiguous-content-file.
Both .yml and .yaml are read; only .yml is written.
The path a route maps to is its scope. /about/team has scope
about/team; the home route has scope ''. _global is a scope like any
other, just not tied to a route — a good home for a company name, phone number
or address used across the site.
YAML shape
meta:
title: "About us"
description: "Who we are and what we build."
heroTitle: "Hello!"
hero:
subtitle: "We make things."
aboutImg:
_type: image
src: "about.png" # relative → public/content/about/about.png
alt: "The team at work"
width: 1200
height: 800
Three things are going on there:
- Scalars are fields.
heroTitleis read as<Copy field="heroTitle" />. - Plain mappings are namespaces.
hero.subtitleis a dot path intohero. Nest as deep as you like. - A mapping carrying
_typeis a typed node, at any depth._type: imagemakesaboutImgan image rather than a namespace containingsrcandalt.
Comments survive edits
Writes go through the YAML Document API rather than a parse-and-serialise round trip, so this:
# Kept short deliberately — the hero is 2 lines on mobile.
heroTitle: "Hello!"
still has its comment after the client's first save. Anything you write to explain a field to your future self stays put.
Naming rules
| Rule | Enforced by |
|---|---|
| Keys are camelCase | copy-ink check |
. is forbidden in key names | it is the path separator |
meta is reserved at the root | always, see below |
Keys beginning with _ are reserved | _type, _draft, _order, _locked |
meta is reserved whether or not autoMetadata is on, so
turning the flag on later cannot silently break a field you wrote.
Metadata
The meta: block feeds generateMetadata:
meta:
title: "About us"
description: "Who we are and what we build."
See Page metadata for how to consume it.
Locking a field
Some copy should be readable by code but untouchable by a client — legal notices, pricing, a registered company number.
A page locks individual paths with a root-level list:
_locked:
- legalNotice
legalNotice: "All rights reserved."
A collection field locks in the config instead:
fields: {
price: { type: 'text', editable: false },
}
Either way the value stays fully readable by <Copy> and the server API; it is
simply not editable in the editor.
Missing fields
A field referenced in JSX but absent from the YAML is not an error by default.
missingField decides what happens:
| Value | Production render | Editor render |
|---|---|---|
'empty' (default) | nothing | labelled placeholder |
'key' | the field path | labelled placeholder |
'error' | throws | throws |
A labelled placeholder matters: the client needs something to click before the field has a value.
copy-ink check reports fields that JSX references but no file defines
(missing-key), and keys that no JSX reads (dead keys). It reads the static
manifest rather than runtime registrations, so a field inside a branch that
never renders still counts.
Validating
npx copy-ink check
Naming, reserved words, both-file-forms, missing and dead keys, and locale collisions, in one pass. It exits non-zero on errors, so it drops straight into CI.