<?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: Vladimir Elchinov</title>
    <description>The latest articles on DEV Community by Vladimir Elchinov (@vladimir_elchinov).</description>
    <link>https://dev.to/vladimir_elchinov</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%2F3418110%2Faca869d2-bf72-4840-ba6b-528b8be3a025.png</url>
      <title>DEV Community: Vladimir Elchinov</title>
      <link>https://dev.to/vladimir_elchinov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vladimir_elchinov"/>
    <language>en</language>
    <item>
      <title>A Web Page Can Tell Which Extensions You Have Installed. Here Is How.</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Tue, 01 Sep 2026 15:47:33 +0000</pubDate>
      <link>https://dev.to/session_replay/a-web-page-can-tell-which-extensions-you-have-installed-here-is-how-553d</link>
      <guid>https://dev.to/session_replay/a-web-page-can-tell-which-extensions-you-have-installed-here-is-how-553d</guid>
      <description>&lt;p&gt;Open a page and it can start guessing which browser extensions you run before you click a thing. Not "extensions in general" - &lt;em&gt;which ones&lt;/em&gt;. Your password manager, your ad blocker, the wallet, the internal tool your employer ships, the accessibility extension you depend on. The page never asks and you never see it happen.&lt;/p&gt;

&lt;p&gt;This is not a bug in Chrome. It is the sum of a few features working exactly as designed, and the people best placed to close it are extension authors who mostly do not know they left it open. I maintain an extension and a library that talks to it, so I have spent real time on the detectable side of this. Here is how a page does it, what the answer is worth to whoever is asking, and what actually stops it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technique one: ask the extension directly
&lt;/h2&gt;

&lt;p&gt;Some extensions accept messages from web pages on purpose - our own does, so a customer's "report a bug" button can tell whether the extension is there. The API is &lt;code&gt;chrome.runtime.sendMessage&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EXTENSION_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ping&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="nx"&gt;reply&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;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// it is installed, and it answered&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;For a page to be &lt;em&gt;allowed&lt;/em&gt; to send that message, the extension has to list the page's origin in its manifest, under &lt;code&gt;externally_connectable&lt;/code&gt;. Authors who want their extension to work with any site reach for the wildcard:&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="nl"&gt;"externally_connectable"&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;"matches"&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="s2"&gt;"&amp;lt;all_urls&amp;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;And that one line is the door. &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; does not mean "my customers' sites". It means every site on the internet may now open a channel to this extension - which means every site may ping it and learn whether you have it. The convenience the author wanted for their own pages, they handed to everybody's.&lt;/p&gt;

&lt;p&gt;This technique is narrow, because it only finds extensions that chose to talk to pages. The next one is not narrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technique two: knock on the extension's own files
&lt;/h2&gt;

&lt;p&gt;Extensions ship assets - icons, injected stylesheets, images. Any asset marked &lt;em&gt;web-accessible&lt;/em&gt; is reachable at a fixed URL built from the extension's id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chrome-extension://&amp;lt;extension-id&amp;gt;/icon-128.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fixed. Which means a page does not have to be given permission to look. It just tries to load the file and watches what happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasExtension&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;path&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;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&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;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onload&lt;/span&gt; &lt;span class="o"&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;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&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;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`chrome-extension://&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="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&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="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;onload&lt;/code&gt; fires, the extension is installed. &lt;code&gt;onerror&lt;/code&gt;, it is not. No message, no cooperation, nothing the extension author has to have agreed to beyond publishing a web-accessible file - which almost all of them do. Extension ids are public and stable; you read them off the store. So a page carries a list of ids and the resource path each one exposes, loops over it, and comes out the other side with an inventory. This is the workhorse, and it is old - security researchers demonstrated it at scale years ago and it still works today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technique three: do not look for the extension, look for its fingerprints
&lt;/h2&gt;

&lt;p&gt;The two above need the extension's id. This one does not, and it catches the extensions that never talk to pages and ship no web-accessible files.&lt;/p&gt;

&lt;p&gt;An extension that changes a page leaves marks, and a page can bait them out. An ad blocker hides elements that look like ads, so plant one and see if it vanishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;bait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ad-banner ad-slot sponsored&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;bait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cssText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;position:absolute;height:10px;left:-9999px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bait&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;requestAnimationFrame&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;blocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offsetHeight&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
                  &lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bait&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// blocked === true means something is hiding ad-like elements&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A password manager injects an icon into password fields, so put a hidden password input on the page and watch for the DOM to change around it. You do not learn &lt;em&gt;which&lt;/em&gt; ad blocker or &lt;em&gt;which&lt;/em&gt; password manager, but you learn the visitor runs one - and often that is the fact that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  So a page has a list of your extensions. What is that worth?
