<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Coreola</title>
    <description>The latest articles on DEV Community by Coreola (@coreola).</description>
    <link>https://dev.to/coreola</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3950320%2Fac7b97f6-7baa-400b-b612-5a79cd88d117.jpg</url>
      <title>DEV Community: Coreola</title>
      <link>https://dev.to/coreola</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/coreola"/>
    <language>en</language>
    <item>
      <title>Beyond CRUD: Anatomy of a real admin workflow in React</title>
      <dc:creator>Coreola</dc:creator>
      <pubDate>Mon, 15 Jun 2026 08:45:46 +0000</pubDate>
      <link>https://dev.to/coreola/beyond-crud-anatomy-of-a-real-admin-workflow-in-react-2e05</link>
      <guid>https://dev.to/coreola/beyond-crud-anatomy-of-a-real-admin-workflow-in-react-2e05</guid>
      <description>&lt;h2&gt;
  
  
  Patterns are abstract until they meet a real workflow with statuses, sub-resources, and a decision that lives separately from status. Here's what falls out when you actually build one.
&lt;/h2&gt;

&lt;p&gt;The previous three posts in this series were about patterns: the &lt;a href="https://dev.to/coreola/the-floor-problem-why-every-admin-product-wastes-3-months-on-the-same-foundation-3pdk"&gt;floor problem&lt;/a&gt;, &lt;a href="https://dev.to/coreola/the-five-problems-hiding-inside-every-admin-table-and-what-to-do-about-them-c4p"&gt;tables&lt;/a&gt;, and &lt;a href="https://dev.to/coreola/five-problems-hiding-inside-every-admin-permissions-setup-ol2"&gt;permissions&lt;/a&gt;. Each walked through one floor item with the same five-part failure pattern.&lt;/p&gt;

&lt;p&gt;This post is different. It's the case study. Because patterns are abstract until you build them against something real, and "real" doesn't mean three CRUD screens — it means a workflow with statuses, sub-resources, ownership, audit timelines, and a decision flow that runs orthogonally to status.&lt;/p&gt;

&lt;p&gt;This post walks through five design decisions that only become visible when an admin module stops being CRUD: separating status from decision, backend-owned transitions, first-class sub-resources, operator queues, and composable detail pages.&lt;/p&gt;

&lt;p&gt;Here's what an actual workflow module — the Assessments module from Coreola — looks like, and the five design decisions you can't make without testing against the real thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the workflow does
&lt;/h2&gt;

&lt;p&gt;An Assessment is a structured review of something: a third-party vendor, a control set, a regulatory framework. The product has to model it from intake through completion, with findings raised against it, evidence requested from internal and external parties, an audit trail, and a decision that's separate from "what state is the assessment in right now."&lt;/p&gt;

&lt;p&gt;The shape, at a glance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Assessment
 ├─ Status lifecycle      (11 operational states)
 ├─ Decision lifecycle    (5 outcome states, orthogonal to status)
 ├─ Findings              (sub-resource, own 5-state status)
 ├─ Evidence requests     (sub-resource, own 6-state status)
 └─ Activity timeline     (sub-resource, audit log)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The entity has 11 workflow statuses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;draft → intake → in_review → waiting_for_evidence
  → remediation_in_progress → blocked → approved
  → approved_with_exceptions → rejected → completed → archived
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has three sub-resources, each with their own lifecycle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Findings&lt;/strong&gt; — issues raised during review, with their own 5-state status (&lt;code&gt;open&lt;/code&gt;, &lt;code&gt;accepted_risk&lt;/code&gt;, &lt;code&gt;in_progress&lt;/code&gt;, &lt;code&gt;resolved&lt;/code&gt;, &lt;code&gt;closed&lt;/code&gt;), 4 severity levels, 8 categories, and links to the evidence that resolves them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidence requests&lt;/strong&gt; — documentation asks sent to recipients (sometimes internal, sometimes external), with their own 6-state status (&lt;code&gt;requested&lt;/code&gt;, &lt;code&gt;in_review&lt;/code&gt;, &lt;code&gt;received&lt;/code&gt;, &lt;code&gt;rejected&lt;/code&gt;, &lt;code&gt;cancelled&lt;/code&gt;, &lt;code&gt;expired&lt;/code&gt;) and three time dimensions tracked separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Activity&lt;/strong&gt; — audit/timeline entries, each with a type and an actor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it has a decision — &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;approved&lt;/code&gt;, &lt;code&gt;approved_with_conditions&lt;/code&gt;, &lt;code&gt;rejected&lt;/code&gt;, &lt;code&gt;escalated&lt;/code&gt; — which lives as a separate field on the assessment, independent of status.&lt;/p&gt;

&lt;p&gt;That last point is where most templates fall over, so let's start there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision #1: separate the decision dimension from the status dimension
&lt;/h2&gt;

&lt;p&gt;Most CRUD templates have one enum for "where is this in the workflow." A naive Assessments model would put &lt;code&gt;approved&lt;/code&gt;, &lt;code&gt;rejected&lt;/code&gt;, and &lt;code&gt;approved_with_exceptions&lt;/code&gt; in the same status enum as &lt;code&gt;draft&lt;/code&gt; and &lt;code&gt;in_review&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Look at what that costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The status field has to carry two semantically different ideas: "what's happening right now" (operational state) and "what was decided" (outcome state).&lt;/li&gt;
&lt;li&gt;An assessment in &lt;code&gt;remediation_in_progress&lt;/code&gt; doesn't have a decision yet, but if &lt;code&gt;approved&lt;/code&gt; is also a status, the UI has to model "no decision yet" as a separate flag — or worse, infer it from the status string.&lt;/li&gt;
&lt;li&gt;Re-opening a decision becomes a status transition. Now the audit log has to distinguish "we're working on this again because new evidence came in" from "we're working on this because we changed our minds about the previous decision."&lt;/li&gt;
&lt;li&gt;Reporting becomes ambiguous. "How many assessments did we approve last quarter" requires knowing which status values count as approvals — and that list will change as new statuses get added.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Coreola's &lt;code&gt;Assessment&lt;/code&gt; carries both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;intake&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;in_review&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;waiting_for_evidence&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;   &lt;span class="c1"&gt;// 11 states&lt;/span&gt;
&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approved_with_conditions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;escalated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Status describes the operational dimension: where is this in the pipeline right now? Decision describes the outcome dimension: what was concluded? They're orthogonal. An assessment can be &lt;code&gt;in_review&lt;/code&gt; with &lt;code&gt;decision: pending&lt;/code&gt;. It can be &lt;code&gt;completed&lt;/code&gt; with &lt;code&gt;decision: approved_with_conditions&lt;/code&gt;. Reopening for new evidence changes status without touching decision. Recording a decision is its own mutation with its own dialog (&lt;code&gt;SubmitDecisionDialogView&lt;/code&gt;) — it's not buried inside a status change.&lt;/p&gt;

