<?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: Nic Polumeyv</title>
    <description>The latest articles on DEV Community by Nic Polumeyv (@polumeyv).</description>
    <link>https://dev.to/polumeyv</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%2F4034466%2F39a0014e-6bea-47e6-b1f8-92ba63349671.png</url>
      <title>DEV Community: Nic Polumeyv</title>
      <link>https://dev.to/polumeyv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/polumeyv"/>
    <language>en</language>
    <item>
      <title>I deleted tailwind-merge from production</title>
      <dc:creator>Nic Polumeyv</dc:creator>
      <pubDate>Fri, 17 Jul 2026 23:06:44 +0000</pubDate>
      <link>https://dev.to/polumeyv/i-deleted-tailwind-merge-from-production-4no0</link>
      <guid>https://dev.to/polumeyv/i-deleted-tailwind-merge-from-production-4no0</guid>
      <description>&lt;h1&gt;
  
  
  I deleted tailwind-merge from production
&lt;/h1&gt;

&lt;p&gt;While auditing dependencies in my monorepo, I ran an experiment that bothered me more than it should have:&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="nf"&gt;twMerge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;border-grid border-red-500&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; 'border-red-500'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;border-grid&lt;/code&gt; is a custom utility defined in my component library's CSS. tailwind-merge has never heard of it, so it does what its heuristics say: anything shaped like &lt;code&gt;border-&amp;lt;something&amp;gt;&lt;/code&gt; that is not a known width or style must be a border color. Two border colors conflict, the later one wins, and my utility silently vanishes from the DOM. No warning, no error... nothing. It renders fine until the day any border color class lands in the same string.&lt;/p&gt;

&lt;p&gt;That sent me down a hole that ended with tailwind-merge gone from all seven apps in the repo, a class of bugs gone with it, and a small tool I wish I had at the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why tailwind-merge exists at all
&lt;/h2&gt;

&lt;p&gt;CSS does not care about the order of your class attribute. The height of &lt;code&gt;class="h-9 h-8"&lt;/code&gt; is decided by stylesheet order, which Tailwind controls, not you. So when a Button component says &lt;code&gt;h-9&lt;/code&gt; and a caller passes &lt;code&gt;h-8&lt;/code&gt;, somebody has to break the tie. tailwind-merge breaks it at runtime: parse every class string, classify every token against its tables, remove the losers. Every render. On every device. FOREVER.&lt;/p&gt;

&lt;p&gt;To be clear, it does this well. The tables are carefully maintained, the cache makes repeat calls nearly free, and the shadcn ecosystem is built on top of it. This is not a hit piece.&lt;/p&gt;

&lt;p&gt;But sitting with that &lt;code&gt;border-grid&lt;/code&gt; result, the framing started to feel wrong. A class conflict is not a runtime condition. It is a line of code I wrote ONCE, in a file. The conflict is the same on every render and every device, and the resolution is the same too. Running a resolver in production to fix a static mistake is like shipping a spell checker inside a published book.&lt;/p&gt;

&lt;p&gt;There was a second result that confirmed it:&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="nf"&gt;twMerge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-sm leading-snug text-xs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; 'text-xs'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller wanted smaller text and lost their line height. tailwind-merge treats font size as conflicting with line height, because &lt;code&gt;text-sm&lt;/code&gt; can set both. Defensible reasoning, surprising outcome... and again, completely silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan
&lt;/h2&gt;

&lt;p&gt;Resolve every conflict once, in source, and demote tailwind-merge to the job it is actually great at: checking. The same move as replacing runtime validation with a type checker. If no class string in the codebase ever needs merging, the merger can run in dev and CI and ship nowhere.&lt;/p&gt;

&lt;p&gt;The problem is that a shadcn-style codebase is written in merge semantics on purpose. Variant systems restate properties so the merge can pick winners: the button base says &lt;code&gt;border-transparent&lt;/code&gt;, the outline variant says &lt;code&gt;border-border&lt;/code&gt;. Component callers pass &lt;code&gt;h-8&lt;/code&gt; against an &lt;code&gt;h-9&lt;/code&gt; base and expect to win. Turn the merge off naively and every one of those spots becomes a stylesheet-order coin flip, with no error anywhere. This is why everybody leaves it on. The off switch was always available (cva ships without merging, tailwind-variants has &lt;code&gt;twMerge: false&lt;/code&gt;). The safety net... was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: variants that mean what they say
&lt;/h2&gt;

&lt;p&gt;I swapped tailwind-variants for a 25 line concatenation helper and made every variant string disjoint: no property set by both the base and a variant for the same modifier prefix. The contested tokens move out of the base and into every variant that does not override them. Verbose, yes. But the string now means what it says.&lt;/p&gt;

&lt;p&gt;Proving this changed nothing was the part I cared about. Before touching anything, I captured tailwind-merge's output for all 109 variant combinations as a baseline. After the rewrite, a test asserts two things for every combination: the output matches the baseline exactly, and running twMerge over it changes nothing. The referee that was being removed is the same referee that certified the removal. That test stays in the repo permanently, because every freshly pulled shadcn component arrives written in merge semantics and fails it until the overlaps are distributed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step two: make the callers honest
&lt;/h2&gt;

&lt;p&gt;For call sites where the override is the point, Tailwind already has a native answer: the important modifier. &lt;code&gt;&amp;lt;Button class="rounded-full!"&amp;gt;&lt;/code&gt; beats the component's &lt;code&gt;rounded-md&lt;/code&gt; through CSS itself, deterministically, with zero runtime. And because important and normal classes live in different buckets, a checker can stay quiet about them: the precedence is explicit.&lt;/p&gt;

