Keeping multi-repo projects in sync: how to stop UI, API and docs from drifting apart

Most non-trivial projects end up split across several repositories: one for the frontend, one for the backend (or several backends), one for the documentation, and sometimes one for an SDK. This split is great for autonomy – each team ships on its own cadence – but it has a well-known dark side.

Every time you cut a release, the question comes up: are these things actually compatible with each other right now?

  • Does the UI call endpoints that don’t exist yet on the server?
  • Did the server change the response shape that the UI still expects?
  • Do the docs describe features that aren’t shipped, or missing features that are?

In our Landscape work, we hit this often enough that we started treating it as a real engineering problem instead of “be careful when merging.” This post walks through the patterns we’ve been exploring, ordered from least to most automated. Pick the one that matches where your team is today – you can always level up later.

One extra wrinkle worth naming up front: not every repo in a project has the same visibility. It’s common to have a public UI and public docs alongside a private server (this is our setup), or any other mix. That asymmetry rules some sync options in and others out, so we’ll flag it on each option.

The problem, in one picture

Three repos, three independent merge timelines, one shared release. Without a sync mechanism, the failure mode is always the same: a bug shows up in production, but not in CI.


Option 1: The integration manifest

The cheapest thing you can do is write down what “compatible” means in a file.

A manifest file lives somewhere central – usually in the UI repo, or in a small dedicated “release” repo – and pins each component to an exact commit or version:

# release.yaml
release: 2026.04.0-beta
components:
  ui:
    repo: your-org/web-ui
    ref: a1b2c3d
  server:
    repo: your-org/api-server
    ref: 8e9f0a1
  docs:
    repo: your-org/docs
    ref: 5c6d7e8

The rule becomes: a release is not the union of main branches at a random point in time. A release is whatever the manifest points to.

How the workflow changes

The manifest PR is the synchronization point. You merge to main in each repo whenever you want, but nothing reaches users until someone bumps the manifest – and that bump is a single, reviewable PR.

Pros and cons

  • :white_check_mark: Very little new infrastructure. It’s a YAML file and a CI step.
  • :white_check_mark: Works across public and private repos (the manifest just stores SHAs).
  • :white_check_mark: Easy to roll back: revert the manifest, redeploy.
  • :cross_mark: Doesn’t catch what broke. It tells you “these two SHAs are pinned together” but not “the UI calls /foo and the server doesn’t serve it.”
  • :cross_mark: Still relies on humans to remember to update the manifest.

This is a great starting point. It establishes the habit of thinking “what version of X do I need?” before you can move to anything fancier.


Option 2: Generate clients from a contract + version handshake

This is the option we’ve put the most thought into. The idea: stop hand-writing TypeScript types that describe the server, and instead generate them from a machine-readable contract that the server publishes itself.

The setup

The server already has an OpenAPI spec (or you can write one – it’s less work than it sounds). With every release, it publishes that spec as a versioned artifact. A simple GitHub Release works:

https://github.com/your-org/api-schemas/releases/download/v2.3.0/openapi.yaml

The UI pins the spec version it was built against and runs a codegen step at build time:

// api-versions.json in the UI repo
{
  "server": "2.3.0",
  "auth-service": "1.4.2"
}
# Build step
pnpm run codegen   # fetches the pinned spec, generates src/api/generated/
pnpm tsc --noEmit  # fails the build if UI code references a field that no longer exists

That’s the compile-time half. Now if a backend ships a breaking change, the UI build cannot pass once someone bumps the spec version. The mismatch surfaces in the PR that does the bump, not in production.

The runtime half

Compile-time checks don’t cover one case: someone deploys an old UI build against a newer server, or vice versa. For that we add a tiny version endpoint:

GET /api/version
{
  "service": "api-server",
  "version": "2.3.0",
  "api_versions": {
    "rest_v2": "2.3.0"
  },
  "min_supported_client": "2.1.0"
}

The UI calls this on boot and compares it against constants baked in at build time:

The rule we use:

  • Same major version → compatible.
  • Server minor ≥ UI’s expected minor → compatible (the server has at least what the UI needs).
  • UI version ≥ min_supported_client → server still supports this UI.

