Publishing Email Design Tokens as an npm Package
Ship email tokens as a versioned package: one source of truth, generated outputs for JSX and markup engines, export maps, and a release process that cannot drift.
Sharing tokens by copying a colour file between repositories works until the second repository edits it. This guide covers packaging the token set so every consumer resolves the same values from one artefact, with generated outputs for both JSX and markup engines and a release process that makes drift impossible rather than merely discouraged.
Root Cause: Shared Constants Without a Shared Artefact
Three sharing mechanisms are commonly tried before packaging, and each fails in a specific way.
Copying the file puts a mutable copy in each repository. It works on day one and diverges the first time someone fixes a colour locally rather than upstream. Nothing detects the divergence, because both copies are valid code.
A git submodule or a monorepo path removes the copy but not the ambiguity: every consumer tracks the same moving reference, so a change lands in all of them at once, without any consumer choosing to take it. That is the opposite problem — a colour adjustment intended for one team's next release ships into another team's already-frozen release.
A shared internal service that serves tokens over HTTP introduces a runtime dependency into a build step, which means a build can now fail because a network call failed, and two builds of the same commit can produce different output.
A versioned package solves all three: the values are immutable once published, consumers choose when to adopt a version, and resolution happens at install time rather than at build or run time.
The Exact Package Shape
One source file, several generated outputs, and an export map that gives each consumer the shape it needs.
{
"name": "@acme/email-tokens",
"version": "3.4.0",
"type": "module",
"sideEffects": false,
"exports": {
".": "./dist/tokens.js",
"./json": "./dist/tokens.json",
"./css": "./dist/tokens.css",
"./dark.css": "./dist/dark.css",
"./mjml": "./dist/mjml-attributes.json"
},
"files": ["dist"],
"scripts": {
"build": "node build/generate.js",
"prepublishOnly": "npm run build && npm test"
}
}
Two details in that manifest do real work. "files": ["dist"] publishes only generated output, so a consumer cannot import the source and bypass the generation step. And prepublishOnly regenerates and tests before every publish, which removes the possibility of shipping a dist/ that is out of date with its source — the single most common defect in hand-managed token packages.
// build/generate.js — every output comes from one source, so they cannot diverge.
import { writeFileSync, mkdirSync } from "node:fs";
import { semantic, scale } from "../src/tokens.js";
mkdirSync("dist", { recursive: true });
// 1. JS object for JSX-based engines, light values flattened.
const flat = {
...Object.fromEntries(Object.entries(semantic).map(([k, v]) => [k, v.light])),
...scale,
};
writeFileSync("dist/tokens.js", `export const tokens = ${JSON.stringify(flat, null, 2)};\n`);
writeFileSync("dist/tokens.json", JSON.stringify(flat, null, 2));
// 2. The dark-mode media block, emitted only for tokens that actually differ.
const dark = Object.entries(semantic).filter(([, v]) => v.dark !== v.light);
writeFileSync("dist/dark.css",
`@media (prefers-color-scheme: dark) {\n` +
dark.map(([k, v]) => ` [data-token="${k}"] { color: ${v.dark} !important; }`).join("\n") +
`\n}\n`);
// 3. mj-attributes defaults, so MJML templates inherit the scale without
// repeating it on every component.
writeFileSync("dist/mjml-attributes.json", JSON.stringify({
"mj-text": { "font-family": "Arial, sans-serif", "font-size": scale.fontBody,
"line-height": scale.lineBody, color: semantic.colorTextBody.light },
"mj-button": { "background-color": semantic.colorActionBg.light,
color: semantic.colorActionText.light, "border-radius": scale.radius },
}, null, 2));
Generating the MJML attribute defaults alongside the JavaScript object is what keeps a mixed estate coherent. A team on MJML and a team on React Email consume different files and get identical values, which is the entire point of the exercise.
Versioning Rules for a Token Package
Token packages need a stricter reading of semantic versioning than most libraries, because a value change is invisible in a diff of consumer code but very visible in an inbox.
| Change | Bump | Reason |
|---|---|---|
| New token added | Minor | Nothing existing renders differently |
| New generated output added | Minor | Purely additive to the export map |
| Any colour, size or spacing value changed | Major | Recipients will see a different message |
| Token renamed | Major | Consumer builds break at import |
| Token removed | Major | Consumer builds break at import |
| Documentation or comment change | Patch | No output changes |
Classifying a value change as major is the rule people resist and the one that matters. Under a minor bump it reaches consumers through an ordinary dependency update and ships to inboxes without anyone reviewing the visual consequence; under a major bump it requires a deliberate upgrade in each consumer, where a visual diff is part of the review.
Variant: Publishing to a Private Registry
Most token packages are internal, which changes two things and nothing else. Scope the package name to your organisation and configure the registry in a project-level .npmrc rather than a developer's home directory, so CI resolves it identically. And publish with provenance where the registry supports it, so a consumer can verify which build produced a given version.
What does not change is the discipline. A private registry makes a package easier to publish, which makes it easier to publish carelessly — the version rules above matter more, not less, when the audience is colleagues who will assume an internal package is safe to upgrade.
Testing the Package Itself
A token package looks too simple to test, which is why its failures are so often discovered by a consumer. Three checks cover almost everything that goes wrong.
Assert the outputs are in sync with the source. Regenerate during the test run and compare against the committed or built artefacts; a mismatch means someone edited a generated file by hand, which is the single most common defect. Since the generation is deterministic, this is a byte comparison rather than a semantic one.
Assert the constraints the values must satisfy. Every colour is a 6-digit hex, every size is an absolute pixel value, no font size is below the mobile floor, and every semantic colour has both a light and a dark value. These are the same rules the design-tool sync enforces on the way in, and asserting them again on the way out catches a value that was edited directly in the source file rather than synced.
Assert contrast for every pairing that renders text on a surface. Body on surface, muted on surface, action text on action background — in both modes. A contrast failure in a token package propagates to every template that uses it, so this is the highest-leverage assertion in the whole system and it costs a few lines.
What none of these test is whether the values look right, which remains a human judgement made at review time against a rendered comparison. The tests exist to guarantee that whatever a human approved is what actually ships, and that no mechanical property was broken on the way.
Pipeline Integration
Publish from CI on a tag, never from a developer's machine. A local publish can include uncommitted changes, and the resulting version is then unreproducible from the repository — which for a token package means a colour exists in production that appears nowhere in source control.
Consumers should pin an exact version rather than a range. A caret range on a token package means an unattended minor upgrade can change what an inbox sees; an exact pin makes every upgrade a commit, which is where the component snapshot tests run and where a visual diff is reviewed.
Validation and Deployment Checklist
Frequently Asked Questions
Should the package include components as well as tokens?
Usually as a second package. Tokens change rarely and are consumed by everything; components change more often and are consumed by templates only. Splitting them means a component fix does not force a token version bump on services that use no components. The cost is one more package to publish, which is small compared with the coupling it removes.
How do we handle a brand refresh that changes every value?
As a single major version with a migration note, not as a series of smaller releases. A refresh is one coherent change, and shipping it piecemeal produces intermediate states where half the palette is new — which looks worse than either the old or the new design. Publish once, then upgrade consumers on a schedule.
Can consumers override a token locally?
They can, and the library cannot stop them, but every override is drift by another name. The productive response to a repeated override is to ask why the token set does not cover the case — usually the answer is a genuinely missing semantic token, and adding it removes the override across every consumer that had invented its own.
Does the package need TypeScript types?
If any consumer uses TypeScript, yes, and they should be generated rather than hand-written. A generated type union of valid token names turns a typo into a build error rather than an inline style with the literal string undefined in it, which is a failure mode that reaches inboxes surprisingly often.
What belongs in the changelog?
The rendered consequence, in the recipient's terms. "Button background darkened for contrast" is useful; "update colorActionBg" is not, because the reviewer cannot tell from it whether the change is safe to take before their next send.
Related
- Email Design Tokens and Component Libraries — the token layering this package publishes
- Versioning Shared Email Components Across Teams — the release discipline applied to components
- MJML Component Architecture — the engine the generated attribute defaults target
- Building a Type Scale for Transactional Emails — the scale the package encodes