One-line hook
A generated admin is the cheapest UI you’ll ever build and the most expensive one you’ll ever own. Here’s what happened when we replaced ours with a custom React + GraphQL admin — the wins were real, the traps were funnier than the wins.
Who this is for
- Anyone sitting on a Rails Admin / ActiveAdmin / Django Admin / Flask-Admin that has outgrown itself
- Teams considering “let’s just build our own admin in $framework” and wondering what the second-order costs are
- Backend engineers who haven’t yet been bitten by
COUNT(*) OVER ()on a list view of a 40M-row table
The setup (vague-client framing)
- Client: B2B SaaS, multi-tenant, with a privileged “superadmin” role used by the internal operations team to support customers across tenants
- The legacy: a generated admin mounted at
/admin, with 80+ resource pages, half of them custom-overridden, none of them documented - The trigger: ops team productivity was tanking. Pages were taking 15+ seconds to load. Bulk operations were impossible. The “edit customer” flow involved opening five tabs.
- The mandate: rebuild the admin, ship incrementally, do not break the ops team’s workflow during the migration
What the generated admin was actually good at
Be honest about this up front. Generated admins are a triumph of metaprogramming. They give you, for free: - CRUD on every model - Reasonable defaults for forms based on column types - A search box that does something - Authorization wired to your existing ability/policy file - Zero design decisions to make
The reason the generated admin lasts as long as it does is that all of the above is real value. The article should not start with “the old admin was bad.” It should start with “the old admin earned its keep for years, and then it didn’t.”
What forced the migration
- List-view performance. The generated admin runs
SELECT COUNT(*)for pagination on every list view. On the larger tables, this is a sequential scan. We had list views taking 8–12 seconds just to render the pagination footer. - Cross-tenant superadmin views. The framework’s authorization layer assumed tenant-scoped queries. The superadmin role wanted cross-tenant views — show me every flagged customer across the whole platform. The framework fought us at every turn.
- Custom forms got out of hand. Every non-trivial edit screen had been overridden with a custom partial. The overrides accumulated until the “generated” admin was 60% bespoke code — but with the framework’s conventions still applying in confusing ways.
- Bulk operations. The framework had a story for this, but it was a slow, single-request story. Bulk-flagging 5,000 customers froze a worker.
- Mobile-unfriendly. Ops did not have laptops on customer calls. The legacy admin was unusable on a phone.
The “obvious” plan and why it was wrong
The obvious plan was “wrap the existing endpoints in a new UI.” We tried this. The endpoints were over-tuned to the framework’s conventions: they returned HTML, they assumed a session cookie, they had implicit ordering and pagination baked in. Wrapping them in a JSON layer was strictly more work than re-querying the database from a new GraphQL resolver.
The plan that worked
- Build a new GraphQL schema from scratch, modeled after the operational workflows of the ops team — not after the underlying tables.
- Build a React admin that is just a client of that schema. Use a component library; don’t reinvent.
- Keep the old admin mounted at
/admin-legacy. Cut over one page at a time, never one model at a time. (More on this distinction below.) - Decommission only after a page has been on the new admin for two weeks with zero ops complaints.
Where the wins were
- Front-end velocity went up dramatically once the GraphQL schema stabilized. New admin pages took hours, not days. The schema was the throttle, not the React layer.
- Rich UI became cheap. Drawer-based detail views, inline edits, multi-select bulk operations, keyboard shortcuts — all things the generated admin made structurally impossible, all easy in the new world.
- Mobile-friendly fell out for free as a consequence of using a modern component library responsibly.
- The ops team stopped opening five tabs. Every workflow that previously required multiple page loads collapsed into a single screen with progressive disclosure.
Where the terrors lived
Terror 1: Counts you can’t denormalize
The framework gives you free count-based pagination. The custom admin doesn’t. Suddenly you have to answer: on a list of 40M customers filtered by “has overdue invoice,” do you:
- Run COUNT(*) every page load? (Slow. Possibly timeout-inducing.)
- Cache the count? (Wrong almost immediately on a live system.)
- Approximate with EXPLAIN? (Acceptable for some workflows, garbage for others.)
- Switch to cursor-based pagination and accept “page X of ?”? (UX regression for some workflows, fine for others.)
- Denormalize a count column on the parent record? (Works only when the filter axis matches a single denormalized count, and now you own a cache-invalidation problem.)
There is no global right answer. We ended up with four different pagination strategies in the codebase, picked per-query based on the actual access pattern. The article will walk through the decision tree.
# Pseudocode for the strategy selector — actual code lives in resolvers
def pagination_strategy_for(query_signature) -> PaginationStrategy:
if query_signature.has_stable_filter and query_signature.estimated_rows < 100_000:
return ExactCountOffsetPagination()
if query_signature.is_time_ordered:
return CursorPagination(cursor_column="created_at")
if query_signature.is_a_dashboard_kpi:
return CachedApproximateCount(ttl_seconds=60)
return EstimateFromExplainPagination()
Terror 2: Cross-tenant superadmin
This is the one that bit hardest. The application’s authorization model was deeply tenant-scoped — every query was implicitly filtered by current_tenant_id. The superadmin role needed to opt out of that filter, on a per-resolver basis, but only for users with the role and only on specifically-marked queries and with audit logging.
We ended up with:
- An explicit Scope object passed through resolvers. Default: tenant-scoped. Superadmin can request Scope.global() but only on resolvers that opt in.
- A test suite that asserts every resolver either (a) is tenant-scoped or (b) explicitly declares it accepts global scope.
- An audit log on every global-scope query. Who, when, what shape, what filter.
The framework’s current_user.can?(:read, Customer) style permission checks fell apart here. They were per-record, not per-query-scope. We built a new authorization layer alongside the GraphQL schema. (Code snippet planned: the scope-object pattern in Python.)
Terror 3: Roles, but for real this time
Generated admins typically assume two roles: “admin” and “not admin.” Reality has at least: L1 support, L2 support, ops manager, finance, security, and read-only auditors. Each needs a different subset of fields visible on the same record.
GraphQL field-level authorization is the right shape for this, but it gets ugly fast. Patterns we settled on:
- Resolver-level guards on sensitive fields (PII, billing data)
- A redact_for(role) step at the response layer for fields that should appear but be masked
- Per-role schema introspection — different roles literally see different schemas when introspecting
Terror 4: The “soft cutover” trap
Running two admins in parallel sounds clean. In practice, ops people memorize URLs. They bookmark the legacy admin. They send each other links. Cutting over “one model at a time” means a customer’s record is now scattered across two UIs.
Fix: cut over by workflow, not by model. The “handle a billing dispute” workflow moves end-to-end, including any models it touches, in one cutover. Other workflows can stay on the legacy admin until their turn. This was the single most-important process decision in the whole migration.
What we’d do differently
- Build the pagination strategy framework first. We discovered the four-strategies-needed truth halfway in and refactored.
- Make the audit log a first-class GraphQL middleware, not a per-resolver bolt-on.
- Document the GraphQL schema as the source of truth for what the admin can do. We let the React app drift ahead of the schema docs and paid for it in onboarding cost.
- Don’t try to be a thin client. Some admin pages need bespoke backend endpoints (bulk operations, exports, long-running jobs). Force-fitting them into the GraphQL idiom is masochism.
Results
- Median admin page load: ~9s → ~400ms
- Bulk operations: hours → minutes (and now backgrounded, not blocking the UI)
- New admin page time-to-ship: ~3 days → ~half a day
- Ops team retention/satisfaction: not measurable cleanly, but the qualitative shift was dramatic
- Production incidents caused by the legacy admin: gone (it turned out a non-trivial chunk of past incidents had been ops people clicking a slow button twice)
The takeaway
Replacing a generated admin is a backend project pretending to be a frontend project. The React layer is the easy part. The interesting work is pagination strategy, scope modeling, role-aware authorization, and figuring out which workflows to migrate first. If you’re early in this journey: build the GraphQL schema around workflows, not tables; pick your pagination strategies before you need them; and never, ever try to denormalize a count that depends on more than one filter axis.