j-riv@localhost:~$

Building a Component Library for NetSuite Reports

I wrote a while back about a pattern I keep reaching for at work, small React apps embedded directly in NetSuite that give people an actual dashboard instead of digging through a saved search. I've built enough of these now that I finally sat down and turned the repeated parts into a real component library instead of copy pasting the same table and filter code into every new report.

What was actually annoying

NetSuite's native reporting isn't bad exactly, it's just not built for the kind of thing I keep getting asked for. Long tables lose their headers the second you scroll. Totals end up below the fold where nobody scrolls far enough to see them. Every report needs the same filters, a date range, a status dropdown, a search box, and I was rebuilding that boilerplate from scratch each time. Refreshing a report usually meant a full page reload, which feels bad when someone just wants to re-run the same query with a different date.

None of that is a huge problem on its own. It just adds up fast once you're maintaining four or five of these.

Splitting the data from the display

The fix was drawing a clear line between what NetSuite is responsible for and what the frontend is responsible for.

NetSuite Backend
  SuiteQL Queries <-> RESTlet API
        |
        | JSON request / response
        v
React Frontend
  Context + Component Library

NetSuite still owns the actual querying, SuiteQL does the heavy lifting on the backend like it always has. But the frontend doesn't know or care how that data got built. It just hits a RESTlet with an action and a payload and gets back a typed JSON response. A shared context provider handles the request lifecycle, loading states, errors, detail lookups, and TanStack Query sits on top of that for caching and periodic refresh. Each report only has to worry about its own filters and its own aggregation logic.

That separation is really the whole point. I can change a query on the backend without touching the frontend, and I can reuse the same frontend building blocks for a completely different dataset without rewriting the plumbing underneath.

The actual building blocks

A few pieces ended up doing most of the work across every report.

NetSuiteReportTable is the generic table. It takes typed rows, column definitions, and a row key, so a new report doesn't mean rebuilding table markup, it means describing columns. Column values can be plain data or custom React content, which is how status badges and formatted numbers end up inside cells without hacking anything together. It also handles the sticky header, an optional totals footer, a quick search box, loading and empty states, and row click handling for drill downs. Filtering is left up to each report though, usually just a useMemo deriving visible rows from whatever the search term is, since what counts as a meaningful filter is different for every report.

NetSuiteSelect, NetSuiteInput, and NetSuiteButton are the form primitives. Nothing exciting here, just making sure every filter bar across every report looks and behaves the same way instead of each one growing its own slightly different dropdown.

NetSuiteBadge and NetSuiteNotice handle status. Work orders come back from NetSuite with status codes like D, B, G, H, which mean nothing to anyone without memorizing them. The report components map those codes to labels like in process, released, built, and closed, and the badge just renders whatever variant it's told to. Keeping that mapping at the report level instead of baking it into the badge itself means the badge stays generic and reusable for other status types later.

The one I like the most is MetricRatioRingCard, a small SVG ring that shows how two numbers relate to each other, picked versus remaining, built versus ordered, whatever the report needs. It's just a circle with strokeDasharray and strokeDashoffset doing the actual math, but it reads a lot better at a glance than a raw number in a table ever does.

Putting it to use

The picker performance and work order production reports were the first two built on top of this, same table and same primitives underneath, just different filters, different columns, and different aggregation logic for each one. Every report since then has mostly been deciding what the response shape and filters look like, then wiring the existing pieces together instead of starting from a blank page.

The boring part turned out to be the best part. Once the shared pieces existed, adding a new report stopped being a frontend project and started being an afternoon.

Next up I want to go into the part I skipped here, how the RESTlet routing actually works on the backend, and the slightly odd trick that gets a React build running inside a Suitelet page at all.