One Content Contract Across System Boundaries
Problem
The portfolio spans more than one deployable. Sanity Studio defines the editing interface. The Sanity Dataset stores published documents. Snapshot Query projects those documents. The Astro site renders them. Each boundary can describe the same field differently, and drift can turn a valid editor action into a failed site build.
A shared schema package seems like the obvious answer, but it can create the wrong owner. If Studio owns the package, the editor interface can introduce a field before the portfolio can render it. If the query owns it, a fetch strategy can dictate the public model. If the package contains all behavior, every consumer becomes coupled to unrelated changes.
I needed one Content Contract without one shared implementation. A Content Contract is the complete shape that the public site knows how to read.
Decision
I made the portfolio the authority for the Content Contract. Field rollout is portfolio-first. The public site understands a field before Studio exposes it and before editors publish values for it.
The contract lives in the workspace package at packages/portfolio-cms-package. The package name is @ssuish/portfolio-cms-package when published. The site and Snapshot Query use the workspace package. The separate Studio repository pins a published package version.
The package exports TypeScript types, Zod validators, parsing helpers, Snapshot envelope types, and closed enums. Zod is a runtime validation library that parses unknown data. The package does not export GROQ. Query behavior belongs to Snapshot Query in portfolio-publish-gate/snapshot-query.
Implementation
The root contentSchema defines the document that the public site accepts. It joins site data, navigation, social links, page calls to action, the CV URL, contact copy, certifications, blogs, projects, services, About content, and page chrome. Its object schemas are strict, so unknown fields do not pass silently.
The package also defines snapshotEnvelopeSchema. The envelope carries contractVersion, capturedAt, and validated content. Both the generator and site can call parseSnapshotEnvelope() at an external boundary. TypeScript types guide code after parsing, while Zod checks the runtime JSON that TypeScript cannot trust.
Route Keys are part of the Contract. A Route Key is a stable code-owned identifier for an internal destination. Content can store a label with services, blogs, or contactProject, but it cannot invent an internal path. ROUTE_KEYS and routeKeySchema keep that set closed. Social icon names and fixed call-to-action keys use the same Contract Enum pattern.
This division gives the package a narrow job. It answers, "Is this value Content that the portfolio understands?" It does not answer, "How do I fetch this value from Sanity?" Snapshot Query owns that second question.
packages/portfolio-publish-gate/src/content.ts contains the GROQ projection and normalization. It can change how documents join, how asset metadata is selected, or how legacy fields disappear without changing the contract package. The query output must still pass parseContent().
The Astro site has another adapter after Contract validation. createDisplayContentFromContent() converts valid Contract Content into Display Content. Display Content is the render-ready page view. It resolves routes and media, so these frontend details do not enter the shared package.
Tests protect each part. Contract tests reject invalid Route Keys and malformed Content. no-groq.test.ts makes sure that the shared package does not acquire query text or Sanity query dependencies. Snapshot Query tests compare projected output with the Contract. Integration tests make sure that the site consumes the workspace package.
The package also supports independent release timing. A Contract change starts in the portfolio repository, beside the consumer that defines its meaning. After review and release, Studio can update its pinned package and expose matching fields. This order prevents a Studio-first rollout.
Rejected Options
I rejected hand-copied validators in the site, query package, and Studio. Copies start equal and then drift. TypeScript alone also failed the boundary because Snapshot JSON is unknown data at runtime.
I rejected Studio ownership of the shared package. Studio defines authoring forms, but it does not define what the public site can render. The editor follows the site contract and does not lead it.
I rejected GROQ inside portfolio-cms-package. A query change can preserve the same output shape. Contract consumers do not need a new package version for that internal fetch change. The shared package also stays independent from Sanity query strategy.
I rejected frontend types such as OptimizedImage in the Contract. An image reference with crop and hotspot data is Content. A generated srcSet and sizing recipe are display concerns. A shared display type gives the Contract ownership of frontend preparation.
Accepted Tradeoffs
Portfolio-first rollout requires coordination. A field can take several steps from Contract change to package release, Studio schema update, Content entry, and site use. That sequence is slower than adding an untyped field directly in Studio.
The repository also carries two related models. Content describes validated shared input. Display Content describes render-ready output. Contributors must know which side of the adapter they are changing.
Package publication adds version work for the separate Studio repository. The monorepo consumers use workspace:*, but Studio needs a released version. This cost buys an explicit handoff between independently deployed systems.
Lesson
A shared contract does not require shared ownership of every operation. The contract can define accepted facts while each system owns its behavior. Snapshot Query owns projection. Studio owns authoring. Astro owns display preparation. The portfolio owns the shape because it bears the rendering obligation.
Runtime validation and static types also solve different problems. Types help developers inside compiled code. Zod protects the places where JSON crosses a system boundary.
Next-Project Rule
For my next multi-system content project, I will assign one consumer as the contract authority. I will share types and runtime validators, but I will keep queries, transport, and display models with their owners. I will define closed identifiers for code-owned choices. I will also make rollout order explicit before the first cross-repository field change.