<?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: kensaadi</title>
    <description>The latest articles on DEV Community by kensaadi (@kensaadi).</description>
    <link>https://dev.to/kensaadi</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F486782%2Fab8acea4-8948-4e43-b744-7b31bf47a184.jpg</url>
      <title>DEV Community: kensaadi</title>
      <link>https://dev.to/kensaadi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kensaadi"/>
    <language>en</language>
    <item>
      <title>A cantonal parliament asked for one shared taxi dispatch. The government answered that it is the operators' business</title>
      <dc:creator>kensaadi</dc:creator>
      <pubDate>Thu, 05 Nov 2026 09:02:28 +0000</pubDate>
      <link>https://dev.to/kensaadi/a-cantonal-parliament-asked-for-one-shared-taxi-dispatch-the-government-answered-that-it-is-the-32oc</link>
      <guid>https://dev.to/kensaadi/a-cantonal-parliament-asked-for-one-shared-taxi-dispatch-the-government-answered-that-it-is-the-32oc</guid>
      <description>&lt;p&gt;On 31 January 2025 two members of the Ticino cantonal parliament, Tiziano Galeazzi and Roberta Soldati, filed a motion on the taxi sector. The point that matters to anyone actually running a dispatch operation is the second one: they ask for "a study of a unified solution (operations centre) for all taxi operators in Ticino".&lt;/p&gt;

&lt;p&gt;And they immediately specify what they do not mean: "not understood as cantonal administration, but as an operations centre", preferably run by private parties, so as not to weigh on cantonal finances.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else it asks
&lt;/h2&gt;

&lt;p&gt;Beyond the shared dispatch, the motion asks the municipalities and the canton to analyse the problem by reactivating a 2023 intercommunal working group, to put contracts, social benefits, wages and tariffs under a single regulation by abolishing the current A and B categories, to identify who should supervise the system, and to look at foreign experience, including capping the number of drivers.&lt;/p&gt;

&lt;p&gt;On that last point: it is the motion's request, reported as such. Software for licensed operators has no business holding a position on how many licences there should be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The government's answer, and the line that counts
&lt;/h2&gt;

&lt;p&gt;The State Council invited parliament not to follow up on the motion. The main reason is municipal autonomy: the proposal, it writes, "would tangibly affect the current municipal autonomy in defining and managing local specificities". It adds that it does not want to take "the indicated path of cantonalisation", preferring to leave room for different solutions across the territory.&lt;/p&gt;

&lt;p&gt;So far, an ordinary refusal. Then comes the sentence that is worth more than the rest. An operations centre run by private parties, the government says, "falls within the freedom of commerce and organisation of those who wish to offer taxi services".&lt;/p&gt;

&lt;p&gt;Read for what it is: the canton will not build that dispatch centre, and it puts in writing that building it is up to whoever offers the service. It is not a door closing. It is a door pointed at somebody else.&lt;/p&gt;

&lt;p&gt;This is worth stating precisely, because it is easy to misread: the government said no to a public solution and observed that a private one is legitimate. It did not endorse anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ten years, same place
&lt;/h2&gt;

&lt;p&gt;This is not the first attempt. In 2015 a member of parliament filed a motion for a cantonal taxi regulation modelled on Zurich. That act, according to local press, was withdrawn on 11 December 2023 without having produced the regulation.&lt;/p&gt;

&lt;p&gt;Ten years between the first request and the second, and Ticino still regulates taxis municipality by municipality. In Lugano alone, more than two hundred professionals compete for the same ground.&lt;/p&gt;

&lt;p&gt;Anyone waiting for the solution to arrive from above now has a track record to reason from, and it says the same thing every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on sourcing
&lt;/h2&gt;

&lt;p&gt;The number of the parliamentary act and its current status could not be verified on the primary source: the cantonal parliament's own site refused connection from here, repeatedly. So the motion is cited through the press, no act number appears, and nothing is claimed about how the vote went. If and when the primary source is reachable, that detail belongs in the piece.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is left, and to whom
&lt;/h2&gt;

&lt;p&gt;Three things are known. Parliament thinks a shared dispatch would help. The government will not build it. And the same government writes that building it falls within the freedom of those who offer the service.&lt;/p&gt;

&lt;p&gt;There is no fourth party in that list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Original (Italian):&lt;/strong&gt; &lt;a href="https://urbango.ch/it-CH/journal/centrale-unica-ticino" rel="noopener noreferrer"&gt;In Ticino la centrale unica l'ha chiesta il parlamento&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://urbango.ch" rel="noopener noreferrer"&gt;UrbanGo&lt;/a&gt;, dispatch software for licensed taxi and private-hire operators. Everything above is sourced; this paragraph is the part where I sell something.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>news</category>
      <category>discuss</category>
      <category>mobility</category>
      <category>regulation</category>
    </item>
    <item>
      <title>Paris announced its robotaxi consortium. None of the three signatories runs vehicles today</title>
      <dc:creator>kensaadi</dc:creator>
      <pubDate>Wed, 04 Nov 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/kensaadi/paris-announced-its-robotaxi-consortium-none-of-the-three-signatories-runs-vehicles-today-58m4</link>
      <guid>https://dev.to/kensaadi/paris-announced-its-robotaxi-consortium-none-of-the-three-signatories-runs-vehicles-today-58m4</guid>
      <description>&lt;p&gt;On 23 October 2026 the Métropole du Grand Paris, Bolt and Stellantis presented the Greater Paris Autonomous Driving Living Lab: a consortium to run autonomous vehicles in the Paris metropolitan area. Testing should begin in 2028 in at most five municipalities to the north-west, a second phase would study extending to around thirty more, and commercial service is the target for 2030.&lt;/p&gt;

&lt;p&gt;All of it conditional on authorisations, as the partners themselves specify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who does what
&lt;/h2&gt;

&lt;p&gt;The division of labour is the most instructive part of the announcement.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Métropole du Grand Paris&lt;/strong&gt; coordinates with the municipalities, identifies suitable routes and oversees the evaluation of results. &lt;strong&gt;Stellantis&lt;/strong&gt; supplies the vehicles, built on its "L4-Ready" platforms. &lt;strong&gt;Bolt&lt;/strong&gt;, through its subsidiary Bolt Autonomous Driving Solutions, leads service integration and deployment. One Italian outlet adds that Bolt's job is to organise the service as a whole, connecting vehicles, autonomous driving technology and the customer-facing digital platform; that is a single source and worth attributing rather than treating as settled fact.&lt;/p&gt;

&lt;p&gt;The driving technology itself is not assigned. Pony.ai is named as a potential supplier, alongside a reference to NVIDIA platforms, but it is not confirmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the announcement does not say
&lt;/h2&gt;

&lt;p&gt;Three things are missing, and together they weigh as much as what is there. No names of the municipalities, no fleet size, no investment figure.&lt;/p&gt;

&lt;p&gt;But the biggest gap is another one: &lt;strong&gt;nobody is named as the party that will run the cars day to day.&lt;/strong&gt; Buying them, insuring them, cleaning them, keeping them charged, sending them to the workshop, replacing one when it stops. That is the job a dispatch company does today with a fleet of people, and in this release it has no name.&lt;/p&gt;

&lt;p&gt;Of the three parties at the table, none does it today. A metropolitan authority does not run vehicle fleets. A carmaker builds. An intermediation platform intermediates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The map is drawn by two of them
&lt;/h2&gt;

&lt;p&gt;There is an element here that Zagreb and London did not show: the public authority is not the body authorising from outside, it is a partner in the consortium. The Métropole coordinates with the municipalities and identifies the routes.&lt;/p&gt;

&lt;p&gt;Which means the design of which areas get served, and in what order, is born at a table between an authority and a platform. Five municipalities to start, around thirty as a possible extension. Whoever already drives those streets with their own vehicles is not among the signatories.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, which is already decided
&lt;/h2&gt;

&lt;p&gt;In every European project announced so far the same shape recurs: someone builds the cars, someone supplies the autonomy, someone runs them, and someone else holds the booking. Four roles, and the fourth needs neither workshops nor depots.&lt;/p&gt;

&lt;p&gt;In Paris that fourth role went to the one signatory that today owns none of the things that move.&lt;/p&gt;

&lt;p&gt;The job of running the cars remains, and will remain. In the Paris consortium it has not been assigned to anyone yet, which means it is a vacancy. The question that matters is not whether it gets filled, but whether whoever fills it reaches the passenger through a relationship of their own, or through somebody else's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Original (Italian):&lt;/strong&gt; &lt;a href="https://urbango.ch/it-IT/journal/robotaxi-parigi-consorzio" rel="noopener noreferrer"&gt;A Parigi i robotaxi li annunciano Bolt, Stellantis e la Métropole&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://urbango.ch" rel="noopener noreferrer"&gt;UrbanGo&lt;/a&gt;, dispatch software for licensed taxi and private-hire operators. Everything above is sourced; this paragraph is the part where I sell something.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>news</category>
      <category>selfdriving</category>
      <category>mobility</category>
    </item>
    <item>
      <title>A Munich taxi company wants 500 robotaxis under its own brand, and on paper that is the opposite model</title>
      <dc:creator>kensaadi</dc:creator>
      <pubDate>Tue, 03 Nov 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/kensaadi/a-munich-taxi-company-wants-500-robotaxis-under-its-own-brand-and-on-paper-that-is-the-opposite-2l59</link>
      <guid>https://dev.to/kensaadi/a-munich-taxi-company-wants-500-robotaxis-under-its-own-brand-and-on-paper-that-is-the-opposite-2l59</guid>
      <description>&lt;p&gt;On 27 October 2026 Münchner Taxi Zentrum, the largest taxi dispatch company in Munich, and California-based Tensor Auto signed a letter of intent to bring up to 500 autonomous vehicles to Germany. Reuters reported it.&lt;/p&gt;

&lt;p&gt;Before looking at the model, it is worth fixing what this is not. It is not a fleet. It is a letter of intent, conditional on definitive agreements and on level 4 homologation in Germany. None of those cars is on the road, the financial terms were not disclosed, and everything that follows is how the two companies describe themselves in their own press release.&lt;/p&gt;

&lt;p&gt;With that said, it is worth reading, because it describes a structure opposite to every other European announcement so far.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they tell it
&lt;/h2&gt;

&lt;p&gt;According to the release, MTZ would run the vehicles under its own brand, inside its own taxi service. The Robocar "integrates with MTZ's existing dispatch systems, so riders book and ride with MTZ as they do today". Tensor sells the vehicle and supplies the autonomy on a recurring subscription it calls Autonomous Driving as a Service.&lt;/p&gt;

&lt;p&gt;Then the line worth stopping on, again from the release: "control of the fleet, the customer and the data stays with the taxi company and therefore in Europe."&lt;/p&gt;

&lt;p&gt;MTZ's chief executive, Gregor Beiner, puts it this way: MTZ will bring autonomous vehicles onto Munich's streets "under its own name, with the service quality and data sovereignty our customers trust". He adds that professional drivers "remain indispensable: wherever personal service, assistance or a human touch is needed, they make the difference".&lt;/p&gt;

&lt;p&gt;The stated goal beyond Germany is to place another 10,000 Robocars with European operators within twenty-four months of the vehicle becoming available in Europe, which is a date that does not exist yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it is different from everything else
&lt;/h2&gt;

&lt;p&gt;Line up the other European announcements.&lt;/p&gt;

&lt;p&gt;In Zagreb the rides reach the passenger through Uber's app, and Uber has invested in Verne, which owns the cars. In Zurich the service announced in June is booked from Uber's app, with a local company supervising the fleet. In Paris the consortium announced on 23 October puts together a public authority, a carmaker and Bolt, and the box for whoever will actually run the cars is still empty.&lt;/p&gt;

&lt;p&gt;In all three, whoever holds the booking is not whoever holds the cars.&lt;/p&gt;

&lt;p&gt;In the Munich announcement that same box is occupied by the dispatch company, and the technology is the thing being rented. If it really worked that way, it would be the first European case where the relationship with the passenger stays where it already was.&lt;/p&gt;

&lt;p&gt;The conditional is mandatory, because so far this is a document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two models on the table, four days apart
&lt;/h2&gt;

&lt;p&gt;The calendar coincidence is worth noticing. On 23 October, in Paris, Bolt puts itself forward to organise an entire robotaxi service. On 27 October, in Munich, MTZ announces it wants to run one while keeping brand, dispatch and customers. Four days.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to watch
&lt;/h2&gt;

&lt;p&gt;Not the number 500, which is conditional. Two other things.&lt;/p&gt;

&lt;p&gt;First, whether integration into the existing dispatch survives contact with the contract. That is the difference between a dispatch company adding vehicles to its own system and a dispatch company becoming somebody else's driver supplier, and the distance between the two is measured in clauses, not in press releases.&lt;/p&gt;