&lt;p&gt;This decision wouldn't have happened from looking at three CRUD screens. It only becomes obvious when you try to model a real review pipeline and run out of room in a single enum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision #2: the state machine lives on the backend
&lt;/h2&gt;

&lt;p&gt;11 statuses means there are far more than 11 possible transitions. Some are valid (&lt;code&gt;in_review&lt;/code&gt; → &lt;code&gt;waiting_for_evidence&lt;/code&gt;), some are nonsensical (&lt;code&gt;completed&lt;/code&gt; → &lt;code&gt;draft&lt;/code&gt;), and some are conditional (&lt;code&gt;blocked&lt;/code&gt; → &lt;code&gt;approved&lt;/code&gt; is fine only if there are no open findings).&lt;/p&gt;

&lt;p&gt;You can model this on the frontend. Lots of teams do. They end up with a hand-maintained state machine in TypeScript that lists every valid &lt;code&gt;from → to&lt;/code&gt; pair, with conditions. It works until the backend adds a status, or removes one, or changes a transition rule. Then there are two state machines — one on the backend, one in the frontend — that drift.&lt;/p&gt;

&lt;p&gt;The pattern that survives is: &lt;strong&gt;the backend owns the transitions, the frontend asks for the current options.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Coreola, the Assessments API has a &lt;code&gt;meta&lt;/code&gt; endpoint. The frontend calls &lt;code&gt;useGetAssessmentsMetaQuery()&lt;/code&gt; and gets back, among other things, the &lt;code&gt;status_options&lt;/code&gt; available for the current state of the entity. The &lt;code&gt;ChangeStatusDialogView&lt;/code&gt; renders only the statuses the backend says are reachable from here. Add a status on the backend, and it appears in the dropdown without a frontend change.&lt;/p&gt;

&lt;p&gt;The frontend's responsibilities become much simpler:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read&lt;/strong&gt; the current status and the available transitions from &lt;code&gt;meta&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt; the status with a &lt;code&gt;Status&lt;/code&gt; component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger&lt;/strong&gt; a transition via &lt;code&gt;useUpdateAssessmentStatus&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refetch&lt;/strong&gt; automatically via RTK Query tag invalidation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The frontend has no idea what the state machine looks like in full. It doesn't need to. The same pattern handles decisions, finding statuses, and evidence statuses — three more enums, none of which the frontend owns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision #3: sub-resources are first-class
&lt;/h2&gt;

&lt;p&gt;A naive model would treat findings and evidence requests as nested fields on the assessment object. Render them inside the details page as inline arrays. Mutate them by sending a whole-assessment update.&lt;/p&gt;

&lt;p&gt;This works for two findings. It breaks the moment a finding has its own owner, its own due date, its own status transitions, its own permission gates, and its own deep link in a Slack message.&lt;/p&gt;

