Skip to main content

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 boundary is tested

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:

  1. Requires a session and re-checks it against auth.allowlist.
  2. Turns the changes into file writes, round-tripping each YAML document so comments and formatting survive.
  3. Asks the backend to commit them — one commit, with the base SHA attached.
  4. 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

ModuleEntry pointRuns
Componentscopy-inkServer (and useCopy on the client)
Server APIcopy-ink/serverServer only
Client hookcopy-ink/clientClient only
Configcopy-ink/configBuild and server
Middlewarecopy-ink/middlewareEdge
Sign-in pagecopy-ink/adminServer
Editorcopy-ink/editorClient, lazily, only with a session

copy-ink/editor is never imported by your code — the loader pulls it in.