&lt;p&gt;Second, the sentence about data sovereignty. It comes from a technology vendor using it to sell, and that is exactly why it is interesting: control of data has become a commercial argument, something an operator asks about before signing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Original (Italian):&lt;/strong&gt; &lt;a href="https://urbango.ch/it-IT/journal/monaco-centrale-affitta-la-guida" rel="noopener noreferrer"&gt;A Monaco una centrale taxi annuncia 500 robotaxi sotto il proprio marchio&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://urbango.ch" rel="noopener noreferrer"&gt;UrbanGo&lt;/a&gt;, dispatch software for licensed taxi and private-hire operators. Everything above is sourced; this paragraph is the part where I sell something.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>news</category>
      <category>selfdriving</category>
      <category>mobility</category>
    </item>
    <item>
      <title>Lavoro su piattaforma e algoritmi: cosa cambia con la direttiva europea per chi progetta il software</title>
      <dc:creator>kensaadi</dc:creator>
      <pubDate>Mon, 02 Nov 2026 19:44:12 +0000</pubDate>
      <link>https://dev.to/kensaadi/lavoro-su-piattaforma-e-algoritmi-cosa-cambia-con-la-direttiva-europea-per-chi-progetta-il-software-5bnn</link>
      <guid>https://dev.to/kensaadi/lavoro-su-piattaforma-e-algoritmi-cosa-cambia-con-la-direttiva-europea-per-chi-progetta-il-software-5bnn</guid>
      <description>&lt;p&gt;Dal 2 dicembre 2026 gli Stati membri devono avere recepito la direttiva UE 2024/2831. Molti degli obblighi che introduce non sono clausole contrattuali. Sono requisiti di architettura.&lt;/p&gt;

&lt;p&gt;Nell’Unione Europea più di 28 milioni di persone lavorano tramite piattaforme digitali. Fanno consegne, guidano taxi e NCC, traducono, si occupano di assistenza domestica, inseriscono dati. Le piattaforme attive sono circa 500 e tra il 2016 e il 2020 i loro ricavi sono passati da circa 3 a 14 miliardi di euro, soprattutto nelle consegne e nel trasporto di persone.&lt;/p&gt;

&lt;p&gt;Per anni il punto debole del modello è stato lo status di queste persone. Formalmente sono lavoratori autonomi, ma nella pratica molti seguono regole, orari e vincoli tipici del lavoro dipendente. Senza le tutele che ne derivano.&lt;/p&gt;

&lt;p&gt;Con la direttiva sul lavoro mediante piattaforme digitali, adottata dal Consiglio il 14 ottobre 2024, l’UE diventa il primo legislatore al mondo a regolare questo settore in modo specifico. Il termine per il recepimento nazionale scade il 2 dicembre 2026.&lt;/p&gt;

&lt;p&gt;Il dibattito pubblico si è concentrato sull’aspetto giuslavoristico. C’è però un secondo livello, meno discusso, che riguarda direttamente chi scrive e mantiene il software di queste piattaforme.&lt;/p&gt;

&lt;p&gt;La presunzione di subordinazione&lt;/p&gt;

&lt;p&gt;Il primo pilastro è una presunzione legale confutabile. Se esistono elementi che indicano controllo e direzione, il rapporto tra piattaforma e lavoratore si presume di lavoro dipendente. Questi elementi sono definiti dal diritto nazionale, dai contratti collettivi o dalla prassi dei singoli Stati.&lt;/p&gt;

&lt;p&gt;L’onere della prova si inverte. Non tocca più al lavoratore dimostrare di essere un dipendente, ma alla piattaforma dimostrare il contrario.&lt;/p&gt;

&lt;p&gt;Questo ha una conseguenza tecnica precisa. Il “controllo e direzione” oggi si esercita quasi sempre attraverso il codice: come vengono assegnati i lavori, chi viene penalizzato se rifiuta una corsa, come si calcola il compenso, quando un account viene limitato. Se la piattaforma deve dimostrare di non esercitare un controllo da datore di lavoro, quella dimostrazione passa dalla logica del sistema e dalla capacità di documentarla.&lt;/p&gt;

&lt;p&gt;La gestione algoritmica diventa materia regolata&lt;/p&gt;

&lt;p&gt;Il secondo pilastro è il più interessante per chi progetta software. La direttiva introduce le prime regole europee sull’uso degli algoritmi nella gestione del lavoro.&lt;/p&gt;

&lt;p&gt;In sintesi:&lt;/p&gt;

&lt;p&gt;i lavoratori devono essere informati dell’uso di sistemi automatizzati di monitoraggio e di decisione;&lt;/p&gt;

&lt;p&gt;i sistemi devono essere supervisionati da personale qualificato, che gode di una protezione specifica da trattamenti sfavorevoli;&lt;/p&gt;

&lt;p&gt;le decisioni significative, come la sospensione di un account, richiedono una supervisione umana;&lt;/p&gt;

&lt;p&gt;alcune categorie di dati non possono essere trattate. Tra queste ci sono i dati sullo stato emotivo o psicologico, le conversazioni private, i dati usati per prevedere attività sindacali, quelli da cui dedurre origine etnica, status migratorio, opinioni politiche, convinzioni religiose o salute, e i dati biometrici usati per scopi diversi dall’autenticazione.&lt;/p&gt;

&lt;p&gt;A questi si aggiungono obblighi di trasparenza verso le autorità nazionali. Le piattaforme devono dichiarare il lavoro svolto e rendere disponibili le informazioni chiave sulla loro attività e sulle persone che vi lavorano, anche quando operano in più Stati membri.&lt;/p&gt;

&lt;p&gt;Da norma a requisito&lt;/p&gt;

&lt;p&gt;Letti con gli occhi di un architetto software, questi punti si traducono in requisiti molto concreti.&lt;/p&gt;

&lt;p&gt;Ogni decisione automatizzata rilevante deve essere tracciabile. Bisogna sapere quale regola o modello l’ha prodotta, su quali dati e in quale momento. Senza un audit trail serio non c’è modo di spiegare una decisione né di difenderla.&lt;/p&gt;

&lt;p&gt;Le decisioni ad alto impatto non possono essere solo automatiche. Serve un flusso in cui un operatore umano riesamina, conferma o annulla, con ruoli e permessi definiti. Chi supervisiona, su cosa, con quali poteri. È un problema di RBAC prima ancora che di processo.&lt;/p&gt;

&lt;p&gt;La minimizzazione dei dati deve stare nello schema, non in una policy scritta a parte. Se certi dati non si possono trattare, la soluzione più robusta è non raccoglierli e non derivarli, anziché filtrarli a valle.&lt;/p&gt;

&lt;p&gt;L’informazione al lavoratore deve essere generata dal sistema stesso. Un documento statico che descrive “come funziona l’algoritmo” invecchia alla prima release. Serve una documentazione legata alla versione effettiva delle regole in produzione.&lt;/p&gt;

&lt;p&gt;La reportistica verso le autorità deve essere un output previsto dal sistema, non un’estrazione manuale fatta quando arriva la richiesta.&lt;/p&gt;

&lt;p&gt;Chi è coinvolto davvero&lt;/p&gt;

&lt;p&gt;Sarebbe un errore pensare che la questione riguardi solo i grandi nomi delle consegne e del ride hailing. Il perimetro è quello delle piattaforme digitali che organizzano lavoro retribuito, e le scelte di recepimento nazionale definiranno i dettagli. Centrali di dispatch, marketplace di servizi, piattaforme di assistenza e software house che sviluppano questi prodotti per conto terzi farebbero bene a verificare la propria posizione adesso, non dopo il recepimento.&lt;/p&gt;

&lt;p&gt;Per chi sviluppa software B2B in questi settori c’è anche un’altra lettura. La conformità diventa un criterio di selezione del fornitore. Un sistema che nasce con tracciabilità, supervisione umana e controllo degli accessi integrati vale di più di uno che deve essere adattato a posteriori.&lt;/p&gt;

&lt;p&gt;In conclusione&lt;/p&gt;

&lt;p&gt;La direttiva non vieta gli algoritmi nella gestione del lavoro. Chiede che siano conoscibili, supervisionati e documentabili. Per i giuristi è una questione di status e tutele. Per chi progetta il software è una questione di architettura: audit, ruoli, flussi di revisione, modello dei dati.&lt;/p&gt;

&lt;p&gt;Il termine di dicembre 2026 è vicino. Le piattaforme che arriveranno preparate saranno quelle che hanno trattato la norma come un insieme di requisiti tecnici fin dall’inizio, e non come un documento da far leggere all’ufficio legale.&lt;/p&gt;

&lt;p&gt;Fonte: Consiglio dell’Unione Europea, “EU rules on platform work”, &lt;a href="//consilium.europa.eu"&gt;consilium.europa.eu&lt;/a&gt;&lt;br&gt;
&lt;a href="//urbango.ch"&gt;UrbanGo&lt;/a&gt;. La piattaforma che rispetta le norme europee.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>compilance</category>
      <category>architecture</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Switzerland withdrew the reform that would have made a normal driving licence enough to carry passengers</title>
      <dc:creator>kensaadi</dc:creator>
      <pubDate>Mon, 02 Nov 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/kensaadi/switzerland-withdrew-the-reform-that-would-have-made-a-normal-driving-licence-enough-to-carry-1bd8</link>
      <guid>https://dev.to/kensaadi/switzerland-withdrew-the-reform-that-would-have-made-a-normal-driving-licence-enough-to-carry-1bd8</guid>
      <description>&lt;p&gt;For anyone who carries passengers for a living in Switzerland, 28 October 2026 is the day nothing changed. That is the news, because for ten years people had been working to change it.&lt;/p&gt;

&lt;p&gt;That Wednesday the Federal Council took note of the consultation results on a partial revision of five road-traffic ordinances and, in its own words, "decided not to proceed with the project". It is also asking Parliament to strike off the three motions the revision grew out of.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would have gone
&lt;/h2&gt;

&lt;p&gt;The project targeted the permit for the professional transport of passengers, which the trade calls the TPP permit. Today, to carry people professionally in a category B car, that permit is mandatory: an extra theory and practical exam, one year of clean driving to show for it, medical requirements stricter than those asked of someone driving the same car without paying passengers, and periodic check-ups.&lt;/p&gt;

&lt;p&gt;The revision would have removed all of it. No permit, no extra exam, no year of practice, no higher medical bar. An ordinary driving licence would have been enough. The package also proposed logging driving and rest time with a certified app instead of a tachograph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it stopped
&lt;/h2&gt;

&lt;p&gt;The consultation ran from 19 December 2025 to 3 April 2026 and drew 81 submissions: all 26 cantons, two parties, intercantonal bodies, sixteen national transport associations and others.&lt;/p&gt;

&lt;p&gt;One question asked whether professional drivers of light vehicles should leave the working-hours ordinance and fall under the general rules. Of the 71 who filled in that part, 13 were in favour and 54 against. That is the 82% figure, and it is the answer to that specific question, not a general verdict on the reform.&lt;/p&gt;

&lt;p&gt;The detail that says most about who objected: among the national transport associations, 12 out of 12 were against. Among cantons, 19 of the 25 that expressed a view. The reasons, writes the federal department, were "linked to road safety and the protection of workers in the taxi sector".&lt;/p&gt;

&lt;p&gt;This was not even the radical version. Scrapping the working-time and rest rules outright had already been "largely rejected" in the past; this was the softened version, and it was rejected too.&lt;/p&gt;

&lt;p&gt;The one element with real support was the app replacing the tachograph, at 75% in favour. The cantons, at 60%, attached conditions strict enough to remove the practical benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it came from, and why that is ironic
&lt;/h2&gt;

&lt;p&gt;The motions grew out of UberPop. The explanatory report says so plainly: when intermediation services entered the market there were "distortions of competition", some drivers had no TPP permit, no tachograph and no cantonal or communal licence, and that "allowed them to offer transport services at lower prices".&lt;/p&gt;

&lt;p&gt;On 9 March 2016 two national councillors filed the answer: tighten nothing, loosen everything, and competition becomes fair again. Parliament accepted the motions that December.&lt;/p&gt;

&lt;p&gt;Ten years later, justifying its own proposal, the Federal Roads Office wrote that lowering the barriers would make it practical to "offer transport services as a secondary activity or at reduced workloads using one's own private vehicle". Then this: "precisely drivers of this type are particularly interesting for intermediation platforms."&lt;/p&gt;

&lt;p&gt;A reform born as an answer to the platforms would have produced exactly the kind of driver the platforms need. The sector noticed, and said no.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;It is not a ban, and it does not concern Uber, Bolt or any other app: platforms operating in Switzerland work with drivers who are already licensed, and the TPP permit does not touch them. It is the withdrawal of a deregulation proposal, not a crackdown.&lt;/p&gt;

&lt;p&gt;It is also not about the taxi licence. The TPP permit is federal and attaches to the person driving. Permission to operate a taxi service in Ticino is granted by the municipalities, a separate layer this decision does not reach.&lt;/p&gt;