&lt;/h2&gt;

&lt;p&gt;This is the part that turns a curiosity into a reason to care.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is a near-unique fingerprint.&lt;/strong&gt; The particular set of extensions you run is close to an identifier, and unlike a cookie you cannot clear it. It rides along with every other fingerprint signal and sharpens all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It deanonymises you by inference.&lt;/strong&gt; This is the sharp end. Extensions are not neutral - many of them say something about the person:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an internal SSO or admin extension that only one company ships → the visitor works there&lt;/li&gt;
&lt;li&gt;a specific screen reader or accessibility extension → an accessibility need, which is about as sensitive as attributes get&lt;/li&gt;
&lt;li&gt;a particular crypto wallet → the visitor holds crypto, which is exactly what a phishing page wants to know &lt;em&gt;before&lt;/em&gt; it decides whether you are worth the effort&lt;/li&gt;
&lt;li&gt;a competitor's extension → this landing page can quietly greet their customer differently from everybody else&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;It happens before you do anything.&lt;/strong&gt; Every technique above runs on page load. By the time you have read the headline, the page has tailored itself - or decided what you are - off a signal you did not know you were sending.&lt;/p&gt;

&lt;p&gt;None of this requires a breach or a trick. It is the platform behaving as documented, aimed at a question you were never asked whether you wanted answered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually stops it
&lt;/h2&gt;

&lt;p&gt;The good news is that the fixes exist and most of them belong to the extension author, not to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For technique two, Manifest V3 gave authors the answer:&lt;/strong&gt; &lt;code&gt;use_dynamic_url&lt;/code&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="nl"&gt;"web_accessible_resources"&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;"resources"&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="s2"&gt;"icon-128.png"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"matches"&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="s2"&gt;"https://your-real-site.com/*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"use_dynamic_url"&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="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;Two things there. &lt;code&gt;use_dynamic_url&lt;/code&gt; makes the resource's URL a per-session random token instead of the fixed path, so the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; probe has nothing stable to request. And &lt;code&gt;matches&lt;/code&gt; narrows &lt;em&gt;who&lt;/em&gt; may load the resource at all, instead of leaving it open to every origin. Ship a web- accessible file with neither and you are the reason technique two still works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For technique one, do not write &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt;.&lt;/strong&gt; List the origins that genuinely need to talk to your extension. If that list cannot be known ahead of time - it is our exact case, a library any customer can install - then do not use &lt;code&gt;externally_connectable&lt;/code&gt; at all. We do detection the other way around: the extension injects a content script that listens for a &lt;code&gt;CustomEvent&lt;/code&gt; the page dispatches, and answers with another. A page that has not deliberately loaded our library and fired the event learns nothing, because the channel only carries a reply to a page that asked. The page can only find the extension by cooperating with it, which is the opposite of a silent probe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For technique three there is no clean fix,&lt;/strong&gt; because the detection is of the extension's &lt;em&gt;effect&lt;/em&gt;, not the extension. Injecting into the page is the whole job. The best an author can do is be less trivially baitable - scope styles tightly, avoid marker classes and ids a page can guess. It is mitigation, not a cure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And as a person who just wants to be probed less:&lt;/strong&gt; the extensions you install are a fingerprint, so run fewer of them, and keep the ones that matter in a separate browser profile from the one you do sensitive things in. A profile with three extensions is a much larger crowd to hide in than one with thirty.&lt;/p&gt;




&lt;p&gt;The theme under all of it is the one that runs through most browser-privacy problems: a feature added for a good reason, used at its most convenient setting, adds up to a capability nobody decided to grant. &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; and a fixed resource URL are each perfectly reasonable in isolation. Together they mean the page you just opened knows things about you that you would not have told it.&lt;/p&gt;

&lt;p&gt;If you write extensions, the ten minutes it takes to set &lt;code&gt;use_dynamic_url&lt;/code&gt; and a real &lt;code&gt;matches&lt;/code&gt; list is the cheapest privacy win you will ship this year. Your users cannot do it for you, and they will never know you did.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>security</category>
      <category>webdev</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Why I Chose Bootstrap Over Tailwind for My Rails Template</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Wed, 06 Aug 2025 23:56:42 +0000</pubDate>
      <link>https://dev.to/vladimir_elchinov/why-i-chose-bootstrap-over-tailwind-for-my-rails-template-74f</link>
      <guid>https://dev.to/vladimir_elchinov/why-i-chose-bootstrap-over-tailwind-for-my-rails-template-74f</guid>
      <description>&lt;h2&gt;
  
  
  The Tailwind Monopoly in Rails Templates
