Read(case-studies/zengrc-pro.md) ⎿ 5 min read
Case study · ZenGRC (Reciprocity → RiskOptics)ZenGRC Pro: from first commit to a ten-engineer product
A compliance platform rebuilt as one full-stack TypeScript app — a generated GraphQL API, types from the database to the button, a router decision made twice, and a redesign done without a design team.
Context
When I joined ZenGRC in early 2022, the company was building a successor to its original GRC (governance, risk and compliance) product. The first attempt was a Next.js frontend on PrimeReact, Redux and styled-components, making REST calls to a Python backend spread across many microservices.
Leadership decided to change course: a new design, and a single full-stack app that one kind of developer could own end to end. Within months of joining, I was asked to bootstrap it. That app became ZenGRC Pro.
It started with a couple of engineers on the Postgres schema and me on the API and a baseline UI. I partnered with our chief architect on the architecture, then served as principal engineer and reviewed every PR as the team grew to about ten engineers.
The constraint
The point of the rewrite was that any engineer could work anywhere in the stack. That ruled out anything that needed a specialist to understand: hand-written API layers drifting from the schema, untyped data crossing the network, or a frontend framework the team would fight.
The other constraint was time. The product had to catch up to an existing one, so the stack had to make the common case (list, read and edit business objects) almost free, and leave room for the unusual cases.
Options I considered
The API. Our chief architect suggested Postgraphile, which reads a Postgres database and generates a full GraphQL schema from it: queries for lists and single objects, and mutations for each table, configurable with Postgres comments. Anything more complex is a custom query or mutation written in our own code and registered as a plugin, and it gets GraphQL types too. The alternative was hand-writing resolvers or REST endpoints for every object, which is exactly the drift we were trying to escape.
The GraphQL client. We looked at Apollo, urql and react-query. We chose react-query: a couple of engineers had used it and spoke highly of it, it had the best docs and the largest community, and we liked its caching and revalidation model. With a generated schema, we didn’t need a client that wanted to own the schema too.
The router. Next.js’s App Router was brand new, and I was eager to try it. The Pages Router was mature and boring.
Components and styling. We moved from PrimeReact and styled-components to Material UI, using its theme-aware sx prop for styling.
The call
One generated, typed data layer
Postgraphile ran as middleware inside the Next.js server, behind a single /api/graphql endpoint, so there was one deployable and one place to look.
On top of it I set up GraphQL codegen. It reads the live schema and generates TypeScript types for every object, plus the variables and payloads of every query and mutation, along with typed react-query hooks. When a developer changed the database or a query, one command regenerated everything. If a schema change broke any call anywhere in the app, the build failed. The whole team got one consistent, strongly typed API surface without anyone maintaining it by hand.
Postgres schema ──► Postgraphile (Next.js middleware, /api/graphql)
│
▼
codegen: types + react-query hooks
│
▼
components ── compile error if the schema moves
The router, decided twice
I built the first slice on the App Router: a simple app, real data through our react-query hooks. It became clear quickly that the App Router wasn’t ready for us. We hit serious memory leaks, and compile times got painful.
So we moved to the Pages Router, but structured the code so we could move back later at low cost. The real application lived in an app directory, organized the way the App Router expects. The pages directory held nothing but thin route files, each one re-exporting the real page component from app. Next.js saw a Pages Router app; we maintained what was effectively an App Router app waiting for its framework to catch up.
A theme built for change
We used Material UI’s theme, heavily customized, with light and dark modes from the first day and a rule that every theme change landed in both. Reusable styles became shared sx objects, which gave us most of what styled-components had without a second styling system.
The theme was designed so customers could eventually bring their own, and we later shipped user preferences for many of the app’s colors on top of it.
Responsiveness was never a product requirement. I pushed for it anyway: Material UI makes it cheap, and I asked that everything merged look reasonable on a phone-sized screen. The team held that line well.
A redesign without a design team
The initial look and feel came from the design team’s specs. Partway through the project, the company restructured and no longer had product designers, and leadership wanted the app’s look refreshed. I took it on.
I’m not a graphic designer, so I leaned on feedback from people across the team as I reworked the main surfaces:
- A detached, collapsible sidebar with accordion sections.
- Cards with overlapping icons, and a consistent color for each area of the app, so you always knew where you were.
- A translucent header that floats over the content.
- A side menu on every business-object page for moving between its sections.
Result
- ZenGRC Pro went from first commit to a production product built by about ten engineers, on one full-stack TypeScript codebase.
- Every query, mutation and object was typed from the database to the component, and schema drift was a compile error rather than a production bug.
- The theme supported light and dark modes from day one and later carried user color preferences.
- I was promoted from Senior UX Engineer to Staff, then Senior Staff, while leading it.
What I’d do differently
Write the custom-query playbook on day one. The generated API was the right call. The hard part was the first few operations that didn’t fit a table. A short guide with one worked example would have saved each engineer from rediscovering the plugin path.
Make “re-export only” a lint rule. The pages-as-re-exports structure only works if nobody puts logic in pages. That was a convention enforced in review; a lint rule would have enforced it for free.
Treat design as a first-class workstream even without designers. When the redesign landed on engineering, it worked because someone owned it. I’d make that ownership explicit up front, with a small set of design principles written down, instead of letting it happen informally.
The views here are my own, not those of any employer, past or present.