Migrating a Bootstrap Codebase to Tailwind Without a Big-Bang Rewrite
Running both side by side, isolating the CSS reset conflict, and knowing when to stop migrating.


One of the projects I work on has been around for about six years. Most of its views are Laravel Blade templates, newer parts are built in Next.js, and nearly all of it was styled with Bootstrap.
Over time, the team started preferring Tailwind for new work. It was faster to build with, easier to keep consistent, and fit better with the component-based approach we were already using in Next.js.
Rewriting the whole UI wasn't an option. There were around four hundred views, a small team, and a product roadmap that kept moving. So we decided to migrate gradually: new features in Tailwind, existing pages moved over when it made sense.
That meant running Bootstrap and Tailwind side by side for a long time. This post covers how we set that up, the conflicts we ran into, and how we decided when the migration was done.
Why Not Just Rewrite It
Because a rewrite is a bet that nothing else will matter for three months, and in a product that's taking payments and running livestreams, something else always matters.
The honest math: four hundred views, a team of three, and a backlog that doesn't pause. A rewrite would have shipped nothing for a quarter and arrived with a hundred visual regressions nobody had time to find.
An incremental migration ships Tailwind on day one, in the one new page that uses it.
That page is the beachhead.
Everything after is about expanding it without breaking what's around it.
Day One: Both Stylesheets, One Page
The first version was almost embarrassingly simple. Bootstrap stayed exactly where it was. Tailwind got added to the build, and the new page used it.
<link rel="stylesheet" href="/css/bootstrap.min.css"><link rel="stylesheet" href="/css/tailwind.css">It worked for about a day.
Then someone opened an old page and every button had lost its styling, every heading had lost its margin, and every list had lost its bullets.
That's Preflight.
The Reset Conflict
Tailwind ships with a base layer called Preflight. It's a modern CSS reset: it strips default margins from headings, removes list styles, makes images display: block, and flattens buttons to unstyled boxes.
Bootstrap ships with its own base layer called Reboot. It does some of the same things, but it also sets a lot of defaults that Bootstrap components rely on: heading margins, button appearance, form control sizing, line heights.
Load both and Preflight wins on anything it touches last, which means Bootstrap components inherit a reset they were never designed for.
The <button class="btn btn-primary"> still has Bootstrap's padding and color, but Preflight has already removed the background-image and reset the border, and the result is a slightly wrong button on every legacy page.
Multiply by four hundred views.
First attempt: turn Preflight off
module.exports = { corePlugins: { preflight: false, },};This fixed the legacy pages instantly.
It also made the new Tailwind pages subtly worse. Without Preflight, a <h2 class="text-xl"> carries Reboot's margin-bottom, an <img class="w-full"> is inline with mysterious whitespace underneath, and a <ul class="space-y-2"> still has bullets.
Every new component needed a few extra classes to undo Bootstrap's opinions. Workable, but every one of those extra classes is tech debt that has to be removed when Bootstrap finally leaves.
What actually worked: scope the reset
The problem isn't Preflight. The problem is Preflight applying globally.
So I generated Preflight once, wrapped it in a scope, and applied it only where Tailwind lives:
/* preflight-scoped.css */.tw { /* paste the Preflight output here, each selector prefixed with .tw */}In practice I didn't paste by hand. A small PostCSS step wrapped Tailwind's base layer under a parent selector:
module.exports = { plugins: { tailwindcss: {}, "postcss-nested": {}, "postcss-prefix-selector": { prefix: ".tw", transform(prefix, selector, prefixed) { if (selector === "html" || selector === "body") return prefix; return prefixed; }, }, },};Then every migrated page or component gets a wrapper:
<div class="tw"> <!-- Tailwind-styled content, with Preflight active --></div>Inside .tw, headings have no default margin, buttons are flat, images are block. Outside it, Bootstrap's Reboot is untouched.
It's not pretty. But it drew a hard line between the two worlds, and that line is what made the rest of the migration safe.
The Utilities That Collide
Preflight is the loud conflict. The quiet one is class names.
Bootstrap and Tailwind both define .container, .rounded, .shadow, .border, .collapse, and the whole m-* / p-* spacing family.
Some of these are harmless. Some are traps.
.collapse is the worst one. In Bootstrap, it's a JavaScript-driven show/hide. In Tailwind, it's visibility: collapse. Add Tailwind to a page with a Bootstrap accordion and the accordion disappears.
Spacing is the sneaky one. In Bootstrap, m-3 is 1rem. In Tailwind, m-3 is 0.75rem. The class exists in both, both apply, and whichever stylesheet loads last wins. A legacy page can shift by four pixels because Tailwind was added to the bundle.
.container behaves differently too. Bootstrap's has padding and fixed max-widths at each breakpoint; Tailwind's has neither by default.
Two ways out, and I used both at different stages.
Prefix Tailwind
module.exports = { prefix: "tw-",};Now it's tw-flex, tw-m-3, tw-collapse. Nothing collides.
The cost is that every Tailwind class is three characters longer, and copy-pasting from the docs or from another project doesn't work. For the first few months, when legacy pages far outnumbered migrated ones, this was worth it.
Or make Tailwind win inside its scope
module.exports = { important: ".tw",};This emits every utility as .tw .flex { ... }, which out-specifics Bootstrap's single-class selectors inside the wrapper and does nothing outside it.
Combined with the scoped Preflight, this meant the .tw wrapper was the whole contract: inside, Tailwind wins; outside, Bootstrap does.
Once migrated pages became the majority, I dropped the prefix and kept the scope. The wrapper is still there today.
If you're on Tailwind v4, the same two ideas exist with different syntax: @import "tailwindcss" prefix(tw); and @import "tailwindcss" important;, and the base layer can be scoped with a nested @layer base under your wrapper.
Migrating in the Right Order
With the two systems isolated, the question became what to migrate first.
Not utilities. Not "replace all d-flex with flex." That's the instinct, and it's wrong, because a page that's half Bootstrap and half Tailwind is harder to reason about than a page that's fully either.
Pages and components, in roughly this order:
New features. Anything new was Tailwind from the start, no exceptions. This is the one rule that costs nothing and compounds forever.
Leaf components. Badges, avatars, empty states, small cards. Things with no children and no JavaScript. Each one is an afternoon, and each one removes a dozen Bootstrap classes from the pages that use it.
Layouts. The app shell, the sidebar, the top nav. Migrating these early meant every page got a .tw wrapper for free, and the migration started to feel visible.
Pages by traffic. The vehicle listing, the detail page, the checkout. The pages people actually use got migrated; the pages nobody opened stayed where they were.
Bootstrap JavaScript components, last. Modals, dropdowns, tooltips, the accordion. These aren't CSS migrations; they're behavior rewrites. Each one got replaced with a headless primitive (Radix on the Next.js side, a small Alpine component on the Blade side) only when its page was already migrated.
Measuring Progress Without Lying to Yourself
I kept one number on a dashboard: the count of Bootstrap class names across the codebase.
grep -rEo 'class="[^"]*\b(btn|row|col-[a-z0-9-]+|d-flex|mb-[0-9])\b' resources/views src | wc -lIt's crude. It doesn't care about Blade loops or dynamic classes. But it went from about 11,000 to under 1,000 over eight months, and watching it fall was the only thing that kept the migration from quietly stalling.
The number I didn't track was CSS bundle size, because it got worse before it got better. Bootstrap's full build plus Tailwind is heavier than either alone. That's the tax of running both, and pretending otherwise just makes people anxious.
What helped was switching the Bootstrap import from the compiled bundle to SCSS modules:
@import "bootstrap/scss/functions";@import "bootstrap/scss/variables";@import "bootstrap/scss/mixins";@import "bootstrap/scss/reboot";@import "bootstrap/scss/grid";@import "bootstrap/scss/buttons";@import "bootstrap/scss/forms";// no modal, no carousel, no tooltips...Every time a component type was fully migrated, its import got deleted. Bootstrap shrank as it was replaced, instead of sitting there at full size until the very end.
Knowing When to Stop
Here's the part nobody tells you.
I didn't finish.
Around the 85% mark, what was left was a cluster of internal admin pages, a few report views that print to PDF, and one legacy onboarding flow that worked fine and that nobody had a reason to open.
Migrating those would have been two or three weeks of pure cosmetic work on pages with no users and no roadmap.
So the rule became: Bootstrap is loaded only on legacy routes.
// Blade layout@if ($legacy) <link rel="stylesheet" href="/css/bootstrap-legacy.css">@endifMigrated pages don't load it at all. New pages can't use it. The remaining Bootstrap pages are a known list, each one tagged, and the list only shrinks.
That's "done" for a migration like this. Not zero Bootstrap, but zero Bootstrap on any page that matters, and a hard guarantee that it can't spread.
If someone touches one of those legacy pages for a real feature, they migrate it as part of the work. If nobody touches it, it stays.
The last 15% will probably leave with the pages themselves, when those features get retired.
What I'd Do Differently
Scope Preflight from day one. I lost two weeks to the global reset before I isolated it, and that cost more than every other step combined.
Pick the prefix decision early and don't revisit it. Switching from tw- to unprefixed mid-migration was a large, boring find-and-replace that I'd rather have avoided.
And set the "when to stop" rule at the start, not at 85%. If I'd decided up front that admin pages weren't in scope, the migration would have felt finished months earlier, instead of feeling like it was trailing off.
The Point
A migration like this isn't a project with an end date.
It's a change in the default. New code goes one way, old code stays where it is, and the boundary between them is explicit enough that neither can leak into the other.
Get the boundary right, keep the count falling, and stop when the pages that remain are pages nobody needs you to touch.