Problem
GUT Music is a platform for emerging artists. Artists build a profile, upload reels and apply to auditions. They also get a wallet with vouchers and can buy premium services from GUT and its partners. Artists, fans and the public can browse artists, auditions, reels, events and collections. The staff approves new artists and manages the content from a CMS.
Both apps run on an API written by a separate backend team. A change in a response could break a screen with no warning. Before each release, the QA checklist was checked by hand, and that took a full day.
Architecture
The artist app and the CMS are two React clients. Every request goes through a contract layer of Zod schemas and a typed client, and then to the backend API. The end-to-end tests use the same schemas. Woodpecker CI builds and deploys both apps. The end-to-end suite runs against staging before a release.
I read the API code to agree on contracts with the backend team, but I don’t change it.
Scope
Mine
- The artist PWA and the CMS, from routing to forms
- The typed API client, with runtime validation on every response
- Instant reopen and offline reads through a persisted query cache
- Unit and end-to-end suites, and the link between the tests and the QA checklist
Other teams
- Backend API and data model (backend team)
- Product decisions and visual design (product and design)
- Final sign-off of a release, still done by hand on the checklist
Key decisions
Validate every API response at runtime
Why. The API belongs to another team. When a contract changes, the Zod error names the field that changed, and the screen shows an error state instead of broken data.
Trade-off. The schemas need upkeep. In return they are the only source of types, for the apps and for the tests.
Persist the query cache in the PWA
Why. Artists open the app on their phone, often on a weak connection. The cache is saved to storage, so the last data shows up at once, also offline.
Trade-off. The cache has to be cleared on logout and on account switch. Queries with private or short-lived data opt out of persistence one by one.
Link the end-to-end tests to the QA checklist
Why. Each row of the checklist has a test or is marked as manual. A test run gives a pass or a fail for every row, in the terms QA already uses.
Trade-off. When the checklist is renumbered, the links have to follow. A script remaps them.
In code
The contract layer, simplified:
const Reel = z.object({
uuid: z.string(),
title: z.string(),
status: z.enum(["draft", "pending", "published"]),
createdAt: z.string(),
});
export type Reel = z.infer<typeof Reel>;
// A changed field fails here, with its path, not in the UI
export const useArtistReels = (artistId: string) =>
useQuery({
queryKey: ["artists", artistId, "reels"],
queryFn: async () =>
z.array(Reel).parse(await api.get(`/artists/${artistId}/reels`)),
});Results
- 1 day → 1 h
- to check every use case in the QA checklist before a release
- 128
- end-to-end tests, about 107 linked to rows of the QA checklist
- ~1,160
- unit tests across the app and the CMS
A release is still signed off by hand. The difference is on the tech side. A test run of about an hour now shows, in the same way every time, that all the use cases in the checklist pass. The same check used to take a full day.