Monorepos and multiple projects
One repository can hold several Storybooks — an app and its design system, a handful of packages, a documentation site. Each one is a project: it is built, uploaded and compared on its own, and it gets its own check run and its own comment on every pull request.
Declare a project per Storybook
A project is a key, which names it everywhere Merrykat reports on it, and a dir, which is where that Storybook builds to.
export default {
projects: [
{ key: 'web', dir: 'apps/web/storybook-static' },
{ key: 'docs', dir: 'apps/docs/storybook-static' },
],
// Everything outside `projects` applies to all of them.
viewports: [
{ name: 'desktop', width: 1280, height: 800 },
{ name: 'mobile', width: 420, height: 700 },
],
};A key may contain letters, digits, dots, underscores and dashes. It is part of the storage path for that project’s builds and of the marker Merrykat finds its own comment by, so it is deliberately not free-form text — apps/web is rejected, apps.web is fine.
With no projects at all you have exactly one, called default, building to storybook-static. Nothing about a single-Storybook repository changes.
Every project you upload has to be listed here. merrykat upload --project mobile against the configuration above fails, naming the keys that do exist. That is deliberate, and it is the one thing projects does that is not about convenience: Merrykat keeps no list of your projects server-side, so an unrecognised key is not an error there — it is a new project. A typo would be accepted in full, quietly starting a second history with no baseline, its own stored builds and its own flake record, and every run after it comparing against the wrong past. Your configuration file is the only place that knows how the key is meant to be spelled.
Upload each project
One merrykat upload per project, each naming its own with --project. On GitHub Actions a matrix is the natural shape: the builds are independent, so they run in parallel and each uploads as soon as it is done.
jobs:
visual:
runs-on: ubuntu-latest
strategy:
# Each project is an independent comparison, so let the others finish even
# if one build fails.
fail-fast: false
matrix:
project:
- { key: web, build: 'pnpm --filter web build-storybook' }
- { key: docs, build: 'pnpm --filter docs build-storybook' }
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: ${{ matrix.project.build }}
- name: Upload the build to Merrykat
run: npx merrykat upload --project ${{ matrix.project.key }}Sequential steps in one job work just as well, and are simpler if the builds share most of their setup:
- run: npm run build-storybook:web
- run: npx merrykat upload --project web
- run: npm run build-storybook:docs
- run: npx merrykat upload --project docs--project is optional only while there is one project to mean. With several configured, an upload that does not name one fails immediately rather than guessing — guessing would upload one project’s build and silently never upload the other, and you would find out half an hour later, from the other project’s run timing out.
The --dir flag is not needed here: each project already says where it builds to. Passing it overrides the configured directory for that one upload.
What a pull request looks like
One check run, always called merrykat. It covers every project: while any of them is still comparing it reads 1 of 2 projects compared, and when they have all reported it concludes on the most severe outcome among them, with a row per project naming what each one found. So the check you require in branch protection is the same check forever — adding a Storybook does not introduce a new required status, and removing one cannot leave a required status that never reports again.
One comment per project, each updated in place as new commits arrive and each headed by the project it is about. Comments stay separate because each project’s report is a substantial document — examined stories, composite images, accessibility tables — and one project’s finishing has no business rewriting another’s.
The projects are independent: a diff in docs says nothing about web, one project’s baseline can be older than another’s, and a build that fails to upload costs its own comparison and no one else’s.
Nothing anticipates which projects you have. A project appears in the check when its build arrives, which means you can add or remove a Storybook without telling Merrykat and without anything to clean up afterwards — a project you stop uploading simply stops appearing. The one visible consequence: if a project uploads long after its siblings have finished, the check will have concluded and then goes back to in-progress when that build lands. Builds that run in parallel — the usual arrangement, and the one the matrix above produces — are all registered long before any of them finishes comparing.
Per-project settings
Everything outside projects applies to every project, which is usually what a monorepo wants: one viewport list, one failure policy, one place to change them. A project that genuinely differs can override any of those settings for itself.
export default {
viewports: [
{ name: 'desktop', width: 1280, height: 800 },
{ name: 'mobile', width: 420, height: 700 },
],
failOn: ['error', 'blank', 'likely_bug'],
projects: [
{ key: 'web', dir: 'apps/web/storybook-static' },
{
key: 'design-system',
dir: 'packages/ui/storybook-static',
// One wide viewport instead of the two above — components, not pages.
viewports: [{ name: 'wide', width: 1440, height: 900 }],
// Stricter here than for the app: a design system that drops an accessible
// name has shipped it to every consumer.
failOn: ['error', 'blank', 'likely_bug', 'new_a11y_violation'],
// Merged field by field over the repository-wide `ai`, so `enabled` and
// `codeContext` keep whatever they were set to.
ai: { maxExamined: 4 },
},
],
};Overridable: viewports, include, exclude, delay, play, playTimeout, failOn, ai, accessibility and compare. Lists replace rather than extend — a project asking for one viewport gets one viewport. The three objects are merged field by field, so overriding ai.maxExamined leaves the rest of ai as you configured it repository-wide.
Configuration describes what each of those settings does.
Fingerprints and flakiness
Both are per project. Build fingerprints are read from the build that wrote them, so each Storybook’s generator only ever describes its own stories. Flake history is counted per project as well — two Storybooks that share a component still record their own results for it, because they render it in their own surroundings.