Editorial
18 April 2026 anonym93 Studio laravel blade alpinejs

How to Migrate from React SPA to Blade + Alpine.js: Real Case Study on anonym93.dev

I migrated anonym93.dev from a React SPA with 500KB JavaScript to Blade + Alpine.js with only 68KB CSS and zero application JS. Here's what I learned, what worked, and the Core Web Vitals scores before and after.

How to Migrate from React SPA to Blade + Alpine.js: Real Case Study on anonym93.dev

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:

  1. Inventory React pages and components
  2. Create Blade layout + base components (navbar, footer, hero)
  3. Rewrite page by page (home, articles, catalog, detail pages)
  4. Migrate interactivity to Alpine.js (mobile menu, locale switcher, theme toggle)
  5. Remove React dependencies and package.json cleanup
  6. 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.tsx

export 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 .tsx files from resources/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_modules 3x 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.

Cookie preferences

The controls below manage only local analytics consent (GA4). Google Ads / AdSense consent must be handled separately through Google Privacy & Messaging or another certified CMP. See details in Privacy Policy.