Skip to main content

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

content/about/_index.yml
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. heroTitle is read as <Copy field="heroTitle" />.
  • Plain mappings are namespaces. hero.subtitle is a dot path into hero. Nest as deep as you like.
  • A mapping carrying _type is a typed node, at any depth. _type: image makes aboutImg an image rather than a namespace containing src and alt.

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

RuleEnforced by
Keys are camelCasecopy-ink check
. is forbidden in key namesit is the path separator
meta is reserved at the rootalways, 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:

ValueProduction renderEditor render
'empty' (default)nothinglabelled placeholder
'key'the field pathlabelled placeholder
'error'throwsthrows

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.