&lt;p&gt;And the three motions are not struck off yet. The Federal Council proposes it; Parliament decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is left on the table
&lt;/h2&gt;

&lt;p&gt;For drivers, the practical answer is the simplest possible: what you needed yesterday you need tomorrow.&lt;/p&gt;

&lt;p&gt;For anyone coordinating a fleet there is a less obvious consequence. The entry bar stays high, and a high bar works for whoever is already inside: the pool of available drivers will not widen overnight, and licensed drivers remain contested.&lt;/p&gt;

&lt;p&gt;But read carefully what was defended. The profession was defended, meaning who may sit behind the wheel. Nothing was decided about who holds the booking, and no federal ordinance deals with that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Original (Italian):&lt;/strong&gt; &lt;a href="https://urbango.ch/it-CH/journal/trasporto-professionale-riforma-ritirata" rel="noopener noreferrer"&gt;Per portare passeggeri in Svizzera non basterà la patente B&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://urbango.ch" rel="noopener noreferrer"&gt;UrbanGo&lt;/a&gt;, dispatch software for licensed taxi and private-hire operators. Everything above is sourced; this paragraph is the part where I sell something.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>news</category>
      <category>discuss</category>
      <category>mobility</category>
      <category>regulation</category>
    </item>
    <item>
      <title>Your UI config quietly became a programming language. Here's the fix</title>
      <dc:creator>kensaadi</dc:creator>
      <pubDate>Mon, 07 Sep 2026 10:13:09 +0000</pubDate>
      <link>https://dev.to/kensaadi/your-ui-config-quietly-became-a-programming-language-heres-the-fix-19kb</link>
      <guid>https://dev.to/kensaadi/your-ui-config-quietly-became-a-programming-language-heres-the-fix-19kb</guid>
      <description>&lt;p&gt;It always starts the same way. You send a form's fields from the server so the team can change them without a&lt;br&gt;
deploy. Reasonable. Then one field has to show only when another field has a certain value. Then a field depends on&lt;br&gt;
two others. Then validation has to cross-check them. Then some fields are hidden for some roles.&lt;/p&gt;

&lt;p&gt;Six months later that payload is full of conditions, dependencies, and branching. It has no types. It has no tests.&lt;br&gt;
Nobody can debug it. On a checkout or KYC screen an auditor opens it and cannot tell what it does. You never sat&lt;br&gt;
down to design a language. You grew one by accident, and it is running in production right now.&lt;/p&gt;

&lt;p&gt;Mike Hadlow gave this a name: the Configuration Complexity Clock. Every server-driven UI project sits somewhere on&lt;br&gt;
that clock, and it only ticks one way.&lt;/p&gt;

&lt;p&gt;Blueprint stops it. It is an open-source UI-contract system with a self-hosted visual Builder, and it went public&lt;br&gt;
today. Here is how it works, and where it draws the line on purpose.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Q7aX3MZJ8lY" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule: describe the screen, not the behavior
&lt;/h2&gt;

&lt;p&gt;Blueprint is a UI contract. The contract carries structure, meaning which components go where, with what data&lt;br&gt;
bindings, visibility, and access rules. It never carries behavior. Logic stays in typed, testable code. That single&lt;br&gt;
boundary is the entire design.&lt;/p&gt;

&lt;p&gt;A contract is plain JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lib"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tw"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"root"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"form"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kyc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"children"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"taxId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"visibleWhen"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"equals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"IT"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customer.taxId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"validation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^IT&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;d{11}$"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things matter here.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;visibleWhen&lt;/code&gt; is an object, not a function.&lt;/strong&gt; It is serializable, so it is inspectable, diffable, and a visual
tool can read and write it. You give up some expressiveness for that. On regulated screens, that is exactly the
trade you want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;access&lt;/code&gt; lives inside the field contract.&lt;/strong&gt; It is not a &lt;code&gt;&amp;lt;CanRead&amp;gt;&lt;/code&gt; wrapper bolted on at the call site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no code anywhere in the payload.&lt;/strong&gt; No expression strings. No &lt;code&gt;eval&lt;/code&gt;. It renders under a strict CSP.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The render pipeline (no eval, ever)
&lt;/h2&gt;

&lt;p&gt;The runtime walks the tree and maps each node's &lt;code&gt;type&lt;/code&gt; to a real React component from a closed catalog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DashBlueprint&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@dashforge/blueprint&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DashBlueprint&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;
  &lt;span class="na"&gt;lib&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"tw"&lt;/span&gt;                 &lt;span class="c1"&gt;// or "mui"&lt;/span&gt;
  &lt;span class="na"&gt;root&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;     &lt;span class="c1"&gt;// the JSON above&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;     &lt;span class="c1"&gt;// values, from your backend&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;           &lt;span class="c1"&gt;// RBAC policy + named rules&lt;/span&gt;
  &lt;span class="na"&gt;validationMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"strict"&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing gets compiled from a string. A node whose &lt;code&gt;type&lt;/code&gt; is not in the catalog throws a hard, visible error. It is&lt;br&gt;
not a silent injection point. That is what makes it safe to render server-supplied UI on a payment page.&lt;/p&gt;
&lt;h2&gt;
  
  
  The escape hatch: custom nodes
&lt;/h2&gt;

&lt;p&gt;A closed catalog would be a cage without a door. The door is custom nodes. You register real, typed React components&lt;br&gt;
and reference them by type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DashBlueprint&lt;/span&gt;
  &lt;span class="na"&gt;root&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;customNodes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="c1"&gt;// your component, your code, your tests&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signature-pad&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SignaturePad&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id-scanner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IdScanner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catalog covers the 90%. Custom nodes cover the rest, as code, where logic belongs. The payload stays data. This&lt;br&gt;
is the line that stops config from ever turning into a language.&lt;/p&gt;
&lt;h2&gt;
  
  
  RBAC and visibility as data (shared front and back)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;access&lt;/code&gt; and &lt;code&gt;visibleWhen&lt;/code&gt; are part of the contract, and one policy governs both the API route and the field it protects. On the client this is not decoration: a provider loads that same policy from the server together with the current user's access and wires it into every component, so the rule the server enforces on the route is the rule the UI applies at render. You declare access, you do not hand-write guards. The rest is in the repo.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where Blueprint sits (and where it does not)
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Builder  ──authors──▶  contract (JSON)
                           │
Your backend  ──fills with data──▶  envelope (contract + data + state)
                           │
Your frontend (Blueprint runtime)  ──renders──▶  real UI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Server-driven UI, and not one line of an interpreter reaches the client.&lt;/p&gt;

&lt;p&gt;Now the honest part. Blueprint is not a compliance product. It does not run your business logic. It does not&lt;br&gt;
inventory your third-party scripts. It renders structure and data. It shrinks the surface and governs it. It does&lt;br&gt;
not erase the work.&lt;/p&gt;
&lt;h2&gt;
  
  
  Builder: author the contract visually
&lt;/h2&gt;

&lt;p&gt;Hand-writing JSON does not scale past a few screens. Builder is a self-hosted visual editor. You drag components,&lt;br&gt;
wire &lt;code&gt;visibleWhen&lt;/code&gt; and &lt;code&gt;access&lt;/code&gt;, validate live, and export the contract. The runtime is open-source. The Builder is&lt;br&gt;
self-hosted. You clone the repo and run it. Your contracts never leave your infrastructure.&lt;br&gt;
&lt;strong&gt;Architecture:&lt;/strong&gt; &lt;a href="https://dashforge-ui.com/blueprint/libraries" rel="noopener noreferrer"&gt;https://dashforge-ui.com/blueprint/libraries&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;It is self-hosted. No SaaS. No hosted demo. You clone it and run it locally, which is exactly the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kensaadi/blueprint
&lt;span class="c"&gt;# runtime + Builder both live in this repo. run the Builder locally, render contracts with the runtime&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also scaffold a fresh React 19 and TypeScript app on Dashforge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx dashforge-cli my-app &lt;span class="nt"&gt;--lib&lt;/span&gt; tw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo (runtime + Builder):&lt;/strong&gt; &lt;a href="https://github.com/kensaadi/blueprint" rel="noopener noreferrer"&gt;https://github.com/kensaadi/blueprint&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a href="https://dashforge-ui.com/blueprint" rel="noopener noreferrer"&gt;https://dashforge-ui.com/blueprint&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One trade-off worth arguing about: serializable conditions instead of functions. Less expressive, but inspectable,&lt;br&gt;
diffable, and auditable. On regulated screens that is the whole point. If you have shipped SDUI in production and&lt;br&gt;
hit a wall this does not cover, open an issue on the repo. Edge cases are how the catalog earns its keep.&lt;/p&gt;

</description>
      <category>serverdrivenui</category>
      <category>lowcode</category>
      <category>formbuilder</category>
      <category>dynamicforms</category>
    </item>
    <item>
      <title>RBAC in React and Node: enforce the same policy on both sides</title>
      <dc:creator>kensaadi</dc:creator>
      <pubDate>Sun, 30 Aug 2026 09:05:49 +0000</pubDate>
      <link>https://dev.to/kensaadi/rbac-in-react-and-node-enforce-the-same-policy-on-both-sides-2ial</link>
      <guid>https://dev.to/kensaadi/rbac-in-react-and-node-enforce-the-same-policy-on-both-sides-2ial</guid>
      <description>&lt;p&gt;The advice is everywhere: &lt;em&gt;the frontend is UX, authorization belongs on the backend.&lt;/em&gt; It is true, and on its own it is useless — because it stops exactly where the hard part starts. Hide a button in the UI and forget the server, and the action is a &lt;code&gt;curl&lt;/code&gt; away. Protect the server and hardcode the UI, and the two drift apart the first time the rules change. The question nobody answers is how to keep the button you hid and the endpoint you protected agreeing with each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The UI is an affordance, the server is the boundary
&lt;/h2&gt;

&lt;p&gt;They are two different jobs. The UI decides what to &lt;strong&gt;show&lt;/strong&gt; — hide or disable a control the user can't use. That is UX. The server decides what to &lt;strong&gt;allow&lt;/strong&gt; — reject the request. That is security. You need both, and they must agree, but only one of them is the security boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;UI only → no security. The endpoint is open; the hidden button is theatre.&lt;/li&gt;
&lt;li&gt;Server only → the app offers buttons that answer 403.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mistake is not doing one of them. It is doing them from &lt;strong&gt;two different sources of truth&lt;/strong&gt;, so they fall out of sync the moment someone edits a rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  One policy, as data
&lt;/h2&gt;

&lt;p&gt;Define the rules once. A policy is roles mapped to permissions — a &lt;code&gt;resource&lt;/code&gt; × &lt;code&gt;action&lt;/code&gt; pair, allow or deny:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createRbacEngine&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;RbacPolicy&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@dashforge/rbac&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RbacPolicy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;roles&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sales&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;permissions&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="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refund&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// explicit deny&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;customer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;permissions&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="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;self&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;update&lt;/span&gt;&lt;span class="dl"&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="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRbacEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An explicit &lt;code&gt;deny&lt;/code&gt; overrides an &lt;code&gt;allow&lt;/code&gt;, so you can grant broadly and carve out the exceptions. The engine is a &lt;strong&gt;pure function&lt;/strong&gt; of &lt;em&gt;(roles, resource, action) → decision&lt;/em&gt; — no framework, no I/O — which is exactly why the same policy can run on the server and in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server: the boundary
&lt;/h2&gt;

&lt;p&gt;Every mutating route consults the engine before the controller runs. The 403 is the thing that actually stops the request:&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;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/customer/me&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;requireAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;requireRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;customer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;rbacGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;self&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;update&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;// resource, action&lt;/span&gt;
  &lt;span class="nx"&gt;controllers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updateMe&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;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;rbacGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unauthenticated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&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="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&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;allow&lt;/span&gt;&lt;span class="dl"&gt;'&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;forbidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;next&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the line that matters. Delete every check in the UI and the app is still secure. Delete this one and no amount of hidden buttons will save you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The UI: the affordance, from the same policy
&lt;/h2&gt;

&lt;p&gt;Now the frontend renders from the &lt;strong&gt;same engine and the same vocabulary&lt;/strong&gt; — &lt;code&gt;resource&lt;/code&gt; and &lt;code&gt;action&lt;/code&gt; — so it can never offer what the server forbids:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Can&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"refund"&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"order"&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DisabledButton&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Refund&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DisabledButton&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RefundButton&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Can&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or imperatively, when you need the boolean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canRefund&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCan&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refund&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The refund control disappears for a &lt;code&gt;sales&lt;/code&gt; user because the policy denies &lt;code&gt;order:refund&lt;/code&gt; — and if they forge the request anyway, &lt;code&gt;rbacGate&lt;/code&gt; answers 403. Same rule, two enforcement points, one source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ownership: "their own", not "any"
&lt;/h2&gt;