Any other case shows a hard error page instead of a broken app shell. Operators see a clear message; users don’t see a half-working UI.

Why this is nice

Two independent safety nets: one at build time (you can’t ship a UI that contradicts the spec it was built against), one at runtime (you can’t serve an incompatible UI/server pair without telling the operator).

  • :white_check_mark: Catches breakage before users do.
  • :white_check_mark: Kills hand-written types – fewer drift sources.
  • :white_check_mark: The spec is a public artifact, so SDKs and docs can consume it too (see Option 4 below).
  • :cross_mark: Requires a real OpenAPI spec on the server side. If you don’t have one, this is a meaningful project.
  • :cross_mark: Doesn’t catch behavioral drift – only shape drift. The endpoint exists with the right fields, but does it actually do what the UI expects?

That last point is what the next option exists for.

Visibility note: public UI, private server

This is the case worth pausing on, because it’s ours and it’s probably yours too. The UI repo is public; the server source is private. How does a public UI’s CI fetch a contract from a private server without secrets in fork PRs?

The trick is to publish the spec to a separate public artifact, not from the server repo directly. The server release pipeline pushes openapi.yaml to a dedicated public mirror repo (e.g. your-org/api-schemas) as a GitHub Release. The server source stays private — only the spec crosses the boundary. The UI’s codegen step then just hits a public URL with no auth at all, which means fork PRs work the same as internal ones. No GitHub App, no token rotation, no pull_request_target workaround.

The spec itself is something you’d expose to API users anyway – it’s the API’s public surface – so publishing it doesn’t leak anything the server didn’t already mean to share.


Option 3: Consumer-driven contract testing with Pact

The most rigorous option. The UI defines, in test form, exactly what it sends to the API and what it expects back. These tests generate a contract (a Pact file). The server then replays that contract against its actual implementation to prove that it can satisfy it.

Before a release goes out, the UI pipeline runs can-i-deploy. If the broker doesn’t have a green entry for “this UI version against the server version currently running in the target environment,” the deployment is blocked.

When this earns its keep

  • :white_check_mark: Catches behavioral contract breaks, not just shape changes.
  • :white_check_mark: Works across languages — Pact has bindings for TS, Python, Go, Java, etc.
  • :white_check_mark: The broker becomes the source of truth for “what’s safe to deploy where.”
  • :cross_mark: Real infrastructure to run: a broker, webhooks, verification jobs in every repo.
  • :cross_mark: Tests have to be maintained – they’re not free.

We treat Pact as a long-term goal, not a starting point. If you’re already doing Option 2, Pact is what you reach for when “shape correct but behavior wrong” becomes the dominant failure mode.

Visibility note: Pact needs a broker that both repos can talk to. With a public UI and a private server, your broker has to be reachable from both – either a hosted service like PactFlow, or a self-hosted broker behind auth that both pipelines have credentials for. This is the option where the public/private split actually costs you something operational.


Option 4: Don’t forget docs

Docs drift the same way code does, and they’re often the most visible failure to users – the API works, but the page describing it is wrong.

Two cheap things that help a lot:

Generate reference docs from the same spec. If the server publishes openapi.yaml for the UI to consume, the docs site can render that spec into reference pages. One source of truth, zero hand-sync.

Pin the docs repo in the same manifest (Option 1). If your release manifest already pins UI and server SHAs, add the docs SHA too. The release notes write themselves: “this release ships UI X, server Y, docs Z.”

One artifact, four consumers. This is the real long-term payoff of having a published spec – the UI-server sync problem was just the most painful symptom.


How to pick

In practice, most teams should start with Option 1 (it’s an afternoon), get to Option 2 within a quarter (it’s a real project but pays for itself fast), and only consider Option 3 if breakage keeps happening despite shape-level correctness.

Takeaway

Multi-repo projects don’t need a monorepo to feel like one. The trick is to give each repo something machine-readable to point at:

  1. A manifest that says which versions go together.
  2. A published contract that the UI and docs both consume.
  3. A runtime handshake that catches what compile-time checks can’t.

Any one of these is a step up from “be careful when merging.” All three together get you to a place where drift is prevented, not discovered.

If you’ve solved this differently in your project, I’d love to hear about it – drop a reply.

1 Like