How it works
copy-ink has three modes of operation, and the whole design follows from keeping them apart.
A visitor's request
request ──▶ middleware stamps x-copy-ink-path
──▶ Server Component renders <Copy field="heroTitle" />
──▶ runtime resolves scope from the path
──▶ ContentStore reads content/<locale>/<scope>.yml
──▶ React receives the string "Hello!"
The rendered HTML contains Hello! — no wrapper element, no data- attribute,
no class. <CopyInkScripts /> renders nothing. Not one byte of the editor is in
the response.
Content lives on the filesystem, so none of this makes a network call. The store caches parsed documents per process and invalidates after a write.
An editor's request
The client visits /admin, signs in, and the route handler sets an httpOnly
session cookie. From then on:
request ──▶ same rendering path as above
──▶ runtime sees a session, so components also emit editing anchors
──▶ <CopyInkScripts /> renders a loader
──▶ loader dynamically imports the editor chunk
An anchor is a <span> carrying the field's identity:
<span data-copy-field="heroTitle" data-copy-scope="about">Hello!</span>
The editor uses those anchors to know what is clickable and what each click edits. They only exist in editor mode, so they cost a visitor nothing.
The test suite contains an import-graph assertion that the editor never enters the visitor bundle. That single test guards the whole premise of the library.
A publish
Edits accumulate client-side for the whole session as a changeset, not as individual saves. Editing a value back to its original drops the change entirely, so the "3 changes" badge never overstates.
Publishing posts the changeset to the route handler, which:
- Requires a session and re-checks it against
auth.allowlist. - Turns the changes into file writes, round-tripping each YAML document so comments and formatting survive.
- Asks the backend to commit them — one commit, with the base SHA attached.
- Invalidates the content cache.
If the branch moved since the editor loaded, the write is rejected with a
STALE_BASE error rather than merged. There is no merging in v1.
The commit triggers your normal deployment. That is the point: there is no runtime that has to know about the change, because the change is the source.
Why deploy latency is a feature, not a bug
Content becomes live when the site redeploys — commit, rebuild, deploy, usually one to three minutes. The editor says "Published — live in about two minutes." for exactly that reason. Unexplained, that gap reads as broken.
In exchange, the site has no database, no CMS process, no cache to invalidate,
no admin backup to take, and a full content history in git log.
The pieces
| Module | Entry point | Runs |
|---|---|---|
| Components | copy-ink | Server (and useCopy on the client) |
| Server API | copy-ink/server | Server only |
| Client hook | copy-ink/client | Client only |
| Config | copy-ink/config | Build and server |
| Middleware | copy-ink/middleware | Edge |
| Sign-in page | copy-ink/admin | Server |
| Editor | copy-ink/editor | Client, lazily, only with a session |
copy-ink/editor is never imported by your code — the loader pulls it in.