&lt;p&gt;Coreola models each sub-resource as a real entity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Its own type definition (&lt;code&gt;AssessmentFinding&lt;/code&gt;, &lt;code&gt;EvidenceRequest&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Its own API endpoints (list, create, update, delete)&lt;/li&gt;
&lt;li&gt;Its own dialogs (&lt;code&gt;NewFindingDialogView&lt;/code&gt;, &lt;code&gt;ChangeFindingStatusDialogView&lt;/code&gt;, &lt;code&gt;AssignFindingOwnerDialogView&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Its own status enum, separate from the parent's&lt;/li&gt;
&lt;li&gt;Its own permission scoping (parent's &lt;code&gt;assessment.update&lt;/code&gt; ability)&lt;/li&gt;
&lt;li&gt;Its own deep-linkable URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters more than it sounds. The details page route is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/collections/assessments/details/:assessmentId?/:resource?/:resourceId?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with &lt;code&gt;resource&lt;/code&gt; constrained in &lt;code&gt;routes.ts&lt;/code&gt; to &lt;code&gt;'finding' | 'evidence'&lt;/code&gt;. So &lt;code&gt;/collections/assessments/details/123/finding/456&lt;/code&gt; is a real, shareable, deep-linkable URL that opens assessment 123 with finding 456 focused. The same pattern works for evidence requests.&lt;/p&gt;

&lt;p&gt;Now links to a specific finding actually work. Now the back button works between findings inside the same assessment. Now the URL is the entire identifier of "what is the user looking at," which is the same property tables relied on in &lt;a href="https://dev.to/coreola/the-five-problems-hiding-inside-every-admin-table-and-what-to-do-about-them-c4p"&gt;post #2&lt;/a&gt; and routes relied on in &lt;a href="https://dev.to/coreola/five-problems-hiding-inside-every-admin-permissions-setup-ol2"&gt;post #3&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The Activity sub-resource follows the same shape — it's not a derived list, it's a proper entity (&lt;code&gt;AssessmentActivity&lt;/code&gt;) with its own API. You get audit logging without instrumenting every mutation site, because activity is a first-class thing the backend records and the frontend reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision #4: the queue is a different view, not a filtered list
&lt;/h2&gt;

&lt;p&gt;When product asks for an "operator queue," the temptation is to add a few preset filters to the list view and call it done. "It's just the list with status filters preset."&lt;/p&gt;

&lt;p&gt;It's not. A list and a queue are different mental models, and the difference shows up in three places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The user's intent.&lt;/strong&gt; A list user is browsing; a queue user is working through items. The list optimizes for finding; the queue optimizes for processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The information density.&lt;/strong&gt; A list shows everything; a queue shows the slice the operator is responsible for plus stats on the other slices they're not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The actions.&lt;/strong&gt; A list has navigation (click to view); a queue has bulk row actions (assign owner, request evidence, change status, submit decision) without leaving the queue.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Coreola, the queue lives at &lt;code&gt;/collections/assessments/queue&lt;/code&gt; as a separate route with its own model hook (&lt;code&gt;useQueueModel&lt;/code&gt;), separate from the list's &lt;code&gt;useListModel&lt;/code&gt;. The queue introduces a &lt;code&gt;queue_section&lt;/code&gt; filter — a persistent radio control with five slices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;needs_review | waiting_for_evidence | remediation_in_progress | blocked | overdue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The selected slice maps to assessment query params and combines with the standard filters. A &lt;code&gt;StatsLine&lt;/code&gt; shows counts for every slice (so the operator can see "11 needing review, 3 overdue") regardless of which one is currently filtered. The stats refresh independently of the table — &lt;code&gt;useGetAssessmentsStatsQuery&lt;/code&gt; runs on its own cadence so scanning the worklist doesn't blow away the current scroll position.&lt;/p&gt;

&lt;p&gt;Row actions are inline dialogs, not navigations: &lt;code&gt;AssignOwnerDialogView&lt;/code&gt;, &lt;code&gt;RequestEvidenceDialogView&lt;/code&gt;, &lt;code&gt;ChangeStatusDialogView&lt;/code&gt;, &lt;code&gt;SubmitDecisionDialogView&lt;/code&gt;. The operator stays in the queue while taking action. The queue becomes the operator's worksurface.&lt;/p&gt;

&lt;p&gt;The list still exists, separately, for the browsing use case. Two routes, two model hooks, one shared table primitive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision #5: composition over monolith for the details page
&lt;/h2&gt;

&lt;p&gt;The details page is where most templates collapse into one big component. "Here's the assessment, here's all its data, here's every action available." 2,000 lines of JSX and a model hook with thirty mutations.&lt;/p&gt;

&lt;p&gt;The shape that survives: a stack of small cards, each owning its own concern, each composed into a top-level model hook.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OverviewCardView    — title, status, owner, dates, metadata (toggleable edit mode)
DecisionCardView    — submit decision, change status
FindingsCardView    — sub-table of findings with row actions
EvidenceCardView    — sub-table of evidence requests with row actions
ActivityCardView    — timeline of AssessmentActivity events
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each card pulls its own data slice from RTK Query. Each card has its own permission check ("does this user see this card at all?"). Each card has its own mutations through per-action sub-hooks (&lt;code&gt;useCreateFinding&lt;/code&gt;, &lt;code&gt;useEvidenceRequest&lt;/code&gt;, &lt;code&gt;useAssignFindingOwner&lt;/code&gt;). The model hook composes them; it doesn't own them.&lt;/p&gt;

&lt;p&gt;Dropping a card is deleting a file and removing one line of composition. Adding a card is the inverse. Domain variants — one workflow needs an extra "Approvers" card; another does not need Evidence — are configuration, not surgery.&lt;/p&gt;

&lt;p&gt;This is the same decoupling that &lt;a href="https://dev.to/coreola/the-five-problems-hiding-inside-every-admin-table-and-what-to-do-about-them-c4p"&gt;post #2&lt;/a&gt; talked about for tables: orthogonal definitions that share only an &lt;code&gt;id&lt;/code&gt;. The card stack is the same idea, scaled up one level: orthogonal sub-views that share only a parent entity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;Five decisions, all of them invisible to a template that only models three CRUD screens. All of them obvious — almost trivial — once you've tried to build a real workflow and hit the wall the naive approach builds for you.&lt;/p&gt;

&lt;p&gt;This is what "tested against a real workflow" means. Not "we have a working app with auth and a customer list." That's a starter template. A foundation is tested against something with multiple lifecycles, orthogonal dimensions, sub-resources, deep links, and actions that don't fit into a single CRUD screen — because that's the shape every operational product takes eventually.&lt;/p&gt;

&lt;p&gt;The patterns from the previous three posts — server-driven tables, URL-scoped state, hidden-not-disabled permissions, decoupled definitions — were the inputs. The Assessments module is the output. Patterns are cheap to write down and expensive to verify. The verification is the foundation.&lt;/p&gt;

&lt;p&gt;If you're starting an admin product and your workflow looks anything like this — items with statuses, sub-resources, decisions, and operator queues — the shape transfers. Rename the entity. Adjust the cards. Keep the conventions. The floor is already laid.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Coreola is a React admin foundation with the Assessments module built in as a reference implementation of these patterns. It can be adapted as a starting point for claims, tickets, applications, audits, reviews, or any workflow with statuses, sub-resources, decisions, and operator queues. Live demo at &lt;a href="https://demo.coreola.com" rel="noopener noreferrer"&gt;demo.coreola.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Five problems hiding inside every admin permissions setup</title>
      <dc:creator>Coreola</dc:creator>
      <pubDate>Mon, 08 Jun 2026 08:25:30 +0000</pubDate>
      <link>https://dev.to/coreola/five-problems-hiding-inside-every-admin-permissions-setup-ol2</link>
      <guid>https://dev.to/coreola/five-problems-hiding-inside-every-admin-permissions-setup-ol2</guid>
      <description>&lt;h2&gt;
  
  
  Permissions look like an if-statement. By the time you have three roles and one custom rule, it's a system — and most teams discover the mistakes in production. Here's the map.
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/coreola/the-five-problems-hiding-inside-every-admin-table-and-what-to-do-about-them-c4p"&gt;previous post&lt;/a&gt; was about tables — one of the eight items on the floor of every admin product, and a representative example of what happens when "a component" turns into "a system." Five problems, one shape.&lt;/p&gt;

&lt;p&gt;Permissions is the next item on that floor. It has the same five-shaped pattern, with completely different surface details, and it bites teams earlier and harder than tables do. The difference: when a table is wrong, the symptom is a slow page or a confused user. When permissions are wrong, the symptom is a data breach or an audit finding.&lt;/p&gt;

&lt;p&gt;This post takes permissions apart the same way. Five problems, in the order they appear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: role strings stop working
&lt;/h2&gt;

&lt;p&gt;It starts as &lt;code&gt;if (user.role === 'admin')&lt;/code&gt;. One check, one role, ships. It works for two months.&lt;/p&gt;

&lt;p&gt;Then product asks for a second admin who can do &lt;em&gt;most&lt;/em&gt; of what the first admin can — but not delete customers. So you add &lt;code&gt;if (user.role === 'admin' || user.role === 'manager')&lt;/code&gt; and a few inverse checks for the delete case. Now there are two role strings appearing across the codebase, and they're not always consistent.&lt;/p&gt;

&lt;p&gt;Then a customer asks for a "viewer" role for their auditor — read-only, but read-only of &lt;em&gt;which&lt;/em&gt; resources? Some tenants want auditors to see findings but not financial details. Now you're inside &lt;code&gt;if (user.role === 'admin' || (user.role === 'auditor' &amp;amp;&amp;amp; tenant.financialAccess))&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the moment role strings die. The fix isn't to make the strings smarter; it's to stop treating the role &lt;em&gt;as&lt;/em&gt; the permission and start treating the role as a &lt;em&gt;grouping of permissions&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That requires three concepts, not one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Abilities&lt;/strong&gt; define what's possible at all. &lt;code&gt;assessment&lt;/code&gt; has the actions &lt;code&gt;read / create / update / delete&lt;/code&gt;. &lt;code&gt;customer&lt;/code&gt; has its own set. These are the &lt;em&gt;atoms&lt;/em&gt; of the permission system, edited by an admin and (rarely) by a developer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roles&lt;/strong&gt; bundle abilities into named groups. A role is a name plus a matrix — &lt;em&gt;"for the &lt;code&gt;assessment&lt;/code&gt; ability, this role can read and create but not update or delete."&lt;/em&gt; The "admin" role and the "auditor" role are two such bundles. Adding a new role is a backend change with no frontend impact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Users&lt;/strong&gt; have roles — at least one, sometimes several. A user who is both an "auditor" and a "support agent" gets the union of those two matrices. The effective permissions arrive at the frontend as a flattened matrix on the user record, computed at sign-in so the frontend doesn't have to do the join:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;abilities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;assessment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...one entry per resource the user can touch&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This three-level structure is what makes the system scale. Adding a permission is editing one role. Granting a user a temporary capability is adding a role to their record. Building a "support agent + auditor" hybrid is assigning two existing roles to one user. Adding a new role should be a data or admin-side change, not a frontend change — the frontend should consume the resulting ability matrix without caring whether the role is called &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;auditor&lt;/code&gt;, or &lt;code&gt;support_agent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coreola&lt;/strong&gt; ships this exact model. The accounts module has three resources: Abilities (the atomic definitions), Roles (named bundles with matrices), and Users (assigned to roles). The user record arriving from sign-in carries the flattened matrix on &lt;code&gt;user.abilities&lt;/code&gt;. A &lt;code&gt;useAbility()&lt;/code&gt; hook builds a CASL &lt;code&gt;Ability&lt;/code&gt; from it, and feature code calls &lt;code&gt;ability.can('read', 'assessment')&lt;/code&gt;. No role strings appear in feature code — only abilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: permission checks scattered everywhere
&lt;/h2&gt;

&lt;p&gt;A button has an inline check. A route file has a different inline check. A menu item has yet another. The redirect resolver — the thing that decides where to send a user landing on &lt;code&gt;/&lt;/code&gt; — has its own logic for "where is this user allowed to go first?"&lt;/p&gt;

&lt;p&gt;Four pieces of code, four checks, almost-but-not-quite the same. A bug in one of them is invisible until someone clicks just the right way and lands in a place they shouldn't be.&lt;/p&gt;

&lt;p&gt;The shape that survives: &lt;strong&gt;one predicate, many call sites.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;canAnyAbility&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AppAbility&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;abilityCan&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;abilityCan&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;abilityCan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;abilityKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;canByAbilityKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ability&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;abilityKey&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;abilityCan&lt;/code&gt; is an array of &lt;code&gt;"subject.action"&lt;/code&gt; keys — &lt;code&gt;"assessment.read"&lt;/code&gt;, &lt;code&gt;"customer.delete"&lt;/code&gt; — and &lt;code&gt;canByAbilityKey&lt;/code&gt; splits the key, normalizes singular/plural subject variants, and asks CASL whether any of them are allowed. The whole helper is one small function consumed by every gating surface in the app.&lt;/p&gt;

&lt;p&gt;That function is called from the route filter, the menu builder, and the redirect resolver. Three call sites, one rule. A change to the rule changes all three at once. A user who is forbidden a route never sees it in the sidebar, never gets redirected into it, and can't navigate to it directly.&lt;/p&gt;

&lt;p&gt;This is the part that looks obvious in retrospect and almost nobody gets right the first time. The instinct is to write the check next to the consumer ("the sidebar handles its own logic"). The cost shows up six months later when the rules diverge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coreola&lt;/strong&gt; funnels every gating decision through &lt;code&gt;filterRoutesByAbility&lt;/code&gt;. The router uses it. The menu tree uses it. The index-redirect resolver uses it. A forbidden route does not appear in any of those surfaces because the same function decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: hide vs disable
&lt;/h2&gt;

&lt;p&gt;A user lacks permission to delete a customer. What should the Delete button look like?&lt;/p&gt;

&lt;p&gt;The intuitive answer is to render it disabled — grey, unclickable, with a tooltip explaining why. It feels honest: "this feature exists; you just can't use it."&lt;/p&gt;

&lt;p&gt;It's the wrong answer most of the time, and here's why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A disabled control invites questions. "Why can't I delete this?" becomes a support ticket. Multiply that by every gated action in the product.&lt;/li&gt;
&lt;li&gt;It makes the UI heavier than it needs to be. Twelve disabled controls per row of a table is visual noise, not information.&lt;/li&gt;
&lt;li&gt;Screenshots for support and docs are worse, because the disabled state varies per user and the screenshots are taken by admins who don't see the disabled version anyway.
The convention that holds up: &lt;strong&gt;hide forbidden controls.&lt;/strong&gt; Render them only when the user can act. If a user with the manager role doesn't see Delete, they don't ask why — they assume the product simply doesn't offer it to them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The exception is &lt;em&gt;discovery&lt;/em&gt;. If a feature is gated as part of a trial or upgrade ("Pro users can export to PDF"), the user should know it exists so they can opt in. For that case, render an empty-state or banner with a "request access" affordance — not a disabled button.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coreola&lt;/strong&gt; treats hide-don't-disable as a project-wide convention. Permission checks at the component level early-return null instead of rendering a disabled variant. Discovery cases get their own empty-state patterns, separate from the gating system. This is the kind of decision that has to be made once across the codebase or it never gets made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: frontend permissions as security
&lt;/h2&gt;

&lt;p&gt;A junior engineer hides the Delete button when the user lacks permission. The button doesn't render. They move on.&lt;/p&gt;

&lt;p&gt;Three weeks later a curious user opens DevTools, finds the API endpoint, fires &lt;code&gt;fetch('/customers/123', { method: 'DELETE' })&lt;/code&gt;, and the customer is gone.&lt;/p&gt;

&lt;p&gt;This isn't a hypothetical. Many teams eventually learn this the hard way. The mistake is treating the frontend's role as &lt;em&gt;security&lt;/em&gt; when it's actually &lt;em&gt;UX&lt;/em&gt;. The frontend hides things to keep the interface coherent; the backend has to refuse things to keep the data safe.&lt;/p&gt;

&lt;p&gt;The rule that works is defense in depth: &lt;strong&gt;frontend gates routes and components; backend enforces every mutation.&lt;/strong&gt; Both layers do their own check, independently, against the same permission rules. The frontend hides the button. The backend returns 403 if anyone asks anyway. If either layer is missing, the system is broken — but the frontend missing is a worse user experience, while the backend missing is a vulnerability.&lt;/p&gt;

&lt;p&gt;The corollary: &lt;strong&gt;never write &lt;code&gt;try/catch&lt;/code&gt; around a forbidden mutation to "handle" the 403.&lt;/strong&gt; If a user can fire a mutation they shouldn't be able to fire, the gate is missing — not the catch handler. Add the gate; don't catch the 403.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coreola&lt;/strong&gt; ships the frontend permission layer: route gates, navigation filtering, component-level checks, and mutation-level UI gates. The production backend still has to enforce the same rules independently. The mock backend in development is for prototyping, not security; the real backend you wire up is where the actual enforcement lives. This is one of the few places where the foundation deliberately stops short and forces the consumer to wire it correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: feature flags and permissions get conflated
&lt;/h2&gt;

&lt;p&gt;A team adds a feature flag for an export-to-PDF rollout. Then someone says "we also want this to be admin-only." The path of least resistance is to wire both checks into the flag — turn off the flag for non-admins, turn it on for admins.&lt;/p&gt;

&lt;p&gt;This breaks within a week. Flags are environment-scoped — they exist to turn things on and off for everyone in a deploy or tenant. Permissions are user-scoped — they exist to grant or deny actions per user. Conflating them creates a flag that's actually a permission, which makes it impossible to roll out the feature to all admins without also turning it on for non-admins.&lt;/p&gt;

&lt;p&gt;The two mechanisms are deliberately separate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Feature flags&lt;/th&gt;
&lt;th&gt;Permissions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;Per-environment&lt;/td&gt;
&lt;td&gt;Per-user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;Short-lived; removed when stable&lt;/td&gt;
&lt;td&gt;Permanent; tied to the resource model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audience for changes&lt;/td&gt;
&lt;td&gt;Engineering / product&lt;/td&gt;
&lt;td&gt;Customer admins / operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;Server-side config&lt;/td&gt;
&lt;td&gt;User record (matrix)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A route or component can gate on &lt;strong&gt;both&lt;/strong&gt;. "Show the export button when the export flag is enabled AND the user can export." Two independent checks, composed at the call site. Either one off, the button is hidden.&lt;/p&gt;

&lt;p&gt;The temptation to merge them is strongest when a feature is new and only used by one role. Resist it. The day the feature graduates to general availability, you'll want to flip the flag without touching the permission model — and that only works if they were never the same thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coreola&lt;/strong&gt; keeps these strictly separate. Routes can declare &lt;code&gt;abilityCan: [...]&lt;/code&gt; and &lt;code&gt;featureFlagCan: [...]&lt;/code&gt; independently. There are two hooks, &lt;code&gt;useAbility&lt;/code&gt; and &lt;code&gt;useFeatureFlag&lt;/code&gt;. The admin UI for managing each lives in a different place — abilities under Accounts → Roles, flags under Settings → Feature Flags — because the people who change them are different people with different intentions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, again
&lt;/h2&gt;

&lt;p&gt;Re-read those five and the shape from the table post echoes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The naive model breaks&lt;/strong&gt; — role strings; the array-of-rows table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logic ends up in the wrong place&lt;/strong&gt; — checks duplicated next to consumers; state duplicated in components instead of the URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two stores or surfaces collide&lt;/strong&gt; — flags and abilities mistakenly merged; two tables fighting over &lt;code&gt;?page&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reconciliation rule is needed&lt;/strong&gt; — defense in depth between frontend and backend; URL state vs. user preferences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A discipline is needed to keep things separate&lt;/strong&gt; — hide-don't-disable as a project convention; decoupled column/filter/query definitions.
Tables solve it with one shape. Permissions solve it with another. Forms will be a third. Notifications a fourth. The eight items on the floor share this pattern, and the work of building each one well is the work of recognizing which of the five problems you're currently in front of and applying the right fix.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is what makes the floor expensive when you build it alone — you have to discover the pattern from scratch at every layer. And it's what makes the floor a solvable problem once: encode the pattern, test it on a real workflow, fork it into every new product.&lt;/p&gt;




&lt;p&gt;The next post takes one of those real workflows apart — the Assessments module — and shows how the abstract patterns from these three posts hold up against statuses, sub-resources, and a decision flow that runs separately from status. That's the test most templates skip and where most foundations crack.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Coreola is a React admin foundation that has CASL-based permissions wired at the route, navigation, and component level, with feature flags as an independent layer and a working ability matrix tested against a real workflow module. Live demo at &lt;a href="https://demo.coreola.com" rel="noopener noreferrer"&gt;demo.coreola.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>softwareengineering</category>
      <category>systemdesign</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The five problems hiding inside every admin table (and what to do about them)</title>
      <dc:creator>Coreola</dc:creator>
      <pubDate>Mon, 01 Jun 2026 08:00:17 +0000</pubDate>
      <link>https://dev.to/coreola/the-five-problems-hiding-inside-every-admin-table-and-what-to-do-about-them-c4p</link>
      <guid>https://dev.to/coreola/the-five-problems-hiding-inside-every-admin-table-and-what-to-do-about-them-c4p</guid>
      <description>&lt;h2&gt;
  
  
  An admin table looks like one component. By the time you've shipped five, it's a system — and most of the work happens in places you didn't budget for. Here's the map.
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/coreola/the-floor-problem-why-every-admin-product-wastes-3-months-on-the-same-foundation-3pdk"&gt;previous post&lt;/a&gt; named the floor problem: every admin product spends two or three months rebuilding the same eight things before anyone can build the actual product. Auth, RBAC, theming, routing, forms, notifications, feature flags — and tables. Eight items on the list. Eight things every team underestimates.&lt;/p&gt;

&lt;p&gt;Today I want to take one of them apart. Not because tables are the most interesting item on the floor — they aren't — but because they are the most representative. The mistakes teams make on tables are the same mistakes they make on the other seven floor items, in miniature. If you understand what's actually inside a "table," the rest of the floor becomes legible.&lt;/p&gt;

&lt;p&gt;A table looks like one component. By the time you've shipped five of them, it's a system. Most of the engineering happens in places you didn't budget for.&lt;/p&gt;

&lt;p&gt;Here are the five problems hiding inside that system, in roughly the order they bite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: client-side data stops working
&lt;/h2&gt;

&lt;p&gt;You start with &lt;code&gt;const [rows, setRows] = useState&amp;lt;Customer[]&amp;gt;([])&lt;/code&gt;, fetch all of them on mount, filter and sort in memory. It works. It works for weeks.&lt;/p&gt;

&lt;p&gt;Then one of three things happens. The dataset grows past 10,000 rows and the first render takes three seconds. Or the backend team adds a permission rule and "all of them" turns into "all of them this user can see" — a computed set the frontend can no longer fetch in one shot. Or a PM asks for "show me everything modified in the last 30 days," and you realize the frontend has to fetch the full dataset just to answer a question the database could answer in milliseconds.&lt;/p&gt;

&lt;p&gt;The naive fix is to keep the same architecture and add a &lt;code&gt;useMemo&lt;/code&gt; for the filtering. That buys a week. The real fix is to invert the relationship between frontend and backend: the table sends a &lt;em&gt;query&lt;/em&gt;, the backend returns &lt;em&gt;one page of results plus a total count&lt;/em&gt;. Pagination, sorting, filtering, and search all live on the server. The frontend holds maybe 50 rows at a time, regardless of how many rows exist.&lt;/p&gt;

&lt;p&gt;This is the moment "the table" stops being a component and starts being a query model. And every subsequent problem on this list is downstream of that fact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coreola&lt;/strong&gt; ships with this inversion done. The query shape is a small, opinionated type — page, page size, search, sort, filters — and every list view in the codebase speaks it. The customers list, the assessments queue, the audit log — all the same shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: filtered views are unshareable
&lt;/h2&gt;

&lt;p&gt;A user filters the customer list to "active, US, signed up in the last 30 days." They Slack the URL to a colleague. The colleague opens the link and sees the unfiltered list.&lt;/p&gt;

&lt;p&gt;This is a small UX paper cut and a large indicator of a broken architecture. If the filter lives in component state (or Redux, or a Zustand store, or a Context), it doesn't exist in the URL, and the URL is the only thing the colleague received.&lt;/p&gt;

&lt;p&gt;The fix is to make the URL the source of truth for table state. Page, page size, search, sort, filters — all serialized to query params. Component state mirrors the URL, not the other way around. Now a share link is a complete description of what the recipient should see. Bookmarks work. The back button works. "Send me what you're looking at" works.&lt;/p&gt;

&lt;p&gt;This sounds obvious until you try to implement it. The first version is fine. The second version — with multi-select filters, ranges, and default values — gets messy. The fifth version has helper functions everyone else on the team is afraid to touch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coreola&lt;/strong&gt; treats the URL as the table's interaction model. There's one hook that owns the URL ↔ state contract, including the cases that look small until they aren't: stripping default values from the URL (so share links don't grow garbage), resetting page to 1 on filter change (the most common URL-state bug), and using &lt;code&gt;replace: true&lt;/code&gt; for filter edits but &lt;code&gt;push&lt;/code&gt; for page navigation (so the back button does what users expect).&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: two tables on one page fight
&lt;/h2&gt;

&lt;p&gt;A dashboard needs two tables side by side. Or a details view has a tab for "related items" that itself is a table. Now both tables want to use &lt;code&gt;?page=2&lt;/code&gt; and they collide.&lt;/p&gt;

&lt;p&gt;The naive fix is to give each table its own slice of state and skip the URL entirely for the second one. Now you have two tables with two different behaviors — the first is shareable, the second isn't — and engineers have to remember which is which.&lt;/p&gt;

&lt;p&gt;The real fix is to scope URL keys per table. Instead of &lt;code&gt;?page=2&lt;/code&gt;, you write &lt;code&gt;?customers.page=2&amp;amp;assessments.page=0&lt;/code&gt;. Every table is identified by an ID, and that ID prefixes its keys. Three tables on the same page do not collide. A fourth table can be added without touching the first three.&lt;/p&gt;

&lt;p&gt;This is one of those decisions you have to make on table #1, or you pay for it on table #5. Retrofitting it across an existing codebase means touching every table at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coreola&lt;/strong&gt; scopes everything by &lt;code&gt;tableId&lt;/code&gt;. Multiple tables coexist on a page without any extra wiring. The customers detail view, which embeds an activity log table inside a tab on a page that already has a breadcrumb-driven layout, is a normal config — not a special case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: user preferences and share links collide
&lt;/h2&gt;

&lt;p&gt;The user resizes their columns, hides one, and sets their preferred page size to 50. The next morning they reload — and everything is back to defaults. So you add localStorage. Now their preferences persist on their machine, but not on their phone. So you move it to the backend. Now it syncs across devices, and you're done.&lt;/p&gt;

&lt;p&gt;Except now a colleague shares a filtered URL. Should the recipient see the &lt;em&gt;filters&lt;/em&gt; from the URL but their &lt;em&gt;own&lt;/em&gt; column layout? Yes — they're different concerns. The filters say "what data," the layout says "how the recipient prefers to look at data." These should never have been the same state.&lt;/p&gt;

&lt;p&gt;So the system has two stores: ephemeral (URL — filters, search, sort, page) and durable (per-user — column visibility, widths, default page size, default sort, saved filter presets). And then there's a hydration problem: what happens when both have an opinion about the page size for this user, but the URL has filters that aren't in the user's saved defaults?&lt;/p&gt;

&lt;p&gt;The rule that works: URL wins where it speaks. Stored preferences fill the gaps. If the URL specifies a page size, use it. If not, look at the user's saved default. If neither, fall back to the system default.&lt;/p&gt;

&lt;p&gt;Implementing this without bugs requires a hydration phase that the data fetch waits for — otherwise the table fires its first query with the wrong filters, gets the wrong data, then fires a second query with the right filters and replaces the data. The user sees a flash. On a slow network it's two flashes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coreola&lt;/strong&gt; separates the two stores explicitly and gates the data query on an &lt;code&gt;isHydrated&lt;/code&gt; flag that goes true only after URL state and saved preferences have been reconciled. One fetch, with the right parameters. No flashes. Share links override defaults; defaults fill gaps; nothing leaks between users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: definitions get tangled
&lt;/h2&gt;

&lt;p&gt;By table #3, the column array knows about the filter array. The filter array knows about the server query. The server query knows about the column array because some columns are computed from filtered relationships. Adding a column requires changes in four files.&lt;/p&gt;

&lt;p&gt;This is the version of the floor problem that gets you in year two. It doesn't look bad at first — the codebase has tables, the tables work, features ship. But every new field touches every layer. Every refactor is high-risk. The team starts avoiding the table code.&lt;/p&gt;

&lt;p&gt;The fix is to decouple three definitions that share only an &lt;code&gt;id&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ColumnDef&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;render&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FilterDef&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;control&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;select&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;date-range&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;QueryShape&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;search&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The column knows how to render a cell. The filter knows what control to show. The query knows what to send to the server. They share an &lt;code&gt;id&lt;/code&gt; (matching the backend field name) and nothing else.&lt;/p&gt;

&lt;p&gt;Now adding a sortable column is a one-line change. Adding a filter is appending to an array. Hiding a column doesn't touch filters. Disabling sort on one column doesn't ripple anywhere.&lt;/p&gt;

&lt;p&gt;This is the part you cannot retrofit. If your codebase started with intertwined definitions, the only way out is a rewrite. Start decoupled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coreola&lt;/strong&gt; keeps these three definitions strictly separate. The same &lt;code&gt;&amp;lt;Table&amp;gt;&lt;/code&gt; component accepts a &lt;code&gt;columns&lt;/code&gt; array, an optional &lt;code&gt;filters&lt;/code&gt; array, and independent feature flags (&lt;code&gt;withSearch&lt;/code&gt;, &lt;code&gt;withExport&lt;/code&gt;, &lt;code&gt;withColumnsResize&lt;/code&gt;). Every table in the codebase follows the same shape, which is the deeper payoff: the second engineer on the project reads three tables and predicts the fourth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern beyond tables
&lt;/h2&gt;

&lt;p&gt;Five problems, one shape:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The naive model breaks at real scale.&lt;/li&gt;
&lt;li&gt;State ends up in the wrong place (component, not URL).&lt;/li&gt;
&lt;li&gt;Two instances of the same thing collide because nothing scoped them.&lt;/li&gt;
&lt;li&gt;Two stores with overlapping responsibilities need a reconciliation rule.&lt;/li&gt;
&lt;li&gt;Definitions that should have been orthogonal grew couplings.
If you reread that list with "tables" replaced by "permissions," "forms," "notifications," or "feature flags," it's almost the same list. Permissions: the naive &lt;code&gt;if (user.role === 'admin')&lt;/code&gt; breaks the moment two users with the same role have different permissions on different rows. Forms: validation logic in the component vs. in the schema vs. in the server response — three stores that need a reconciliation rule. Notifications: a toast fired from inside a Redux thunk needs to reach a React provider that wasn't designed for it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The floor isn't eight small problems. It's one structural problem with eight surface symptoms. And the work of building it well is, on every item, the same work: invert the naive model, put state where it belongs, scope what can collide, write a reconciliation rule where stores overlap, decouple what shouldn't have been one definition.&lt;/p&gt;

&lt;p&gt;Doing that work once is a foundation. Doing it eight times is most of a year.&lt;/p&gt;




&lt;p&gt;This was the table version. The next post is the permission version — same five-shaped problem, completely different surface. If you've ever written &lt;code&gt;if (user.role === 'admin')&lt;/code&gt; and thought "this'll be fine," it's for you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Coreola is a React admin foundation that has these five problems solved for tables — URL state, scoped per table, reconciled with user preferences, decoupled column/filter/query definitions, all wired into a working customers list and assessments queue. Live demo at &lt;a href="https://demo.coreola.com" rel="noopener noreferrer"&gt;demo.coreola.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The floor problem: why every admin product wastes 3 months on the same foundation</title>
      <dc:creator>Coreola</dc:creator>
      <pubDate>Tue, 26 May 2026 09:30:36 +0000</pubDate>
      <link>https://dev.to/coreola/the-floor-problem-why-every-admin-product-wastes-3-months-on-the-same-foundation-3pdk</link>
      <guid>https://dev.to/coreola/the-floor-problem-why-every-admin-product-wastes-3-months-on-the-same-foundation-3pdk</guid>
      <description>&lt;h2&gt;
  
  
  Every admin product starts by rebuilding the same eight things — auth, RBAC, tables, forms, theming, i18n, feature flags, routing. None of it is the product. Here's why this keeps happening, and what to do instead.
&lt;/h2&gt;

&lt;p&gt;Every internal tool, SaaS back office, and operational admin product begins life the same way. Someone opens an empty Vite project. The team picks a UI library. Then they spend three months building the same things every other admin product has already built.&lt;/p&gt;

&lt;p&gt;Auth with token refresh. A permissions system. A data table that does server pagination, sorting, filtering, search, column reorder, and CSV export. A form stack with consistent validation. Empty states. Loading states. Error states. A notifications system. Light and dark themes. A language switcher. Feature flags. A routing setup that drives navigation and breadcrumbs from a single source.&lt;/p&gt;

&lt;p&gt;Then — and only then — does the team start building the actual product.&lt;/p&gt;

&lt;p&gt;This is the floor problem. The floor is the work you have to do before the product can stand on it. And almost nobody talks about how much of it there is, or how badly teams rebuild it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The floor is bigger than people remember
&lt;/h2&gt;

&lt;p&gt;If you've shipped one admin product, you remember the floor as a few weeks of setup. If you've shipped three, you remember it as months. The reason is that the floor reveals itself slowly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Week 1 looks easy. You have a sidebar, a couple of routes, a login page hitting &lt;code&gt;/auth&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Week 4 is where the table grows from "list of rows" to "server-paginated, with URL state, persistent column preferences, and a working CSV export."&lt;/li&gt;
&lt;li&gt;Week 8 is where permissions stop being a hardcoded &lt;code&gt;if (user.role === 'admin')&lt;/code&gt; check and become a real ability matrix that gates navigation items, route access, and individual action buttons.&lt;/li&gt;
&lt;li&gt;Week 12 is where someone says "we need feature flags for the rollout," and now there's a config UI to build, a flag-checking hook to write, and a route guard to wire in.
Multiply that by every admin product the team has built in the last five years and the cost gets uncomfortable. Most teams have rebuilt all of this — slightly differently — at every job.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the floor actually contains
&lt;/h2&gt;

&lt;p&gt;Here is the honest list. If you're building a serious admin product, you will need every one of these, and skipping any of them creates a worse version later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Application foundation&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication with session persistence and silent token refresh&lt;/li&gt;
&lt;li&gt;A state layer that survives reloads (auth, theme, language, table preferences)&lt;/li&gt;
&lt;li&gt;Centralized API error handling and notification surfacing&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A routing system that drives the sidebar, breadcrumbs, and permission checks from one source of truth&lt;br&gt;
&lt;strong&gt;Access control&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A real RBAC model — abilities, not just role strings&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Route-level guards&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Component-level checks (hiding the "Approve" button when the user lacks the ability)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An admin UI to edit roles and abilities without a code change&lt;br&gt;
&lt;strong&gt;Data UX&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A table with server-side pagination, sort, filter, and search&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Column visibility, reorder, and resize, persisted per user&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;URL-driven state so a filtered view can be bookmarked or shared&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;CSV export&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One canonical empty state, loading state, and error state across the app&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A filter system that is declarative and serializable to URL and backend&lt;br&gt;
&lt;strong&gt;Forms&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A form stack with consistent validation, error mapping from server responses, and a single canonical pattern&lt;br&gt;
&lt;strong&gt;System features&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Feature flags with route- and component-level gating, plus a UI to manage them&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Theme switching (light/dark) with no flash on reload&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Internationalization with chained backends so translations can load from disk in dev and HTTP in production&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A notifications system reachable from non-React code (RTK Query base queries, slices, anywhere)&lt;br&gt;
&lt;strong&gt;Engineering conventions&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A way to structure a page so every page in the codebase has the same shape&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A scaffolding tool so new pages don't drift&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A linter setup, a formatter setup, hooks for pre-commit, a circular-import detector&lt;br&gt;
None of this is your product. All of it is required.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why teams keep rebuilding it
&lt;/h2&gt;

&lt;p&gt;You'd expect the industry to have solved this by now. There are component libraries, starter templates, admin generators, low-code platforms. What is everyone doing?&lt;/p&gt;

&lt;p&gt;The honest answer is that the existing solutions cluster at two ends, and neither end works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UI kits&lt;/strong&gt; give you the parts. Material UI, Ant Design, shadcn/ui — these are excellent. They are also the bottom 10% of the work. They give you a &lt;code&gt;&amp;lt;Table&amp;gt;&lt;/code&gt; component, not a data table that knows how to paginate against your backend. They give you a &lt;code&gt;&amp;lt;Dialog&amp;gt;&lt;/code&gt;, not a confirmation pattern that fires a mutation and shows a notification. The rest is yours to assemble.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Starter templates&lt;/strong&gt; give you a working layout. They tend to be demo-grade. The auth is fake. The permissions are a &lt;code&gt;role: 'admin' | 'user'&lt;/code&gt; enum. The tables are client-side with hardcoded data. The first time you try to use one for a real workflow with statuses, sub-resources, and operator queues, it breaks down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Low-code platforms&lt;/strong&gt; give you a working product — until your requirements drift one millimeter from what the platform allows. Then you're working around the platform.&lt;/p&gt;

&lt;p&gt;So teams keep rebuilding. Each rebuild is slightly different. Each team's "ConfirmDialog" works slightly differently. Each team's &lt;code&gt;&amp;lt;DataTable&amp;gt;&lt;/code&gt; accepts slightly different props. The patterns proliferate, the codebases drift, and the second engineer on the project spends their first month learning bespoke conventions instead of building features.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden cost: architectural drift
&lt;/h2&gt;

&lt;p&gt;The visible cost of the floor is time. The hidden cost is drift.&lt;/p&gt;

&lt;p&gt;If your codebase doesn't have a strong convention for what a page is, every engineer who touches it invents one. Some pages keep business logic in the component. Others extract a hook. Others use a Redux thunk. Six months later you have eleven different page shapes and the next new feature has to pick one.&lt;/p&gt;

&lt;p&gt;If your codebase doesn't have a strong convention for tables, every engineer who needs a table picks a different one — server-side here, client-side there, with a custom wrapper on a third page. Now the user sees three subtly different table behaviors in the same product. Filter chips that look the same but behave differently. Pagination that resets in some places and persists in others.&lt;/p&gt;

&lt;p&gt;If your codebase doesn't have a strong convention for permissions, the gating logic ends up sprinkled. Some routes check abilities. Some buttons check roles. Some components check ownership inline. Adding a new role becomes a code archaeology project.&lt;/p&gt;

&lt;p&gt;This is what happens when there's no floor. Not catastrophe — just slow rot. The product becomes less coherent with every new module. Velocity decays. The third year of the codebase is much harder than the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "good" looks like
&lt;/h2&gt;

&lt;p&gt;A good admin foundation has three properties.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is opinionated.&lt;/strong&gt; There is one way to build a page. One way to define a route. One way to wire a table. One way to check a permission. The convention is explicit, documented, and enforced where it matters. A new engineer can read three pages and predict the fourth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is complete.&lt;/strong&gt; Auth, RBAC, tables, forms, dashboards, notifications, theming, i18n, feature flags, routing — all of it is wired together and working. You can sign in, see a list, click into a detail, edit a record, get a notification, switch language, toggle dark mode, and have your column preferences persist across reloads. Out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is real.&lt;/strong&gt; It has been tested against an actual operational workflow with statuses, sub-resources, queue views, and decision flows. Not a demo of three CRUD screens. Patterns that survive the second module are very different from patterns that look fine in the first.&lt;/p&gt;

&lt;p&gt;The "real" property is the one almost everyone gets wrong. Most templates pass the first two tests and fail the third. They have a working &lt;code&gt;&amp;lt;Table&amp;gt;&lt;/code&gt;, a working &lt;code&gt;&amp;lt;Form&amp;gt;&lt;/code&gt;, a working theme switcher — and then the moment you try to model a workflow with an item that has findings and evidence requests and a separate decision dimension, the template runs out of road and you're inventing patterns again.&lt;/p&gt;

&lt;h2&gt;
  
  
  A different way to think about it
&lt;/h2&gt;

&lt;p&gt;The unit of admin software is not the component. It is the pattern.&lt;/p&gt;

&lt;p&gt;A pattern is what happens when a component is used in the context of a real screen, with real data, real loading states, real error states, and real permissions. A &lt;code&gt;&amp;lt;Table&amp;gt;&lt;/code&gt; is a component. "A server-paginated list with URL-driven filters, persistent column preferences, and CASL-gated row actions" is a pattern. The pattern is what teams actually need. The component is the smallest of the inputs to it.&lt;/p&gt;

&lt;p&gt;If you build your admin product up from components, you spend the floor months assembling patterns. If you start from patterns, you spend that time on the product.&lt;/p&gt;

&lt;p&gt;This is why component libraries, no matter how good, never compress the floor by the amount you expect. They solve the smallest layer. The patterns above them are still yours to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;Three options, in increasing order of how much you give up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build the floor yourself, but be deliberate.&lt;/strong&gt; Pick the patterns up front. Document them. Enforce them. Scaffold them. Don't let twelve subtly-different table implementations grow in the codebase. This is the most flexible option and the most expensive. Worth it if your product is unusual or you have engineers who care about this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adopt a foundation.&lt;/strong&gt; Start from a codebase that already encodes the patterns and fork it into your product. The win is the months you don't spend on the floor. The cost is accepting somebody else's opinions on Redux vs. Zustand, MUI vs. Tailwind, MVVM vs. component-down. If their opinions are close to yours, this is the right trade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a low-code platform.&lt;/strong&gt; Fastest start, hardest ceiling. Right when the product is small and unusual flexibility isn't a requirement.
The middle option is the one most teams underuse, because the foundations they evaluate are usually starter templates pretending to be foundations. A real foundation has tested itself against a real workflow module, not three demo screens. The test is straightforward: open the demo, look for an entity with statuses &lt;em&gt;and&lt;/em&gt; sub-resources &lt;em&gt;and&lt;/em&gt; a decision flow separate from status. If it has all three, the patterns probably survive contact with reality. If it has none of them, you are looking at a UI kit with extra steps.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;The floor is real. The cost of rebuilding it is real. The cost of not having a convention for it is bigger than the cost of building it once.&lt;/p&gt;

&lt;p&gt;If you're starting an admin product this quarter and you've done this before, you already know which weeks are coming. The question is whether you want to spend them again.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Coreola is a production-ready React admin foundation that encodes these patterns — auth, CASL permissions, server-driven tables, forms, dashboards, feature flags, theming, i18n, and a working Assessments workflow module — in one opinionated codebase you fork into your product. Live demo at &lt;a href="https://demo.coreola.com" rel="noopener noreferrer"&gt;demo.coreola.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