&lt;p&gt;One trap worth knowing if you try this. When a plain token gets the &lt;code&gt;!&lt;/code&gt;, any responsive sibling on the same property needs it too. &lt;code&gt;text-2xl! md:text-3xl&lt;/code&gt; renders 2xl at every width, because an important plain class beats a normal responsive one. The fix is &lt;code&gt;text-2xl! md:text-3xl!&lt;/code&gt;. My first pass got this wrong on EIGHT files and the only reason I caught it is that I went looking.&lt;/p&gt;

&lt;p&gt;I did not convert sites by hand. A script resolved each component's actual classes, diffed every class literal in all seven apps against what twMerge would drop, and rewrote the conflicting tokens. 233 call sites, each one pixel-identical, because the rewrite target is by definition the string twMerge was already producing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step three: flip it and keep a tripwire
&lt;/h2&gt;

&lt;p&gt;With the variants disjoint and the callers explicit, &lt;code&gt;cn&lt;/code&gt; became a plain join, and tailwind-merge moved into a dev-only tripwire that names any conflicting tokens the moment they render. Bundlers eliminate the whole branch in production.&lt;/p&gt;

&lt;p&gt;Results, measured: roughly 25KB of minified JavaScript removed from each app's client bundle, zero visual changes across seven apps, and a permanent dev-mode alarm for the next conflict anyone writes.&lt;/p&gt;

&lt;p&gt;And one more thing, which is my favorite part. After the migration was supposedly complete, I ran a fresh static scan over the codebase. It found two more conflicts on pages NOBODY had rendered in dev: a marketing page where &lt;code&gt;py-12&lt;/code&gt; was fighting &lt;code&gt;p-10&lt;/code&gt;, and a vendored component carrying both &lt;code&gt;outline-hidden&lt;/code&gt; and &lt;code&gt;outline-none&lt;/code&gt;. The first one had actually become a live regression the moment the merge came off. The checker caught what the migration missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool
&lt;/h2&gt;

&lt;p&gt;I extracted the pieces into a package, because the safety net is the part that did not exist. It is called overrule: &lt;a href="https://github.com/Nic-Polumeyv/overrule" rel="noopener noreferrer"&gt;https://github.com/Nic-Polumeyv/overrule&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The command that earns the link here is &lt;code&gt;cross&lt;/code&gt;. It judges every class literal twice, once with the name tables and once with your actual compiled stylesheet, and prints every place the two disagree. I pointed it back at the seven apps that started all this. One disagreement in the whole repo, and it was overrule being right: a bare &lt;code&gt;filter&lt;/code&gt; token sitting dead next to &lt;code&gt;blur-[10px]&lt;/code&gt;, invisible to name tables because every filter utility restates the full filter chain. And the &lt;code&gt;border-grid&lt;/code&gt; result at the top of this post? The stylesheet oracle keeps it. The compiler, unlike the tables, has actually met my utility.&lt;/p&gt;

&lt;p&gt;The kilobytes are nice. The silent deletions were the reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postscript: the CLI is Rust now
&lt;/h2&gt;

&lt;p&gt;The checker grew up after this post was drafted. &lt;code&gt;npm i -D overrule&lt;/code&gt; now installs a native binary. &lt;code&gt;guard()&lt;/code&gt; and the test assertion are not part of the rewrite and never will be: they run inside YOUR bundle, so they stay JavaScript.&lt;/p&gt;

&lt;p&gt;Honest numbers, because rewrite posts love to lie with them. On 84,300 files the node CLI takes 1.52s and single threaded Rust takes 1.15s. The merge engine dominates and the language barely matters. Then ten lines of rayon take it to 0.28s, because the scanner is embarrassingly parallel and Rust makes that safe. That is the actual argument for the rewrite, not the single thread delta.&lt;/p&gt;

&lt;p&gt;The name tables side is tailwind-fuse, the Rust port of tailwind-merge's tables. The stylesheet side still compiles your classes with Tailwind itself, because a reimplementation that drifts from the real one is EXACTLY the bug class this tool exists to catch. tailwind-fuse demonstrated the thesis within the hour: the first &lt;code&gt;cross&lt;/code&gt; run flagged the shadcn focus ring, wanting to delete &lt;code&gt;focus-visible:ring-ring/50&lt;/code&gt; sitting next to &lt;code&gt;focus-visible:ring-[3px]&lt;/code&gt;, because its tables cannot tell ring color from ring width through an arbitrary value. New tables, same disease, caught the same way. Every misread &lt;code&gt;cross&lt;/code&gt; has caught since is encoded in a patch layer, each verdict taken from a tailwind-merge run first. So the tables stopped losing the argument, and tailwind-merge stopped executing anywhere in my apps, not even in dev: the tests ask the binary for verdicts, the dev guard judges with a conflict map generated from the compiled stylesheet, and the package ships with no tailwind-merge at all... not a dependency, not a peer. What survives is the role it earned: the reference every table verdict was checked against before it was encoded.&lt;/p&gt;

&lt;p&gt;The same map now drives an eslint rule, &lt;code&gt;overrule/eslint&lt;/code&gt;, so the conflict shows under your cursor before it shows in CI. Its fix is the same rewrite the CLI does: losers removed, nothing else touched. The one existing eslint rule in this space judges with name tables and cannot fix; this one judges with your stylesheet and can.&lt;/p&gt;

&lt;p&gt;One last thing I learned so you do not have to: npm's spam filter permanently blocks new unscoped packages matching &lt;code&gt;*-win32-x64&lt;/code&gt;. Not rate limiting... the name itself. The windows binary is published as &lt;code&gt;overrule-windows-x64&lt;/code&gt;, which is also what git-cliff settled on after hitting the same wall.&lt;/p&gt;

</description>
      <category>tailwindcss</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>rust</category>
    </item>
  </channel>
</rss>