&lt;p&gt;Most real rules are not &lt;em&gt;can a customer update a customer&lt;/em&gt; but &lt;em&gt;can a customer update **their own&lt;/em&gt;* record*. That is a condition on the permission, evaluated against the resource:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;self&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;update&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resourceData&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;subject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resourceData&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ownerId&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;ownerId&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;The server passes the loaded record as &lt;code&gt;resourceData&lt;/code&gt;; the UI passes the same shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Can&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"edit"&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"order"&lt;/span&gt; &lt;span class="na"&gt;resourceData&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EditButton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Can&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same condition, both sides. The UI stops offering the button on someone else's record; the server refuses the request if it arrives anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; and &lt;code&gt;useCan&lt;/code&gt; are UX — they never make anything safe. The only thing that makes the app safe is &lt;code&gt;rbacGate&lt;/code&gt; on the server; the UI just stops users from walking into a 403. If you ever have to choose, protect the server. The reason to share the policy is &lt;strong&gt;not&lt;/strong&gt; to promote the UI into a security layer — it is to stop the UI and the server from disagreeing as the rules change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One policy, as data: roles → permissions, &lt;code&gt;resource&lt;/code&gt; × &lt;code&gt;action&lt;/code&gt;, explicit &lt;code&gt;deny&lt;/code&gt; wins.&lt;/li&gt;
&lt;li&gt;One engine (&lt;code&gt;createRbacEngine&lt;/code&gt;), the same policy on the client and the server.&lt;/li&gt;
&lt;li&gt;The server is the boundary: &lt;code&gt;rbacGate(engine, resource, action)&lt;/code&gt; on every mutating route, returning 403.&lt;/li&gt;
&lt;li&gt;The UI is the affordance: &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; / &lt;code&gt;useCan&lt;/code&gt; from the same policy, hiding or disabling.&lt;/li&gt;
&lt;li&gt;Ownership as a &lt;code&gt;condition&lt;/code&gt; on the permission, with the same &lt;code&gt;resourceData&lt;/code&gt; shape on both sides.&lt;/li&gt;
&lt;li&gt;Never ship a UI-only check and call it access control.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;The engine is &lt;a href="https://www.npmjs.com/package/@dashforge/rbac" rel="noopener noreferrer"&gt;&lt;code&gt;@dashforge/rbac&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — open source, on npm, framework-free. &lt;code&gt;createRbacEngine(policy)&lt;/code&gt; is a pure function you run unchanged in React and in your API. The &lt;a href="https://checkout-kit.dashforge-ui.com" rel="noopener noreferrer"&gt;Checkout Kit demo&lt;/a&gt; wires the whole loop end to end — the &lt;code&gt;rbacGate&lt;/code&gt; middleware and &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; in the UI, mirrored in the Go and Node editions.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dashforge-ui.com/guides/rbac-react-node-same-policy" rel="noopener noreferrer"&gt;dashforge-ui.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>node</category>
      <category>typescript</category>
      <category>security</category>
    </item>
    <item>
      <title>Availability slots across timezones and DST</title>
      <dc:creator>kensaadi</dc:creator>
      <pubDate>Sun, 30 Aug 2026 08:49:50 +0000</pubDate>
      <link>https://dev.to/kensaadi/availability-slots-across-timezones-and-dst-1e5h</link>
      <guid>https://dev.to/kensaadi/availability-slots-across-timezones-and-dst-1e5h</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR: 09:00 is wall-clock time in a timezone, not a UTC instant. Resolve the offset in the business's zone and convert to UTC last, or you're an hour off across zones and grow a phantom slot on the DST change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;source → &lt;a href="https://dashforge-ui.com/guides/availability-slots-timezones-dst" rel="noopener noreferrer"&gt;https://dashforge-ui.com/guides/availability-slots-timezones-dst&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An availability engine looks trivial until it ships. It works on your machine, in your timezone, on an ordinary week. Then a customer in another timezone sees every slot an hour off, or the clocks change and a Sunday grows a slot that doesn't exist. Every one of these is the same mistake: treating a wall-clock time as if it were a fixed point in time.&lt;/p&gt;

&lt;h1&gt;
  
  
  "09:00" is not an instant
&lt;/h1&gt;

&lt;p&gt;A working window —&amp;nbsp;&lt;em&gt;open 09:00 to 12:00&lt;/em&gt;&amp;nbsp;— is&amp;nbsp;&lt;strong&gt;wall-clock time in the business's timezone&lt;/strong&gt;. It is not a UTC offset and not a number of minutes from midnight UTC. 09:00 in a Paris salon is 07:00 UTC in summer and 08:00 UTC in winter, because Paris is UTC+2 under DST and UTC+1 otherwise. The same&amp;nbsp;&lt;code&gt;"09:00"&lt;/code&gt;&amp;nbsp;maps to two different instants depending on the date.&lt;/p&gt;

&lt;p&gt;So the rule that makes the whole engine correct:&amp;nbsp;&lt;strong&gt;build the window in the business's timezone, resolve the offset there, and only then convert to UTC.&lt;/strong&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  The bug: computing slots in UTC (or the server's clock)
&lt;/h1&gt;

&lt;p&gt;Build each boundary as a wall-clock instant&amp;nbsp;&lt;strong&gt;in the availability timezone&lt;/strong&gt;&amp;nbsp;and let the date library resolve the offset on the resulting wall clock. Here that is&amp;nbsp;&lt;code&gt;luxon&lt;/code&gt;; the Go edition uses&amp;nbsp;&lt;code&gt;time.Date(y, mo, d, 0, minute, 0, 0, loc)&lt;/code&gt;&amp;nbsp;— the same idea.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { DateTime } from 'luxon';

