kevhender.netKevin H.Ask

kevhender.net / Writing

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.

More reading

  1. i.Case study

    I built an AI workflow builder — then deleted it

    From an in-app chat builder to a headless MCP plugin inside Claude and ChatGPT; evals, native tool calling, and knowing when the best UI is someone else's.

  2. ii.Case study

    Measuring LLM features before trusting them

    Deterministic scorers before LLM judges, a simulated user for clarifying questions, grading effects instead of claims, and shadow-testing an LLM's replacement on live traffic.

  3. iii.Case study

    Pausing autonomous agents for a human, durably

    How to pause an agent before a consequential action and wait days for a human, without losing state.

Letters to the author

Hiring for Staff, Principal or Founding? Write to Kevin.