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

The setup (vague-client framing)

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

Where the wins were

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

Results

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.