// "minutes from midnight" → an instant, resolved IN `zone`.
function wallClock(year, month, day, minutesFromMidnight, zone) {
  const days   = Math.floor(minutesFromMidnight / 1440);
  const rem    = minutesFromMidnight - days * 1440; // 0..1439
  const hour   = Math.floor(rem / 60);
  const minute = rem % 60;
  return DateTime.fromObject({ year, month, day, hour, minute }, { zone })
    .plus({ days });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The whole trick is&amp;nbsp;&lt;code&gt;{ zone }&lt;/code&gt;: luxon resolves the UTC offset for&amp;nbsp;&lt;em&gt;that wall clock, on that date&lt;/em&gt;&amp;nbsp;— so 09:00 in August gets +02:00 and 09:00 in January gets +01:00, automatically. You never write an offset down.&lt;/p&gt;

&lt;p&gt;Then step candidate starts&amp;nbsp;&lt;strong&gt;in the zone&lt;/strong&gt;&amp;nbsp;and keep the ones that fit the window, converting to UTC only at the end:&lt;/p&gt;

&lt;h1&gt;
  
  
  Prove it: 09:00 in Paris in August is 07:00 UTC
&lt;/h1&gt;

&lt;p&gt;This is the single assertion that pins the whole thing. Paris is UTC+2 in August, so a 09:00 window start must come out as 07:00 UTC — not 09:00 UTC:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const slots = computeSlots({
  availability: weekdayAvail('Europe/Paris', MONDAY),
  date: '2026-08-17',
  durationMinutes: 60,
  now: new Date('2026-08-01T00:00:00Z'),
});

expect(slots[0].startAt.toISOString()).toBe('2026-08-17T07:00:00.000Z');
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Compute slots in UTC or the server's clock and this comes out as&amp;nbsp;&lt;code&gt;09:00Z&lt;/code&gt;&amp;nbsp;— an hour wrong, silently, for every customer, forever.&lt;/p&gt;

&lt;h1&gt;
  
  
  The spring-forward day
&lt;/h1&gt;

&lt;p&gt;The reason to build in the zone rather than add fixed offsets is the transition days. On spring-forward night the local clock jumps 02:00 → 03:00, so the day has 23 hours and the 02:00–03:00 wall time never happens (on fall-back it happens twice). Because each boundary is resolved as a wall clock&amp;nbsp;&lt;strong&gt;in the zone&lt;/strong&gt;&amp;nbsp;— not as "midnight UTC plus N hours" — the offset flips at the right instant and the slots land where a human expects.&lt;/p&gt;

&lt;p&gt;That is pinned by a test. A 01:00–05:00 window on the EU spring-forward Sunday is four wall-clock hours but only three real ones, so a 60-minute service yields&amp;nbsp;&lt;strong&gt;three&lt;/strong&gt;&amp;nbsp;slots, not four — the 02:00&amp;nbsp;&lt;em&gt;local&lt;/em&gt;&amp;nbsp;slot is absent, because that hour does not exist:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// 2026-03-29, Europe/Paris — the clock jumps 02:00 → 03:00
const slots = computeSlots({
  availability, // Sunday 01:00–05:00, Europe/Paris
  date: '2026-03-29',
  durationMinutes: 60,
  now: new Date('2026-03-01T00:00:00Z'),
});

// local 01:00, 03:00, 04:00 → UTC 00:00, 01:00, 02:00
expect(slots.map((s) =&amp;gt; s.startAt.toISOString())).toEqual([
  '2026-03-29T00:00:00.000Z',
  '2026-03-29T01:00:00.000Z',
  '2026-03-29T02:00:00.000Z',
]);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;A "midnight UTC + N hours" engine emits a phantom fourth slot here; this one does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest part:&lt;/strong&gt;&amp;nbsp;these two assertions pin the offset case and the spring-forward gap for&amp;nbsp;&lt;code&gt;Europe/Paris&lt;/code&gt;. The fall-back day (the hour that runs twice) and every other zone are the same wall-clock resolution, not special-cased code — they follow from the same property, but only these two are behind a test. Copy the&amp;nbsp;&lt;code&gt;wallClock&lt;/code&gt;&amp;nbsp;approach and you inherit the property; copy an "add hours to UTC" approach and no test will save you.&lt;/p&gt;

&lt;h1&gt;
  
  
  Store UTC, compare on instants, render local
&lt;/h1&gt;

&lt;p&gt;Slots come out as UTC instants; store bookings the same way. Conflict detection is then timezone-free — a half-open&amp;nbsp;&lt;code&gt;[start, end)&lt;/code&gt;&amp;nbsp;overlap on millisecond instants:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function intervalsOverlap(aStart, aEnd, bStart, bEnd) {
  return aStart &amp;lt; bEnd &amp;amp;&amp;amp; bStart &amp;lt; aEnd; // instants, location-independent
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The customer's browser renders those instants in&amp;nbsp;&lt;em&gt;their&lt;/em&gt;&amp;nbsp;timezone. The business defines availability in&amp;nbsp;&lt;em&gt;its&lt;/em&gt;&amp;nbsp;timezone. Nothing in between ever stores a wall-clock string as if it were an instant.&lt;/p&gt;

&lt;h1&gt;
  
  
  The checklist
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;A working window is wall-clock time&amp;nbsp;&lt;strong&gt;in the business's timezone&lt;/strong&gt;, not a UTC offset.&lt;/li&gt;
&lt;li&gt;Build each boundary with the zone attached; let the library resolve the offset on that wall clock. Never hardcode&amp;nbsp;&lt;code&gt;+02:00&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Step candidate starts as zoned datetimes; convert to UTC only on output.&lt;/li&gt;
&lt;li&gt;Store bookings as UTC instants; compare with a half-open interval overlap on millis.&lt;/li&gt;
&lt;li&gt;Render in the customer's timezone in the UI, never on the server.&lt;/li&gt;
&lt;li&gt;Test the offset case&amp;nbsp;&lt;strong&gt;and&lt;/strong&gt;&amp;nbsp;a spring-forward date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A booking slot at "09:00" is not a point in time. That single wrong assumption is why availability engines ship, run fine all week, then quietly grow a slot that doesn't exist on the Sunday the clocks change.&lt;/p&gt;

&lt;p&gt;"09:00" is wall-clock time in the business's timezone. In Paris it's 07:00 UTC in summer and 08:00 UTC in winter — same string, two different instants.&lt;/p&gt;

&lt;p&gt;The fix is one rule: build the window in the business's timezone, let the date library resolve the offset there, and convert to UTC only at the end. Never write "+02:00" by hand.&lt;/p&gt;

&lt;p&gt;Do the naive "midnight UTC + N hours" and two things break:&lt;br&gt;
→ every customer in another timezone sees slots an hour off&lt;br&gt;
→ the spring-forward day has 23 hours, so it grows a phantom slot at 02:00 — an hour that doesn't exist&lt;/p&gt;

&lt;p&gt;I wrote it up with the real code and the two tests that pin it: the Paris offset, and a spring-forward case that returns 3 slots, not 4.&lt;/p&gt;

&lt;p&gt;source → &lt;a href="https://dashforge-ui.com/guides/availability-slots-timezones-dst" rel="noopener noreferrer"&gt;https://dashforge-ui.com/guides/availability-slots-timezones-dst&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you build scheduling, this is the bug you don't find until production.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>useFieldArray is not updating across steps — and why it can't</title>
      <dc:creator>kensaadi</dc:creator>
      <pubDate>Thu, 27 Aug 2026 20:48:19 +0000</pubDate>
      <link>https://dev.to/kensaadi/usefieldarray-is-not-updating-across-steps-and-why-it-cant-1gcm</link>
      <guid>https://dev.to/kensaadi/usefieldarray-is-not-updating-across-steps-and-why-it-cant-1gcm</guid>
      <description>&lt;p&gt;If you have a wizard where step two appends to items and step four also appends to items, and the two steps never see each other's data — you have not misconfigured anything. Two useFieldArray instances pointed at the same name cannot observe each other, by design. Each one keeps a private snapshot of that array internally.&lt;/p&gt;

&lt;p&gt;react-hook-form documents it plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No two useFieldArray for the same field can be instantiated, as each handles its own snapshot of state internally.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a single-page form this never surfaces. For a wizard it surfaces on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the rule exists
&lt;/h2&gt;

&lt;p&gt;This is not neglect, and it is worth understanding before you work around it.&lt;/p&gt;

&lt;p&gt;The reason react-hook-form is fast is that state lives close to the inputs and is not centralised. Inputs are uncontrolled, the form does not re-render on every keystroke, and each useFieldArray owns its slice. Sharing one array across mounting boundaries asks for the opposite architecture — a central store that every instance subscribes to.&lt;/p&gt;

&lt;p&gt;That tension is real. It is why the request keeps reopening rather than getting fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three shapes where it bites
&lt;/h2&gt;

&lt;p&gt;Multi-page forms. Different steps need to update the same field array, and there is no first-party way to share one instance across them.&lt;/p&gt;

&lt;p&gt;Conditional fields inside an array item. Uncontrolled inputs do not re-render when a value changes, so showing or hiding a nested field based on a sibling's value simply does not react.&lt;/p&gt;

&lt;p&gt;Arrays that mount and unmount. Toggled sections, wizard steps and tab navigation all remount the array — the scenario most likely to lose state in a way that looks like a bug in your own code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fix 1 — conditional fields: subscribe narrowly with useWatch
&lt;/h2&gt;

&lt;p&gt;For "show this field only when a sibling has a certain value", there is a clean first-party answer. Do not reach for watch() on the whole form: that re-renders everything and throws away the reason you picked this library.&lt;/p&gt;

&lt;p&gt;Instead, extract the dependent field into its own component and subscribe to that one array item with useWatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useFormContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useWatch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-hook-form&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// One component per conditional field. The subscription is narrow,&lt;/span&gt;
&lt;span class="c1"&gt;// so the re-render is narrow too.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CourierNote&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;control&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;register&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Subscribes to exactly items.&amp;lt;index&amp;gt;.method — nothing else re-renders.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useWatch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;control&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`items.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.method`&lt;/span&gt;&lt;span class="p"&gt;,&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="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;courier&lt;/span&gt;&lt;span class="dl"&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`items.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.note`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Delivery instructions"&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;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;function&lt;/span&gt; &lt;span class="nf"&gt;ItemRow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;register&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sku`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;select&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.method`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;option&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"pickup"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Pickup&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;option&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;option&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"courier"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Courier&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;option&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;select&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CourierNote&lt;/span&gt; &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&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;The whole trick is the component boundary. useWatch re-renders the component that called it, so keeping that component tiny keeps the cost tiny. You get reactivity without giving up the uncontrolled model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2 — sharing one array across steps: own the context
&lt;/h2&gt;

&lt;p&gt;For the wizard case there is no first-party answer. The pattern that circulates — and the one that actually works — is to instantiate the field array once and hand it down through your own React context, so every step reads the same instance instead of creating a second one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useContext&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;ReactNode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;useFieldArray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;useFormContext&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;UseFieldArrayReturn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-hook-form&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ItemsContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UseFieldArrayReturn&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ItemsProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;control&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// The ONLY useFieldArray('items') in the tree.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fieldArray&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFieldArray&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;control&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;items&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ItemsContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Provider&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fieldArray&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ItemsContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useItems&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ItemsContext&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;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;useItems must be used inside &amp;lt;ItemsProvider&amp;gt;&lt;/span&gt;&lt;span class="dl"&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;ctx&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;Now every step consumes the same instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;StepTwo&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;append&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useItems&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ItemRow&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`items.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&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;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pickup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        Add item
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;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;function&lt;/span&gt; &lt;span class="nf"&gt;StepFour&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Same array, same snapshot — because it is literally the same instance.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remove&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useItems&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&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;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;×&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&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;h3&gt;
  
  
  The detail that decides whether this works
&lt;/h3&gt;

&lt;p&gt;The provider must be mounted above the step switch, not inside it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ survives step changes&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FormProvider&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;methods&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ItemsProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StepTwo&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StepFour&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ItemsProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;FormProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// ❌ remounts on every step change — you are back where you started&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FormProvider&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;methods&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ItemsProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StepTwo&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ItemsProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ItemsProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StepFour&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ItemsProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;FormProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the provider unmounts, the field array unmounts with it, and the second mount is a brand-new instance with a fresh snapshot. Which is the original bug, reintroduced by the fix.&lt;/p&gt;

&lt;p&gt;It works. It is also a piece of state architecture you now own and maintain — in a library you adopted specifically so you would not have to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we stand
&lt;/h2&gt;

&lt;p&gt;I maintain a form orchestrator built on top of react-hook-form, so it would be a strange article to end with a pitch. Our own useDashFieldArray does not solve this either.&lt;/p&gt;

&lt;p&gt;It is a thin adapter: it pre-computes the field path and exposes a live index, then delegates every operation straight through to RHF.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;append&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remove&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useDashFieldArray&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;items&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TextField&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sku`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better DX — you stop hand-building items.${index}.sku strings — but two instances on the same name behave exactly as RHF's do, because underneath they are RHF's. Fixing this properly means owning array state centrally, which is a different architecture, not a wrapper.&lt;/p&gt;

&lt;p&gt;So the honest summary for today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Conditional fields inside an array → solved, use useWatch at the component level.&lt;/li&gt;
&lt;li&gt;One array shared across wizard steps → not solved upstream. Own a context, and mount it above the steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If someone has a third pattern that holds up in production, I would genuinely like to see it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;p&gt;react-hook-form — useFieldArray documentation &lt;a href="https://dashforge-ui.com/fault-lines/usefieldarray-multi-step-wizards" rel="noopener noreferrer"&gt;the quoted rule&lt;/a&gt;&lt;br&gt;
react-hook-form — useWatch documentation&lt;/p&gt;

&lt;p&gt;This is part of Fault Lines, a set of open fronts in the React form ecosystem, each traced to primary sources and dated.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>programming</category>
      <category>react</category>
    </item>
    <item>
      <title>Dashforge: an application orchestrator for React</title>
      <dc:creator>kensaadi</dc:creator>
      <pubDate>Thu, 20 Aug 2026 06:52:28 +0000</pubDate>
      <link>https://dev.to/kensaadi/dashforge-an-application-orchestrator-for-react-56o9</link>
      <guid>https://dev.to/kensaadi/dashforge-an-application-orchestrator-for-react-56o9</guid>
      <description>&lt;p&gt;React solved rendering. Dashforge tries to solve orchestration — theming, forms,&lt;br&gt;
permissions, and visibility moved out of your components, declaratively, predictably,&lt;br&gt;
reusably. Two skins (MUI and Tailwind), one contract.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Building complex applications isn't about building components.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Inside a single module you're juggling forms, permissions, roles, visibility conditions, fields that depend on other fields, business logic. And all that logic ends up &lt;strong&gt;scattered across the app&lt;/strong&gt;: a &lt;code&gt;&amp;lt;Controller&amp;gt;&lt;/code&gt; here, an &lt;code&gt;if (user.role === …)&lt;/code&gt; there, a &lt;code&gt;useEffect&lt;/code&gt; watching one field to update another, a context for theming.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If React solves the &lt;strong&gt;rendering&lt;/strong&gt; problem, Dashforge tries to solve the &lt;strong&gt;orchestration&lt;/strong&gt; problem.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Dashforge moves that complexity &lt;strong&gt;out of the components&lt;/strong&gt; and makes it &lt;strong&gt;declarative, predictable, and reusable&lt;/strong&gt;. At its core it uses &lt;code&gt;react-hook-form&lt;/code&gt;; on top of it, a stable contract — identical across the &lt;strong&gt;MUI&lt;/strong&gt; and &lt;strong&gt;Tailwind&lt;/strong&gt; editions. Let's go through it piece by piece.&lt;/p&gt;


&lt;h2&gt;
  
  
  1. Theming — token-first, build-time &lt;em&gt;and&lt;/em&gt; run-time
&lt;/h2&gt;

&lt;p&gt;Components don't hard-code colors or spacing: they consume &lt;strong&gt;typed design tokens&lt;/strong&gt; (&lt;code&gt;@dashforge/tw-tokens&lt;/code&gt;, a pure TypeScript package, zero runtime). From there, the tokens travel on two rails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build-time — the utilities.&lt;/strong&gt; A Tailwind preset emits the usual utilities (&lt;code&gt;bg-primary-600&lt;/code&gt;, &lt;code&gt;text-neutral-900&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="c1"&gt;// tailwind.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;dashforgePreset&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@dashforge/tw-theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;presets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;dashforgePreset&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./src/**/*.{ts,tsx}&lt;/span&gt;&lt;span class="dl"&gt;'&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;strong&gt;Run-time — the CSS variables.&lt;/strong&gt; The provider republishes those same tokens as CSS variables on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DashforgeTailwindProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;App&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DashforgeTailwindProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the trick: &lt;code&gt;bg-primary-600&lt;/code&gt; doesn't resolve to a fixed color — it resolves to &lt;code&gt;var(--tw-color-primary-500)&lt;/code&gt;. The provider sets that variable; &lt;strong&gt;change the variable, the color changes — no re-render, no Tailwind rebuild.&lt;/strong&gt; The store is reactive (Valtio) with cross-tab sync, so dark mode or a live theme change is just a variable flip.&lt;/p&gt;

&lt;p&gt;In the &lt;strong&gt;MUI edition&lt;/strong&gt; the same token surface feeds a runtime MUI theme (via Emotion): same source of truth, only the delivery differs.&lt;/p&gt;

&lt;p&gt;Concrete payoffs: &lt;strong&gt;dark mode is free&lt;/strong&gt; (components don't even know it exists), a &lt;strong&gt;redesign is a token change&lt;/strong&gt; rather than a sweep across a hundred files, and the design system is &lt;em&gt;typed&lt;/em&gt; instead of being a convention your team has to remember.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Forms — declarative orchestration
&lt;/h2&gt;

&lt;p&gt;If you use &lt;code&gt;react-hook-form&lt;/code&gt; (and you should), you know the tax: a &lt;code&gt;&amp;lt;Controller&amp;gt;&lt;/code&gt; for &lt;strong&gt;every&lt;/strong&gt; field, and a handful of &lt;code&gt;watch&lt;/code&gt; / &lt;code&gt;useWatch&lt;/code&gt; / &lt;code&gt;useEffect&lt;/code&gt; scattered through the component to react to values.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Plain RHF — same pattern, every field, every form&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Controller&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;
  &lt;span class="na"&gt;control&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;control&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Email is required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fieldState&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TextField&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Email"&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;fieldState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;helperText&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fieldState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Dashforge the field is bound &lt;strong&gt;by name&lt;/strong&gt; to the form. Binding, errors, "touched" state: inside the component.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TextField&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Email"&lt;/span&gt; &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Email is required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Per-field subscriptions&lt;/strong&gt; → fewer re-renders than plain RHF, zero &lt;code&gt;&amp;lt;Controller&amp;gt;&lt;/code&gt; at the call site. And this pattern is &lt;strong&gt;native to both CSS libraries&lt;/strong&gt;: if you build admin apps all day, that alone is a reason to adopt it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 Reactions — logic &lt;em&gt;outside&lt;/em&gt; the render
&lt;/h3&gt;

&lt;p&gt;The real case: &lt;strong&gt;fields that depend on other fields&lt;/strong&gt;. In an e-commerce app you pick a &lt;em&gt;category&lt;/em&gt; and the right &lt;em&gt;subcategories&lt;/em&gt; must appear, loaded from an API. With RHF you end up with &lt;code&gt;useEffect&lt;/code&gt; + local state + cleanup + race-condition handling, all inside the component.&lt;/p&gt;

&lt;p&gt;Dashforge declares it &lt;strong&gt;outside the render&lt;/strong&gt;, in a &lt;em&gt;reaction&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reactions&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;load-subcategories&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;category&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;                 &lt;span class="c1"&gt;// the same "watch" as react-hook-form — not by accident&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;category&lt;/span&gt;&lt;span class="dl"&gt;'&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;category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                  &lt;span class="c1"&gt;// stale-response guard&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRuntime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subcategory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&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;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchSubcategories&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// external fetch, stays out of render&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isLatest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                     &lt;span class="c1"&gt;// drop out-of-order responses&lt;/span&gt;
      &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRuntime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subcategory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&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;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;options&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="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;The reaction declares &lt;em&gt;what&lt;/em&gt; happens between fields. The &lt;code&gt;fetch&lt;/code&gt; never enters the render; the data lives only inside the reaction. And the classic race condition ("the user already changed the value but the previous response just arrived") is handled by &lt;code&gt;beginAsync&lt;/code&gt; / &lt;code&gt;isLatest&lt;/code&gt;, not hand-written.&lt;/p&gt;

&lt;p&gt;When the form mounts, React takes over — reactions attach via a prop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DashForm&lt;/span&gt;
  &lt;span class="na"&gt;defaultValues&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;reactions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;reactions&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;save&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Select&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"category"&lt;/span&gt;    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Category"&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;CATEGORIES&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Select&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"subcategory"&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Subcategory"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* options populated by the reaction */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DashForm&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few lines orchestrate a form that would take &lt;strong&gt;real effort to build by hand&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. RBAC — permissions as data, not as &lt;code&gt;if&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;In an enterprise app, "who can do what" isn't a UI decision: it's a rule that comes &lt;strong&gt;from the backend&lt;/strong&gt;. The problem is that it usually gets re-translated by hand into dozens of scattered &lt;code&gt;if (user.role === 'admin')&lt;/code&gt;. Dashforge keeps it in one place and lets components apply it. Three pieces.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 Policy — what's allowed
&lt;/h3&gt;

&lt;p&gt;An object of roles and permissions (&lt;code&gt;action&lt;/code&gt; + &lt;code&gt;resource&lt;/code&gt; + &lt;code&gt;effect&lt;/code&gt;). In production &lt;strong&gt;you fetch it from your backend&lt;/strong&gt; (JWT claims, a &lt;code&gt;/me/policy&lt;/code&gt; endpoint), you don't hand-write it in the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RbacProvider&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;RbacPolicy&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@dashforge/rbac&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RbacPolicy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;roles&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allow&lt;/span&gt;&lt;span class="dl"&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;edit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;booking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
                                    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;booking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allow&lt;/span&gt;&lt;span class="dl"&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;viewer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;booking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allow&lt;/span&gt;&lt;span class="dl"&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3.2 RbacProvider — who you are (the link to authentication)
&lt;/h3&gt;

&lt;p&gt;The provider takes the &lt;strong&gt;policy&lt;/strong&gt; &lt;em&gt;and&lt;/em&gt; the &lt;strong&gt;subject&lt;/strong&gt;: the current user with their roles — also coming from your &lt;strong&gt;authentication&lt;/strong&gt; system. This is where RBAC connects to the user's real identity. It mounts once, at the top of the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;App&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentUser&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// from your auth / backend&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RbacProvider&lt;/span&gt; &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;currentUser&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BookingForm&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;RbacProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&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;From here on, every component "knows" who the user is and what they're allowed to do.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 access — how permissions propagate to components
&lt;/h3&gt;

&lt;p&gt;Each component declares which &lt;strong&gt;resource/action&lt;/strong&gt; it needs and &lt;strong&gt;what to do when the permission is missing&lt;/strong&gt; (&lt;code&gt;hide&lt;/code&gt; / &lt;code&gt;disable&lt;/code&gt; / &lt;code&gt;readonly&lt;/code&gt;). The object is inline, so you see exactly what's in it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DashForm&lt;/span&gt; &lt;span class="na"&gt;defaultValues&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;customerName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&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;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;save&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* viewer → read-only · editor/admin → editable */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TextField&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"customerName"&lt;/span&gt;
    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Customer name"&lt;/span&gt;
    &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;edit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;booking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onUnauthorized&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;readonly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* disappears entirely if you can't delete */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;delete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;booking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onUnauthorized&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hide&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    Delete booking
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DashForm&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider resolves &lt;code&gt;access&lt;/code&gt; against the policy and the subject: &lt;strong&gt;change the user's role and the same form re-resolves itself&lt;/strong&gt; — &lt;code&gt;admin&lt;/code&gt; sees and edits everything, &lt;code&gt;editor&lt;/code&gt; loses the admin-only fields, &lt;code&gt;viewer&lt;/code&gt; gets a read-only name and no Save button. No &lt;code&gt;if&lt;/code&gt; in the JSX, no permission logic duplicated page by page. And the &lt;code&gt;access&lt;/code&gt; declaration is &lt;strong&gt;identical&lt;/strong&gt; in the MUI and Tailwind editions.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. visibleWhen — the end of scattered conditional rendering
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;visibleWhen&lt;/code&gt; is a reactive visibility predicate: return &lt;code&gt;false&lt;/code&gt; and the component renders &lt;code&gt;null&lt;/code&gt;. It lives in two contexts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inside a form — on the engine.&lt;/strong&gt; It reaches the form engine directly and reads a field's value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Select&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"subcategory"&lt;/span&gt;
  &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Subcategory"&lt;/span&gt;
  &lt;span class="na"&gt;visibleWhen&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;category&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Outside a form — on state.&lt;/strong&gt; It captures local/external state in the closure: &lt;strong&gt;the component decides for itself whether to show up&lt;/strong&gt;, without the classic &lt;code&gt;{condition &amp;amp;&amp;amp; &amp;lt;Component/&amp;gt;}&lt;/code&gt; cluttering your JSX. Many display-only components expose it precisely for this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Alert&lt;/span&gt; &lt;span class="na"&gt;visibleWhen&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&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;hasUnsavedChanges&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;You have unsaved changes&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Alert&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either way, the condition is a &lt;strong&gt;declared property&lt;/strong&gt; of the component, not a rendering branch you repeat by hand.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;There's a single thread: what are usually &lt;strong&gt;scattered imperative functions&lt;/strong&gt; — &lt;code&gt;&amp;lt;Controller&amp;gt;&lt;/code&gt;, &lt;code&gt;useEffect&lt;/code&gt;, permission &lt;code&gt;if&lt;/code&gt;s, &lt;code&gt;dark:&lt;/code&gt; variants, conditional rendering — become &lt;strong&gt;declarative properties&lt;/strong&gt; of forms, components, and schema in Dashforge. Readable, testable, reusable. And because everything flows through the shared bridge, the same schema runs &lt;strong&gt;identically on MUI and Tailwind&lt;/strong&gt;: you only change the import line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@dashforge/tw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// Tailwind&lt;/span&gt;
&lt;span class="c1"&gt;// import { TextField, Select, Button } from '@dashforge/ui'; // MUI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; React/TypeScript teams, &lt;strong&gt;enterprise and fintech&lt;/strong&gt; applications, &lt;strong&gt;back offices&lt;/strong&gt;, &lt;strong&gt;complex dashboards&lt;/strong&gt;, products with &lt;strong&gt;heavy business logic&lt;/strong&gt;. If you're building a small app or a landing page, Dashforge probably &lt;strong&gt;won't help you&lt;/strong&gt; — and that's fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it without installing dependencies&lt;/strong&gt; — the CLI ships two templates, &lt;em&gt;landing&lt;/em&gt; or &lt;em&gt;dashboard&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx dashforge-cli my-app &lt;span class="nt"&gt;--lib&lt;/span&gt; tw   &lt;span class="c"&gt;# or --lib mui&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;my-app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pnpm dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;📚 Docs, live demos, and repo: &lt;strong&gt;&lt;a href="https://dashforge-ui.com" rel="noopener noreferrer"&gt;https://dashforge-ui.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you build data-driven apps in React, the one question I care about: &lt;strong&gt;would orchestrating forms, permissions, and dependencies outside your components make you adopt Dashforge?&lt;/strong&gt; Let me know in the comments — that's the feedback that steers the roadmap.&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I gave Tailwind typed props. Then it ate React Hook Form.</title>
      <dc:creator>kensaadi</dc:creator>
      <pubDate>Fri, 19 Jun 2026 17:31:59 +0000</pubDate>
      <link>https://dev.to/kensaadi/i-gave-tailwind-typed-props-then-it-ate-react-hook-form-1dlj</link>
      <guid>https://dev.to/kensaadi/i-gave-tailwind-typed-props-then-it-ate-react-hook-form-1dlj</guid>
      <description>&lt;p&gt;I spent years thinking my React forms were a CSS problem.&lt;/p&gt;

&lt;p&gt;They weren't. They were a &lt;em&gt;wiring&lt;/em&gt; problem — and I'd been paying for it on every single field, in every single project.&lt;/p&gt;

&lt;p&gt;This is the story of how questioning one small thing — &lt;em&gt;why is styling hidden inside strings?&lt;/em&gt; — led me somewhere I didn't expect: to components that already know what form they live in, who's allowed to use them, and when they should exist at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  It started with className
&lt;/h2&gt;

&lt;p&gt;Like most React developers, I've written this code thousands of times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex items-center justify-between gap-4 p-4 rounded-xl bg-white"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's nothing wrong with Tailwind. It solved a real category of problems that hand-written CSS created — naming, dead styles, cascade surprises. I'm not here to relitigate that. I reach for it on every project.&lt;/p&gt;

&lt;p&gt;But after years of building business apps, one thing kept nagging me.&lt;/p&gt;

&lt;p&gt;React is built around props. Everything is a prop. Except styling — layout, spacing, color, visual state — which lives inside one big opaque string. No autocomplete. No type-checking. No refactor safety. The compiler has no idea what's in there.&lt;/p&gt;

&lt;p&gt;So I asked a dumb question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What would Tailwind components look like if every utility that mattered were a typed prop instead of a substring?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Props-first, on top of Tailwind
&lt;/h2&gt;

&lt;p&gt;Instead of a className soup, I wanted the visual API to be the type system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"solid"&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"primary"&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"md"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Save
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;variant&lt;/code&gt;, &lt;code&gt;color&lt;/code&gt;, &lt;code&gt;size&lt;/code&gt; — autocompleted, type-checked, self-documenting. Behind the scenes each one still resolves to plain Tailwind utility classes (compiled with &lt;a href="https://www.tailwind-variants.org/" rel="noopener noreferrer"&gt;&lt;code&gt;tailwind-variants&lt;/code&gt;&lt;/a&gt;), and every color and spacing value comes from design tokens wired into the Tailwind config through a preset — not magic numbers, the same scale across the whole system.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;className&lt;/code&gt; isn't even on the component. There's exactly one override path: an &lt;code&gt;sx&lt;/code&gt; escape hatch, where &lt;code&gt;tailwind-merge&lt;/code&gt; guarantees your utility wins over the variant's. One way in, no specificity roulette.&lt;/p&gt;

&lt;p&gt;Under the hood the interactive primitives are &lt;a href="https://www.radix-ui.com/" rel="noopener noreferrer"&gt;Radix&lt;/a&gt; (and React Aria for the hard ones), so accessibility, focus management, and keyboard behavior aren't something I reinvented badly at 11pm.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Wait, this is just Chakra / MUI's &lt;code&gt;sx&lt;/code&gt;."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fair — props-first styling isn't a new idea, and I'm not going to pretend I invented it. But there's a real difference: this is &lt;strong&gt;build-time Tailwind utilities&lt;/strong&gt; over &lt;strong&gt;headless Radix primitives&lt;/strong&gt;, not a runtime CSS-in-JS engine. Dark mode is a CSS-variable swap from a theme layer — the &lt;em&gt;same&lt;/em&gt; class resolves to a different value when a &lt;code&gt;data-&lt;/code&gt; attribute flips, no &lt;code&gt;dark:&lt;/code&gt; explosion in every recipe.&lt;/p&gt;

&lt;p&gt;And honestly? The styling was never the interesting part. It was just the door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then React Hook Form happened
&lt;/h2&gt;

&lt;p&gt;The first real app exposed the actual problem.&lt;/p&gt;

&lt;p&gt;A typical RHF field looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Controller&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;
  &lt;span class="na"&gt;control&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;control&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fieldState&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fieldState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fieldState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing wrong with it. But by your 50th field, the shape is undeniable. Controller, render prop, field wiring, error wiring — copy, paste, again. And again. And again.&lt;/p&gt;

&lt;p&gt;So I asked the next dumb question: &lt;em&gt;why does every developer manually re-connect every field to the form?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What if the component already understood forms?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TextField&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Email"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PasswordField&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Password"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;Controller&lt;/code&gt;. No render prop. No error plumbing. The field reads its value, validation state, and error directly from a form bridge via context — and it errors only after blur or submit, so you don't get red text screaming at someone mid-keystroke.&lt;/p&gt;

&lt;p&gt;This is where I stopped thinking I'd rebuilt Tailwind. The real cost in business UIs was never CSS or even rendering. It was &lt;strong&gt;orchestration&lt;/strong&gt; — the glue wiring everything to everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fields that know when they exist
&lt;/h2&gt;

&lt;p&gt;Every app eventually grows dependent fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customerType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customerType&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="nx"&gt;customerType&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;business&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vatNumber&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&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="nx"&gt;customerType&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;watch&lt;/code&gt;, a conditional render, a cleanup effect — per rule, times hundreds of rules. That's not business logic. It's plumbing.&lt;/p&gt;

&lt;p&gt;What if the field described its own condition?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TextField&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"vatNumber"&lt;/span&gt;
  &lt;span class="na"&gt;visibleWhen&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customerType&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;business&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;watch&lt;/code&gt;. No effect. No manual reset. The field already knows when it should be on screen — and the engine handles the teardown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Buttons that know who's allowed to press them
&lt;/h2&gt;

&lt;p&gt;Same story with permissions, scattered across every screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice:update&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Save&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why is that check &lt;em&gt;outside&lt;/em&gt; the component? Why doesn't the button know who can use it?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"invoice:update"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Save&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule lives where it matters — on the node itself. Unauthorized can mean hide, disable, or readonly, your call. The permission logic stops being a render-time &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; smeared across the codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I didn't plan
&lt;/h2&gt;

&lt;p&gt;Here's the reveal, and it's the bit I'm actually proud of.&lt;/p&gt;

&lt;p&gt;There are &lt;strong&gt;two&lt;/strong&gt; component libraries: one rendered with MUI, one rendered with Tailwind + Radix. Same component names, same orchestration props — &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;visibleWhen&lt;/code&gt;, &lt;code&gt;access&lt;/code&gt; — different pixels.&lt;/p&gt;

&lt;p&gt;They share &lt;strong&gt;zero styling code&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What they &lt;em&gt;do&lt;/em&gt; share is one headless engine: the form bridge, the visibility engine, the RBAC layer. The application-awareness — &lt;em&gt;what a field knows about the app it lives in&lt;/em&gt; — is a separate, framework-agnostic core. The styling layer is swappable. The intelligence underneath is not.&lt;/p&gt;

&lt;p&gt;That's when it clicked. I wasn't building UI components anymore. I was building &lt;strong&gt;application-aware&lt;/strong&gt; components — things that understand styling, forms, validation, visibility, and permissions, not because they contain business logic, but because they understand how they &lt;em&gt;participate&lt;/em&gt; in an application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lives
&lt;/h2&gt;

&lt;p&gt;This is &lt;a href="https://github.com/kensaadi/dashforge" rel="noopener noreferrer"&gt;DashForge&lt;/a&gt; — React + MUI + React Hook Form + Tailwind CSS, with the Tailwind track (&lt;code&gt;@dashforge/tw&lt;/code&gt;) built on Radix and &lt;code&gt;tailwind-variants&lt;/code&gt;. It's an early alpha; the core architecture is already running in real apps, the API surface is still settling.&lt;/p&gt;

&lt;p&gt;If the orchestration idea resonates — if you've also written the same &lt;code&gt;watch&lt;/code&gt; / &lt;code&gt;useEffect&lt;/code&gt; / permission check for the hundredth time — the repo is here:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://github.com/kensaadi/dashforge" rel="noopener noreferrer"&gt;https://github.com/kensaadi/dashforge&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
👉 &lt;strong&gt;&lt;a href="https://dashforge-ui.com/tw" rel="noopener noreferrer"&gt;https://dashforge-ui.com/tw&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A star helps me gauge whether to keep pushing this. And I'd genuinely like to hear how &lt;em&gt;you&lt;/em&gt; deal with form orchestration at scale — drop it in the comments.&lt;/p&gt;

</description>
      <category>react</category>
      <category>tailwindcss</category>
      <category>typescript</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Production RBAC patterns for Go and Node startups</title>
      <dc:creator>kensaadi</dc:creator>
      <pubDate>Wed, 17 Jun 2026 09:56:56 +0000</pubDate>
      <link>https://dev.to/kensaadi/production-rbac-patterns-for-go-and-node-startups-1ddl</link>
      <guid>https://dev.to/kensaadi/production-rbac-patterns-for-go-and-node-startups-1ddl</guid>
      <description>&lt;p&gt;A founder told me last year:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We need admin features shipped by Friday. Can we just hardcode the roles for now?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was 7 months ago. They're still paying for that decision today.&lt;/p&gt;

&lt;p&gt;This article is the breakdown I wish I had given them before that Friday — the patterns that separate a quick MVP version of RBAC from one that won't bite you back in six months.&lt;/p&gt;

&lt;p&gt;If you're a startup CTO, technical founder, or senior engineer about to ship admin/dashboard features for the first time, this is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4 signs your RBAC is a time bomb
&lt;/h2&gt;

&lt;p&gt;I've reviewed this exact pattern in 5+ early-stage codebases. Every one of them eventually hit the wall. Look for these signs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Roles hardcoded in the JWT&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"admin"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple. Until you need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add a new role → migration + token refresh strategy&lt;/li&gt;
&lt;li&gt;Give one user a temporary elevated permission → "we'll just reissue tokens"&lt;/li&gt;
&lt;li&gt;Audit who had &lt;code&gt;admin&lt;/code&gt; last month → good luck&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Permission checks scattered across 40+ controllers&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// In every single handler:&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"admin"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time you have 30 endpoints, you've made the same mistake 30 times. Refactoring is a full sprint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. One &lt;code&gt;admin&lt;/code&gt; superuser that can do everything&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Until a sales rep accidentally deletes a customer record because they were elevated to admin "just for that one task last week."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Zero audit trail&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the data goes missing, your only investigative tool is &lt;code&gt;git blame&lt;/code&gt; on the source code and a desperate grep through CloudWatch logs.&lt;/p&gt;

&lt;p&gt;If you've nodded at any of these — keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem: policies-as-code
&lt;/h2&gt;

&lt;p&gt;Most teams treat permissions like business logic. They live in source files, gated by &lt;code&gt;if&lt;/code&gt; statements, deployed with the rest of the code.&lt;/p&gt;

&lt;p&gt;This is the original sin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code changes need deploys. Permissions change daily.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When marketing onboards a new role next quarter, when a customer success rep needs view-only access to billing, when legal asks "who could have read this customer's data on Tuesday at 3pm?" — every one of these requires a permission change. If permissions live in code, every one of these requires a deploy.&lt;/p&gt;

&lt;p&gt;Production RBAC starts with one shift in mental model: &lt;strong&gt;permissions are data, not code&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three rules for production-grade RBAC
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Rule 1: Policy-as-data, not policy-as-code
&lt;/h3&gt;

&lt;p&gt;Policies live in your database. Source code only knows how to &lt;em&gt;check&lt;/em&gt; them, not &lt;em&gt;define&lt;/em&gt; them.&lt;/p&gt;

&lt;p&gt;A reasonable schema (Postgres flavor, equivalent in MongoDB):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Roles&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Permissions (action on resource)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;permissions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;resource&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- e.g., "orders", "customers"&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- e.g., "read", "delete"&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Role &amp;lt;-&amp;gt; Permission mapping&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;role_permissions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;role_id&lt;/span&gt;        &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;permission_id&lt;/span&gt;  &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;permission_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- User &amp;lt;-&amp;gt; Role mapping&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;user_roles&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;  &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;role_id&lt;/span&gt;  &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role_id&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;In MongoDB the same idea is one document per role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sales"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"create"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updatedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-10T14:00:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference: now you change a permission with &lt;code&gt;UPDATE&lt;/code&gt;, not with &lt;code&gt;git push&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 2: Granular permissions (action-level, not role-level)
&lt;/h3&gt;

&lt;p&gt;The wrong abstraction is &lt;code&gt;role === permission&lt;/code&gt;. The right abstraction is &lt;code&gt;permission === (resource, action)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Bad:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"admin"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* do everything */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Good:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"delete"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* delete the order */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Can()&lt;/code&gt; function looks up: does this user, through any of their roles, have the permission &lt;code&gt;(orders, delete)&lt;/code&gt;? Cache it per-request with the user's permission set loaded once on auth.&lt;/p&gt;

&lt;p&gt;This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New permission? Insert one row. No deploy.&lt;/li&gt;
&lt;li&gt;Promote a role? Add a row to &lt;code&gt;role_permissions&lt;/code&gt;. No deploy.&lt;/li&gt;
&lt;li&gt;Demote a user? Remove a row from &lt;code&gt;user_roles&lt;/code&gt;. No deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Rule 3: Audit trail — non-negotiable
&lt;/h3&gt;

&lt;p&gt;Every sensitive action logs to an append-only table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;actor_id&lt;/span&gt;   &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;-- e.g., "order.delete"&lt;/span&gt;
  &lt;span class="n"&gt;resource&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;-- e.g., "orders/42"&lt;/span&gt;
  &lt;span class="k"&gt;before&lt;/span&gt;     &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="c1"&gt;-- snapshot before&lt;/span&gt;
  &lt;span class="k"&gt;after&lt;/span&gt;      &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="c1"&gt;-- snapshot after (NULL for delete)&lt;/span&gt;
  &lt;span class="n"&gt;ip_address&lt;/span&gt; &lt;span class="n"&gt;INET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_agent&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&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;Append-only. No &lt;code&gt;UPDATE&lt;/code&gt;, no &lt;code&gt;DELETE&lt;/code&gt;. When the customer record disappears, you have a complete trail of who, when, and what changed.&lt;/p&gt;

&lt;p&gt;This sounds like overhead until the first time you need it. Then it's the cheapest insurance you ever bought.&lt;/p&gt;

&lt;h2&gt;
  
  
  RBAC vs ABAC vs ReBAC: which one do you need?
&lt;/h2&gt;

&lt;p&gt;A quick decision tree:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plain RBAC&lt;/strong&gt; — most startups need this and nothing more.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users have roles. Roles have permissions. Done.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;ABAC&lt;/strong&gt; (Attribute-Based Access Control) — only if your access depends on attributes of the request, not just the user.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Sales reps can only see customers in their region"&lt;/li&gt;
&lt;li&gt;"This document is editable only during business hours"&lt;/li&gt;
&lt;li&gt;ABAC adds policy engines and complexity. Skip unless required.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;ReBAC&lt;/strong&gt; (Relationship-Based, Zanzibar-style) — only for sharing/multi-tenant graphs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"User A shared this document with User B who shared a folder with Team C"&lt;/li&gt;
&lt;li&gt;Google Docs, Notion, Figma need this. Your B2B SaaS probably doesn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're not sure — start with RBAC + audit log. You can always layer ABAC on top later.&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete implementation walkthrough
&lt;/h2&gt;

&lt;p&gt;Here's the minimal viable production-grade RBAC in real code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Go middleware (Fiber)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;middleware&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/gofiber/fiber/v2"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;PermissionChecker&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userID&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;RequirePermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checker&lt;/span&gt; &lt;span class="n"&gt;PermissionChecker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;fiber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;fiber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;userID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Locals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;checker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fiber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"permission check failed"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fiber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"forbidden"&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="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&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="c"&gt;// Usage in routes:&lt;/span&gt;
&lt;span class="c"&gt;// app.Delete("/orders/:id",&lt;/span&gt;
&lt;span class="c"&gt;//     RequirePermission(permChecker, "orders", "delete"),&lt;/span&gt;
&lt;span class="c"&gt;//     handlers.DeleteOrder)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middleware is &lt;strong&gt;stateless&lt;/strong&gt; about which roles exist. It just asks: "can this user do this action on this resource?" The answer comes from the DB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node middleware (Express)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware/requirePermission.js&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requirePermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;checker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;checker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&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;allowed&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;forbidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;permission check failed&lt;/span&gt;&lt;span class="dl"&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="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;requirePermission&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Usage:&lt;/span&gt;
&lt;span class="c1"&gt;// router.delete('/orders/:id',&lt;/span&gt;
&lt;span class="c1"&gt;//   requirePermission(permChecker, 'orders', 'delete'),&lt;/span&gt;
&lt;span class="c1"&gt;//   handlers.deleteOrder);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same pattern, different language. The permission set is loaded once at login and cached on the user session — &lt;code&gt;checker.can()&lt;/code&gt; is an in-memory lookup against a &lt;code&gt;Set&lt;/code&gt; per request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frontend gating: where most teams stop too early
&lt;/h3&gt;

&lt;p&gt;Most articles end here with a one-component &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; example and a "server-side check is the source of truth" disclaimer. That's correct, but it's the &lt;strong&gt;bare minimum&lt;/strong&gt; of what production RBAC needs on the frontend.&lt;/p&gt;

&lt;p&gt;A real RBAC layer on the frontend has at least 5 concerns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hook-level access&lt;/strong&gt; — read user permissions anywhere without prop drilling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline gating&lt;/strong&gt; — show/hide buttons, columns, table actions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route-level gating&lt;/strong&gt; — block entire pages, redirect unauthorized users&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin UI&lt;/strong&gt; — non-engineers need to view/edit roles and permissions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit visibility&lt;/strong&gt; — someone needs to read the audit log without DB access&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Skip any of these and you'll re-invent them six months later. Here's the shape they should have.&lt;/p&gt;

&lt;h4&gt;
  
  
  1. The hook
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// useAuth() returns user + a Set of permission keys&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAuth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// usePermissions() is a focused alias when you don't need user data&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;usePermissions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders:delete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// true | false&lt;/span&gt;
&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canAny&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders:delete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders:archive&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canAll&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;customers:read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;customers:update&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The permission set is loaded once on auth, cached in React context, and every check is O(1).&lt;/p&gt;

&lt;h4&gt;
  
  
  2. Inline gating with &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt;
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Can&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"delete"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DeleteButton&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleDelete&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Can&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// With a fallback (shows a disabled button + tooltip instead of hiding)&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Can&lt;/span&gt;
  &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"delete"&lt;/span&gt;
  &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DisabledButton&lt;/span&gt; &lt;span class="na"&gt;tooltip&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"You don't have permission to delete orders"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DeleteButton&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleDelete&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Can&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// With multiple permissions (any of these is enough)&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Can&lt;/span&gt; &lt;span class="na"&gt;anyOf&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orders:delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orders:archive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DangerActions&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Can&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;fallback&lt;/code&gt; prop is the key detail most implementations miss. Hiding a button creates a worse UX than showing it disabled with a "why you can't" tooltip — users feel less broken when they know the rule exists.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Route-level gating with &lt;code&gt;&amp;lt;RoleGuard&amp;gt;&lt;/code&gt;
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Inside the router&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/admin/orders"&lt;/span&gt;
  &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RoleGuard&lt;/span&gt;
      &lt;span class="na"&gt;anyOf&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orders:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ForbiddenPage&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/dashboard"&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderManagementPage&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;RoleGuard&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whole pages refuse to render. Better than letting the user load the page and then erroring on every API call.&lt;/p&gt;

&lt;h4&gt;
  
  
  4. The admin UI — the part nobody writes themselves
&lt;/h4&gt;

&lt;p&gt;This is where DIY RBAC implementations fall apart. Engineers build the middleware and the hook and stop. Then someone in product asks: &lt;em&gt;"Can sales also export customers?"&lt;/em&gt; and the answer is: &lt;em&gt;"Sure, I'll deploy that change next week."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A real admin UI lets non-engineers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;See the role × permission matrix (the same diagram everyone draws on a whiteboard)&lt;/li&gt;
&lt;li&gt;Toggle permissions per role with a click&lt;/li&gt;
&lt;li&gt;Assign users to roles&lt;/li&gt;
&lt;li&gt;See the audit log filterable by actor, action, date range, IP&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is what an &lt;code&gt;&amp;lt;PermissionMatrix&amp;gt;&lt;/code&gt; admin component looks like from the consumer side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PermissionMatrix&lt;/span&gt;
  &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;                  &lt;span class="c1"&gt;// ["customer", "sales", "admin"]&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;resources&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;          &lt;span class="c1"&gt;// ["orders", "customers", "products"]&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;update&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;currentPolicy&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;          &lt;span class="c1"&gt;// current role × permission map&lt;/span&gt;
  &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handlePolicyChange&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// saves to DB, no deploy&lt;/span&gt;
  &lt;span class="na"&gt;readonly&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin:manage_roles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind it: the same &lt;code&gt;roles&lt;/code&gt; / &lt;code&gt;permissions&lt;/code&gt; / &lt;code&gt;role_permissions&lt;/code&gt; tables from Rule #1. Edits hit the DB directly. No code change, no deploy, no engineer in the loop.&lt;/p&gt;

&lt;h4&gt;
  
  
  5. The audit log viewer
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AuditLogViewer&lt;/span&gt;
  &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;actionPrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;order.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// "order.delete", "order.update", etc.&lt;/span&gt;
    &lt;span class="na"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;last30Days&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;actor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;action&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resource&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;before&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;after&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;exportable&lt;/span&gt;                     &lt;span class="c1"&gt;// CSV export for compliance reviews&lt;/span&gt;
  &lt;span class="na"&gt;paginate&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the data goes missing or the auditor asks who did what, you don't &lt;code&gt;psql&lt;/code&gt; into prod. You filter the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit logging in two lines
&lt;/h2&gt;

&lt;p&gt;Wrap your mutation handlers with a logger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;DeleteOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;fiber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;orderID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Params&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeleteOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderID&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;audit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Locals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"order.delete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"orders/"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;orderID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IP&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"User-Agent"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;204&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;Same pattern in Node. The audit log table is append-only. Months from now, when something disappears, you'll have the full trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The RBAC layer in Dashforge — what end-to-end actually looks like
&lt;/h2&gt;

&lt;p&gt;Everything above — the schema, the middleware, the hooks, the components, the admin UI — is the actual design of the RBAC layer in &lt;a href="https://dashforge-ui.com" rel="noopener noreferrer"&gt;Dashforge&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The reason this article exists is that &lt;strong&gt;every piece of this puzzle is usually built separately by separate teams&lt;/strong&gt;, in different shapes, with different bugs. The middleware is in one repo, the React &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; is invented case-by-case, the audit log is "TODO for next sprint", the admin panel never gets built. Six months later you have four half-finished RBAC systems that don't talk to each other.&lt;/p&gt;

&lt;p&gt;Dashforge ships them as &lt;strong&gt;one coherent layer&lt;/strong&gt;:&lt;/p&gt;

&lt;h3&gt;
  
  
  The full stack, day-1 ready
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────────────────────────────────────┐
│  Admin UI (React)                                                 │
│  ── PermissionMatrix   roles × actions toggle grid                │
│  ── UserRolesEditor    assign/revoke roles per user               │
│  ── AuditLogViewer     filter, search, CSV export                 │
└─────────────────┬────────────────────────────────────────────────┘
                  │ same auth context, same permission set
┌─────────────────▼────────────────────────────────────────────────┐
│  React components                                                 │
│  ── useAuth()  /  usePermissions()  /  &amp;lt;Can&amp;gt;  /  &amp;lt;RoleGuard&amp;gt;      │
│  Available in BOTH Tailwind and MUI flavors                       │
└─────────────────┬────────────────────────────────────────────────┘
                  │ HTTP, JWT carries user_id only
┌─────────────────▼────────────────────────────────────────────────┐
│  Backend middleware                                               │
│  ── Go (Fiber) and Node (Express) — same contract                 │
│  ── RequirePermission(resource, action)                           │
│  ── Audit log middleware (auto-wraps mutations)                   │
└─────────────────┬────────────────────────────────────────────────┘
                  │
┌─────────────────▼────────────────────────────────────────────────┐
│  Database (MongoDB, Postgres-ready via provider pattern)          │
│  ── roles, permissions, role_permissions, user_roles, audit_log   │
└──────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Single source of truth — the database. Every layer reads from the same tables. You change a permission once, the change propagates automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Middleware blocks unauthorized requests&lt;/li&gt;
&lt;li&gt;React &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; hides UI accordingly&lt;/li&gt;
&lt;li&gt;Audit log records everything&lt;/li&gt;
&lt;li&gt;Admin panel reflects the new state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No code changes anywhere. No deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you get on day 1
&lt;/h3&gt;

&lt;p&gt;When you open the checkout-kit, the RBAC layer is &lt;strong&gt;already wired up&lt;/strong&gt; with three working roles and the full admin UI:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend (Go or Node — pick one):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;RequirePermission(resource, action)&lt;/code&gt; middleware wrapping every protected route&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PolicyChecker&lt;/code&gt; service that loads user permissions on auth, caches them per session&lt;/li&gt;
&lt;li&gt;Audit logger that wraps mutation handlers automatically (no per-handler boilerplate)&lt;/li&gt;
&lt;li&gt;REST endpoints for &lt;code&gt;/admin/roles&lt;/code&gt;, &lt;code&gt;/admin/permissions&lt;/code&gt;, &lt;code&gt;/admin/users/:id/roles&lt;/code&gt;, &lt;code&gt;/admin/audit-log&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Frontend (Tailwind OR MUI flavor — both shipped):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;AuthProvider&amp;gt;&lt;/code&gt; initialized in your root component&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;useAuth()&lt;/code&gt; and &lt;code&gt;usePermissions()&lt;/code&gt; hooks ready to import&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; with &lt;code&gt;fallback&lt;/code&gt;, &lt;code&gt;anyOf&lt;/code&gt;, &lt;code&gt;allOf&lt;/code&gt; props — the full API documented above&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;RoleGuard&amp;gt;&lt;/code&gt; for route-level protection (drop-in for React Router)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;PermissionMatrix&amp;gt;&lt;/code&gt; admin component — pre-styled, pre-wired to the API&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;UserRolesEditor&amp;gt;&lt;/code&gt; — searchable user list with role assignment&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;AuditLogViewer&amp;gt;&lt;/code&gt; — filterable, exportable, paginated&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Three roles, pre-seeded:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;customer&lt;/code&gt; — read their own orders, update their own profile&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sales&lt;/code&gt; — read/create/update orders and customers, no delete&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;admin&lt;/code&gt; — everything, plus role/permission management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Extending to a fourth role is one row in the &lt;code&gt;roles&lt;/code&gt; table and a few clicks in the admin UI. No code change required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why bundling matters
&lt;/h3&gt;

&lt;p&gt;You can build all of this yourself. Here's the realistic timeline I've seen in startups:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Time to build properly (mid-senior engineer)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DB schema + migrations&lt;/td&gt;
&lt;td&gt;4-6 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Middleware Go + Node&lt;/td&gt;
&lt;td&gt;6-10 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React hooks + Context&lt;/td&gt;
&lt;td&gt;4-6 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; + &lt;code&gt;&amp;lt;RoleGuard&amp;gt;&lt;/code&gt; with edge cases&lt;/td&gt;
&lt;td&gt;6-10 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;PermissionMatrix&amp;gt;&lt;/code&gt; admin UI&lt;/td&gt;
&lt;td&gt;16-24 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;UserRolesEditor&amp;gt;&lt;/code&gt; with search/pagination&lt;/td&gt;
&lt;td&gt;8-16 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;AuditLogViewer&amp;gt;&lt;/code&gt; with filters/export&lt;/td&gt;
&lt;td&gt;12-20 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit middleware + before/after snapshot logic&lt;/td&gt;
&lt;td&gt;8-12 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End-to-end tests, documentation, bugfixes&lt;/td&gt;
&lt;td&gt;16-24 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total realistic&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;80-130 hours (2-3 weeks)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's not counting design iterations, the inevitable "wait, we forgot about row-level permissions" mid-sprint, or onboarding a new engineer who needs to understand all of it.&lt;/p&gt;

&lt;p&gt;Dashforge ships it all on day 1, in your repo, code you own. You modify it freely. There's no SaaS subscription, no vendor lock-in, no monthly fee. Buy it once, deploy it forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  How it composes with the rest of the kit
&lt;/h3&gt;

&lt;p&gt;The checkout-kit isn't just RBAC. The same auth + permission layer also powers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe checkout flow&lt;/strong&gt; — pricing tiers gated by role (admin sees test-mode toggles, customer sees buy CTAs only)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product catalog&lt;/strong&gt; — sales can create products, customer can only browse&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order management&lt;/strong&gt; — full CRUD for admin/sales, read-only for customer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenant ready&lt;/strong&gt; — extend the policy with a &lt;code&gt;tenant_id&lt;/code&gt; column and you have row-level security&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe webhook handlers&lt;/strong&gt; — already protected with API-key auth, audit logged&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing is one repo, one auth model, one permission set. Not five disconnected libraries glued together with hope.&lt;/p&gt;

&lt;p&gt;If you're about to start building this from scratch — pause and take a look at &lt;a href="https://dashforge-ui.com" rel="noopener noreferrer"&gt;the kit&lt;/a&gt;. You'll save 2-3 weeks of work and get a layer that's already been thought through. Drop a comment if you want me to share more on a specific piece (the schema migrations, the audit log middleware, the admin UI patterns).&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;If you've shipped RBAC in production recently, I'd love to hear what tradeoffs you made:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did you build from scratch, or use a library (Casbin, Oso, Permify)?&lt;/li&gt;
&lt;li&gt;ABAC, ReBAC, or plain RBAC + audit?&lt;/li&gt;
&lt;li&gt;What's the one thing you'd do differently?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Drop a comment — I'm collecting patterns for a follow-up piece on RBAC at scale (10K+ users, multi-tenant).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you found this useful and want more deep-dives on Go/Node infrastructure patterns for startups, follow me here on dev.to.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>go</category>
      <category>node</category>
      <category>security</category>
    </item>
  </channel>
</rss>
