Versioning Shared Email Components Across Teams
Release email components without shipping surprise visual changes: a stricter semver reading, snapshot gates, deprecation windows, and coordinated upgrades.
A shared component library only works if consumers upgrade, and consumers only upgrade if upgrades are safe. This guide covers the release discipline that keeps both true for email components: a stricter reading of semantic versioning, the gates that make a release reviewable, and deprecation windows that let several teams move at their own pace.
Root Cause: Email Has No Rollback
Web libraries can ship an unintended visual change and correct it within the hour; the next page load shows the fix. Email cannot. Once a message is delivered it is a static artefact in someone's mailbox, and a broken button in fifty thousand receipts stays broken.
That asymmetry changes what a version number has to communicate. In a web library, semantic versioning describes API compatibility: will my code still compile and run. In an email component library it must also describe rendered compatibility: will my messages still look the same. A change can be perfectly compatible at the API level — same props, same types — and still alter every button in production.
The failure this produces is specific and common. A well-meaning patch release adjusts a padding value; a consumer's dependency bot takes it automatically; the change reaches inboxes with no human having looked at the rendered result. Nobody did anything wrong under the usual rules, which is exactly why the rules need to be different here.
The Exact Rules
Two questions decide the bump, asked in order.
Does any consumer's code stop working? A removed prop, a renamed export, a changed required type — major, under ordinary semantic versioning.
Does any rendered pixel change? If yes, also major, even when the API is untouched. If no, then a fix is a patch and an addition is a minor.
| Change | Bump | Rendered effect |
|---|---|---|
| New optional prop with a default matching current behaviour | Minor | None |
| New component | Minor | None |
| Internal refactor, byte-identical output | Patch | None |
| Outlook conditional added, output unchanged in other clients | Patch | None in the clients that already worked |
| Padding, colour, radius or size changed | Major | Visible in every message |
| Dark-mode value changed | Major | Visible to a subset, unreviewed by most |
| Required prop added | Major | Build breaks |
| Component removed after deprecation | Major | Build breaks |
The fourth row is the subtle one. Adding an Outlook fix that only affects clients where the component was already broken changes rendered output — but only from "wrong" to "right", and only in clients no consumer was relying on. That is a patch, and treating it as major would discourage exactly the fixes the library exists to distribute.
The Gate That Makes This Enforceable
Classification by judgement decays. A snapshot suite inside the library turns it into a mechanical check: if the compiled output changed, the reviewer is shown the diff and must decide the bump deliberately.
// components/__tests__/button.test.js — snapshot the COMPILED output, since
// that is what a client receives. A diff here means a version decision.
import { render } from "@react-email/render";
import { Button } from "../Button.jsx";
test("button renders identical markup for identical props", async () => {
const html = await render(
<Button href="https://example.com/confirm">Confirm your order</Button>,
{ pretty: false },
);
// Any change to this snapshot is a rendered change, and therefore a major
// version — the test does not decide that, but it makes it impossible to
// ship the change without noticing.
expect(html).toMatchSnapshot();
});
test("button clears the 44px minimum touch target", async () => {
const html = await render(<Button href="#">Confirm</Button>, { pretty: false });
// Guard the properties that carry a requirement rather than a preference,
// so a refactor cannot quietly drop them.
expect(html).toMatch(/border:\s*14px solid/);
expect(html).toMatch(/line-height:\s*24px/);
expect(html).toMatch(/mso-line-height-rule:\s*exactly/);
});
Snapshotting compiled output rather than a component tree is the decision that makes this work. A tree snapshot passes when the styling changes; the compiled string is what a client actually parses, so it is the only representation where "rendered change" is a well-defined concept.
Deprecation Windows Across Teams
Several teams on different release cadences cannot all upgrade at once, so removal needs a window rather than a moment.
Announce the replacement in a minor release, keeping the old component working and adding a build-time warning that names the successor. Leave it in place for at least two of the slowest consumer's release cycles — in practice a quarter is a reasonable default. Only then remove it in a major.
The warning matters more than the timeline. A deprecation nobody sees is a removal with extra steps, and the place a consumer will actually notice is their own build output rather than a changelog they did not read.
// Deprecation that surfaces where the consumer will see it: their build log.
export function LegacyButton(props) {
if (process.env.NODE_ENV !== "production") {
console.warn(
"[@acme/email-components] LegacyButton is deprecated and will be removed " +
"in 5.0.0. Use <Button> — it takes the same props plus `width`.",
);
}
return <Button {...props} />; // keep it working; do not merely warn
}
Coordinating an Upgrade Across Several Teams
A major version is only useful if consumers actually take it, and the failure mode of a strict versioning policy is a library whose latest release nobody is on. Three practices keep adoption moving.
Publish the upgrade cost with the release. A major version note that says what changed visually, which components are affected, and roughly how long an upgrade takes lets a team schedule it. Without that, a major bump reads as an unbounded task and is deferred indefinitely — which is how an organisation ends up with four teams on four majors.
Provide a codemod where the change is mechanical. A renamed prop or a moved import can be automated, and shipping the transformation alongside the release turns a twenty-minute task into a two-minute one. Where the change is genuinely visual it cannot be automated, but the mechanical half usually can be, and separating them makes the remaining work visibly small.
Set an adoption window rather than a deadline. "Everyone on 5.x within a quarter" is achievable and reviewable; "upgrade immediately" is neither, and it is ignored. Tracking which consumer is on which version — a simple scheduled query against each repository's lockfile — makes the drift visible before it becomes three majors deep.
The version-skew problem compounds quietly. Two teams on different majors send visibly different buttons from the same brand, and the person who notices is usually a customer comparing two emails. That is the outcome the whole apparatus exists to prevent, so treating adoption as part of the release rather than as the consumer's problem is the difference between a library that works and one that merely exists.
Pipeline Integration
Publish from CI on a tag, and require the snapshot suite to pass before publishing. Where the library covers components that carry client workarounds, add a client render check on the library's own example templates before release — the consuming services should not be the first place an Outlook regression appears.
On the consumer side, pin exact versions and schedule upgrades rather than automating them. A monthly upgrade window where someone reviews the visual diff is more valuable than a bot that keeps every service current and occasionally ships a changed button to production.
Validation and Deployment Checklist
Frequently Asked Questions
Does treating pixels as breaking mean constant major versions?
Initially yes, and that is a signal rather than a problem — a library producing frequent major versions is one whose visual decisions are still settling. The rate falls sharply once the components stabilise, and by then the discipline is established. If it does not fall, the components are being used as a styling utility rather than as a design decision, which is a different problem to fix.
How do we handle an urgent fix that also changes rendering?
Ship it as a major and communicate it directly rather than relying on the changelog. An urgent Outlook fix that also shifts spacing is genuinely two changes; where possible, split them so the urgent correction can be taken as a patch and the spacing change follows separately.
Should the library support more than one major version at a time?
Only for security or client-breakage fixes, and only for one previous major. Backporting rendering changes to an older line means maintaining two visual systems, which is the situation the shared library was created to end.
What if a consumer refuses to upgrade?
Find out why before escalating. The usual reasons are a local override that a newer version conflicts with, or an upgrade that previously broke something and was never diagnosed. Both are library problems wearing a consumer's clothes, and both are more cheaply fixed than argued about.
Do we need a changelog if the diff is in git?
Yes, because the audience is different. The git diff answers "what changed in the code"; the changelog answers "what will my recipients see differently, and must I look at anything before taking this". A consumer deciding whether to upgrade before a send needs the second, and reconstructing it from a diff is exactly the work the changelog exists to save.
Related
- Email Design Tokens and Component Libraries — the library this release process governs
- Publishing Email Design Tokens as an npm Package — the same discipline applied to the token layer
- Automated Snapshot Testing — the gate that makes a rendered change impossible to miss
- Testing React Email Components With Vitest — the test harness these snapshots run in