Why I migrated from React to Blade + Alpine
Until recently, anonym93.dev was a classic React SPA: Vite + React 18 + React Router v6 + TanStack Query + shadcn/ui. The entire public side (articles, portfolio, marketing pages) was rendered client-side after the browser downloaded and executed a ~500KB JavaScript bundle.
Motivation for the migration came from three directions:
- Weak SEO: Google only partially indexed pages — content was visible only after hydration
- Poor Core Web Vitals: LCP over 3 seconds on mobile, INP over 300ms
- Unnecessary complexity: I had React + TanStack Query just to fetch static content from the CMS
The solution: Blade (Laravel) + Alpine.js for minimal interactivity. Vite stays just for CSS (Tailwind v4). Zero application JavaScript.
Before vs after (the real numbers)
Bundle size
- Before: ~500KB JS + 68KB CSS = 568KB critical assets
- After: 68KB CSS + ~15KB Alpine.js (CDN, cached) = 83KB
- Reduction: 85%
Core Web Vitals (mobile, simulated 4G)
- LCP: 3.2s → 0.9s
- INP: 310ms → 45ms
- CLS: 0.12 → 0.02
- TTFB: 180ms → 250ms (SSR has a small cost, but worth it)
Google indexing
- Before: ~40% pages correctly indexed (content visible to crawler)
- After: 100% pages indexed, full content in initial HTML
The migration plan
The migration took about 2 weeks of part-time work. Main steps:
- Inventory React pages and components
- Create Blade layout + base components (navbar, footer, hero)
- Rewrite page by page (home, articles, catalog, detail pages)
- Migrate interactivity to Alpine.js (mobile menu, locale switcher, theme toggle)
- Remove React dependencies and package.json cleanup
- Update tests and CI
Step 1: From React components to Blade components
Concrete example — an article card. Before, in React:
// resources/js/src/components/ArticleCard.tsxexport function ArticleCard({ article }: { article: Article }) {
return (
<a href=/articles/${article.slug}} className="group rounded-xl border p-6">
<h3 className="text-lg font-bold">{article.title}</h3>
<p className="text-sm text-muted">{article.excerpt}</p>
</a>
);
}
After, in Blade:
{{-- resources/views/components/article-card.blade.php --}}@props(['article'])
<a href="/articles/{{ $article['slug'] }}" class="group rounded-xl border p-6">
<h3 class="text-lg font-bold">{{ $article['translation']['title'] }}</h3>
<p class="text-sm text-muted">{{ $article['translation']['excerpt'] }}</p>
</a>
Usage in a page: <x-article-card :article="$article" />. Simple, declarative, server-rendered — zero JS.
Step 2: Interactivity — when you call Alpine.js
Alpine.js covers 90% of interactivity needs on marketing sites: mobile menu, dropdowns, modals, tabs, theme toggle, simple forms.
Example — mobile menu toggle. Before needed React state:
const [open, setOpen] = useState(false);return (
<button onClick={() => setOpen(!open)}>Menu</button>
{open && <nav>...</nav>}
);
After, in Alpine (one line of code):
<div x-data="{ open: false }"> <button @click="open = !open">Menu</button>
<nav x-show="open" x-transition>...</nav>
</div>
Alpine loads from CDN (14KB minified gzipped), no build step, no hydration.
Step 3: Data fetching — from TanStack Query to PHP Controller
Before, each page fetched from an API:
const { data: articles } = useQuery({ queryKey: ['articles'],
queryFn: () => fetch('/api/cms/content?kind=ARTICLE').then(r => r.json()),
});
After, in a Laravel controller:
public function articles(Request $request){
$articles = ContentItem::query()
->where('kind', 'ARTICLE')
->where('status', 'PUBLISHED')
->with('translations')
->orderBy('sort_order')
->get();
return view('pages.articles', [
'articles' => $articles->toArray(),
]);
}
Data arrives directly in the view, server-rendered, with zero network round-trip after load.
Step 4: What I deleted (without fear)
After migration, I deleted about 98 React files:
- All
.tsxfiles fromresources/js/src/(pages, components, hooks, lib, providers) tsconfig.json,resources/js/app.tsx,resources/js/bootstrap.js- 45 dependencies from
package.json: react, react-dom, react-router-dom, @tanstack/react-query, 19 @radix-ui packages, lucide-react, shadcn utilities
Final package.json has only devDependencies for Tailwind, Vite, and Laravel plugin — no runtime dependencies.
Step 5: CSS — Tailwind stays
The good part: Tailwind v4 works identically in Blade. I didn't have to rewrite any CSS class. The only change was in tailwind.config.js — I switched scan paths from .tsx to .blade.php:
content: [ "./resources/views/**/*.blade.php",
// "./resources/js/src/**/*.{ts,tsx}", <-- removed
],
Pitfalls I encountered
1. Vite manifest in CI tests
After migration, PHPUnit tests failed with ViteManifestNotFoundException because the Blade layout used @vite('resources/css/app.css'), and CI didn't run npm run build. Fix: $this->withoutVite() in the setUp() of tests that render pages.
2. Release artifact didn't include lang/
I had a release where lang/ro.json and lang/en.json were missing from the artifact, and the live site displayed translation keys (nav.home instead of "Home"). Fix: add lang to config/updater.php -> include_paths.
3. SetLocale middleware doesn't apply to 404
Error pages are rendered by Laravel outside the route middleware stack. I added inline locale detection in resources/views/errors/404.blade.php:
$locale = request()->query('locale') ?? request()->cookie('locale')
?? session('locale')
?? config('app.locale', 'ro');
app()->setLocale($locale);
When you should NOT do this migration
It's not a silver bullet. Don't migrate if:
- Your app is a dashboard with lots of state and client-side routing
- You have a frontend team specialized in React and no PHP experience
- You have complex websocket or real-time streaming integrations
- You've heavily invested in custom React components or a design system
The migration makes sense for public sites with predominantly static content: blogs, portfolios, small e-commerce, marketing sites.
Final results
- ~98 React files deleted, ~12,000 lines of code removed
- ~45 npm dependencies removed,
node_modules3x smaller - Build time dropped from 18s to 2s
- Core Web Vitals in the green zone on all metrics
- Google indexing 100% vs ~40% before
- Filament admin panel unchanged — continues to work independently
Conclusion
For my site, migrating from React SPA to Blade + Alpine.js was one of the most valuable technical decisions of the past year. Less code to maintain, better performance, better SEO, fast builds. And most importantly — I can write content without worrying about whether it's indexed correctly.
For the initial Laravel 12 + Vite + Tailwind v4 setup, see Complete Guide: Laravel 12 + Vite + Tailwind v4.
For the detailed comparison between SSR, SPA, and SSG, read SSR vs SPA vs SSG — Which to Choose.