&lt;/h2&gt;

&lt;p&gt;If you've looked for a Rails application template recently, you've probably noticed something: They ALL use Tailwind CSS.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Jumpstart Pro? Tailwind.&lt;/li&gt;
&lt;li&gt;Bullet Train? Tailwind.&lt;/li&gt;
&lt;li&gt;Business Class? Tailwind.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't get me wrong - Tailwind is great for many developers. But what about those of us who prefer Bootstrap's component-based&lt;br&gt;
  approach?&lt;/p&gt;
&lt;h2&gt;
  
  
  Why I Still Prefer Bootstrap
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Component Classes Make Sense
&lt;/h3&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"btn btn-primary"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Click me&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Click me
  &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I'm prototyping quickly, I want to think in components, not utility classes.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Existing Ecosystem
&lt;/h3&gt;

&lt;p&gt;Bootstrap has thousands of themes, templates, and components ready to use. When a client needs something fast, I can grab a&lt;br&gt;
  Bootstrap theme and integrate it immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Team Familiarity
&lt;/h3&gt;

&lt;p&gt;Many developers already know Bootstrap. Onboarding is easier when your team doesn't need to learn a new utility-first philosophy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter Rails Blueprint
&lt;/h2&gt;

&lt;p&gt;After searching for a modern Rails template with Bootstrap and finding nothing, I built Rails Blueprint.&lt;/p&gt;

&lt;p&gt;What's Included&lt;/p&gt;

&lt;p&gt;Free Tier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rails 8.0.2 with Ruby 3.4.4&lt;/li&gt;
&lt;li&gt;Bootstrap 5.3 with custom theming&lt;/li&gt;
&lt;li&gt;Authentication with Devise&lt;/li&gt;
&lt;li&gt;Admin panel&lt;/li&gt;
&lt;li&gt;Blog/CMS with Trix editor&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus Tier (€100/year):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OAuth (Google, GitHub)&lt;/li&gt;
&lt;li&gt;Dark theme support&lt;/li&gt;
&lt;li&gt;Avatar uploads&lt;/li&gt;
&lt;li&gt;Lead collection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pro Tier (€500/year):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;REST API with JWT&lt;/li&gt;
&lt;li&gt;Stripe integration&lt;/li&gt;
&lt;li&gt;Swagger documentation&lt;/li&gt;
&lt;li&gt;Feature flags&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Technical Stack&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rails 8.0.2 - Latest version with Hotwire&lt;/li&gt;
&lt;li&gt;Bootstrap 5.3 - With CSS variable customization&lt;/li&gt;
&lt;li&gt;Stimulus - For JavaScript sprinkles&lt;/li&gt;
&lt;li&gt;PostgreSQL - Rock-solid database&lt;/li&gt;
&lt;li&gt;Good Job - Database-backed job processing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's Not About Bootstrap vs Tailwind&lt;/p&gt;

&lt;p&gt;This isn't a war between CSS frameworks. It's about having options.&lt;/p&gt;

&lt;p&gt;Tailwind developers have dozens of Rails templates to choose from. Bootstrap developers had zero modern options - until now.&lt;/p&gt;

&lt;p&gt;Try It Out&lt;/p&gt;

&lt;p&gt;The basic tier is completely free and open source:&lt;/p&gt;

&lt;p&gt;🔗 Website: &lt;a href="https://railsblueprint.com" rel="noopener noreferrer"&gt;https://railsblueprint.com&lt;/a&gt;&lt;br&gt;
  🔗 GitHub: &lt;a href="https://github.com/railsblueprint/basic" rel="noopener noreferrer"&gt;https://github.com/railsblueprint/basic&lt;/a&gt;&lt;br&gt;
  🔗 Live Demo: &lt;a href="https://pro.railsblueprint.com" rel="noopener noreferrer"&gt;https://pro.railsblueprint.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Conclusion&lt;/p&gt;

&lt;p&gt;If you're a Bootstrap developer feeling left behind by the Rails community's Tailwind obsession, Rails Blueprint is for you.&lt;/p&gt;

&lt;p&gt;And if you're a Tailwind developer, that's cool too - you've got plenty of great options already!&lt;/p&gt;

&lt;p&gt;What's your take? Are you Team Bootstrap, Team Tailwind, or Team Whatever-Works?&lt;/p&gt;

</description>
      <category>rails</category>
      <category>bootstrap</category>
      <category>webdev</category>
      <category>ruby</category>
    </item>
  </channel>
</rss>
