The Rewrite That Never Was
We needed a modern frontend. The Blade + Bootstrap + jQuery stack was showing its age. The design team had a new UI/UX vision. The natural instinct was: rewrite the frontend in React.
But big-bang rewrites fail. Joel Spolsky wrote about this in 2000. Fred Brooks explained it before that. The pattern is always the same: you spend months building The New Thing, the old thing keeps getting patches, the two diverge, and you end up with two broken systems instead of one working one.
So we didn't rewrite. We migrated. Page by page. Feature by feature. And we set up the architecture so both frontends could coexist without developers losing their minds.
Two Paths, One Codebase
The application runs two frontend architectures simultaneously:
| Path | Server Layer | UI Layer | Status |
|---|---|---|---|
| Legacy | Web controllers | Blade views | Bug fixes only |
| SPA | API controllers | React + TypeScript | All new features |
The Legacy path serves ~228 Blade views across ~59 web controllers. It works. Users depend on it. We're not touching it unless something's broken.
The SPA path is the target architecture. React 19, TypeScript 5, Tailwind CSS 4, mounted at /app via a catch-all route:
Route::get('/app/{any?}', [SpaController::class, 'index'])
->where('any', '.*')
->middleware(['auth', 'verified', 'onboarding', '2fa']);
The SPA gets the initial state (user, CSRF token) from Blade via window.__INITIAL_STATE__, then React Router handles everything client-side.
Environment Gating
The SPA is gated to local, staging, and testing environments. Production serves Legacy pages exclusively (with one exception we'll get to).
// SpaController
public function index()
{
if (! in_array(app()->environment(), ['local', 'staging', 'testing'])) {
abort(404);
}
return view('spa.index');
}
This means we can build, test, and iterate on the SPA without any risk to production. Staging gets the full SPA experience. Production gets the battle-tested Blade views.
When a feature is stable and tested in the SPA, we have two options:
- Wait until the SPA is ungated for production
- Ship it now using the interim wrapper pattern
The Interim Wrapper Pattern
This is the trick that made the migration practical. When an SPA page is ready for production but the SPA environment gate isn't lifted yet, we mount the React component inside a Blade shell.
Here's how it works:
1. The SPA component is the source of truth.
// resources/js/spa/pages/Dashboard/Dashboard.tsx
export function Dashboard({ dashboardUrl = '/app/dashboard' }) {
const { data } = useDashboard(dashboardUrl);
return (
<AppShell>
<DashboardContent data={data} />
</AppShell>
);
}
2. The interim wrapper renders it with legacy URL overrides.
// resources/js/dashboard/InterimDashboard.tsx
import { Dashboard } from '../spa/pages/Dashboard/Dashboard';
export function InterimDashboard() {
return <Dashboard dashboardUrl="/dashboard" />;
}
3. A standalone mount file hydrates it into a Blade shell.
// resources/js/dashboard/main.tsx
import { createRoot } from 'react-dom/client';
import { InterimDashboard } from './InterimDashboard';
createRoot(document.getElementById('dashboard-root')!).render(
<InterimDashboard />
);
4. A Blade view provides the mount point.
{{-- dashboard/v2.blade.php --}}
@extends('layouts.app')
@section('content')
<div id="dashboard-root"></div>
@viteReactRefresh
@vite('resources/js/dashboard/main.tsx')
@endsection
The key insight: the SPA component is always the source of truth. The interim wrapper is just a thin shell that renders the SPA component with different URL props. Bug fixes go into the SPA component and automatically apply to both the SPA and the legacy context.
We shipped six features this way:
| Feature | SPA Component | Interim Wrapper | Blade Shell |
|---|---|---|---|
| Dashboard | Dashboard |
InterimDashboard |
v2.blade.php |
| Onboarding | OnboardingWizard |
InterimOnboarding |
onboarding.blade.php |
| Planner | PlannerWizard |
InterimPlanner |
index.blade.php |
| Guide | Guide |
InterimGuide |
index.blade.php |
| Marketplace | Marketplace |
InterimMarketplace |
index.blade.php |
| History | PlannerHistory |
InterimPlannerHistory |
history.blade.php |
The Frontend Overhaul
The migration also involved modernizing the entire frontend stack. This happened in a deliberate sequence:
1. Tailwind CSS 4 + Shadcn/ui
Replace Bootstrap with Tailwind. Add Shadcn/ui for consistent React components. This was the foundation layer.
2. SPA pages migrated
All existing React pages updated to use Tailwind and Shadcn/ui components.
3. jQuery elimination
Every $(document).ready() and $.ajax() call replaced with vanilla JS. jQuery removed from the bundle.
4. Blade template migration
All 228 Blade views migrated from Bootstrap classes to Tailwind. This was the biggest single PR, but it was almost entirely CSS class changes; no logic changes.
5. Livewire to React
The few Livewire components we had were rebuilt in React.
6. Dead code removal
Legacy frontend dependencies, unused JS files, and Bootstrap artifacts cleaned out.
Each of these was a separate PR, tested independently, merged to main within a day or two. No long-lived branches. No merge conflicts. The test suite verified nothing broke after each change.
Feature Flags
For features that needed gradual rollout or A/B testing, we used a feature flag / analytics service:
// Server-side feature gate
if ($this->analytics->checkGate($user, 'new_dashboard_layout')) {
return view('dashboard.v2');
}
return view('dashboard.index');
The analytics service also handles event tracking. Every meaningful user action (order created, ticket submitted, dashboard viewed) gets logged:
$this->analytics->logDashboardView($user, 'v2');
The AnalyticsService wraps the SDK and no-ops when ANALYTICS_ENABLED=false, so tests and local development aren't affected.
Scoping Rules
To keep everyone sane (humans and agents), we defined clear scoping rules:
Bug in a legacy-only domain? Fix in the web controller and Blade view.
Bug in a migrated domain? Fix in the SPA React component. It automatically applies to both contexts.
New feature in any domain? Build the API endpoint and SPA page. No new Blade features.
Migrating a domain? Follow the sequence: extract to Actions → build API controller → create React page → create interim wrapper if needed.
These rules are documented in the CLAUDE.md harness files (we'll get to those in posts 7–8). The agent reads the rules and follows them. No ambiguity about where new code goes.
The Asset Pipeline
Vite 6 handles both the SPA and the interim wrappers:
// vite.config.ts
export default defineConfig({
plugins: [
laravel({
input: [
'resources/js/spa/main.tsx', // SPA entry
'resources/js/dashboard/main.tsx', // Interim: Dashboard
'resources/js/onboarding/main.tsx', // Interim: Onboarding
'resources/js/planner/main.tsx', // Interim: Planner
// ...
],
}),
react(),
],
});
Each interim wrapper gets its own entry point. Vite tree-shakes unused code. The SPA gets its own bundle. Blade pages get the specific entry they need via @vite().
The Takeaway
- Never rewrite. Migrate. Page by page, feature by feature, with both systems running in parallel.
- Gate the new thing. Don't ship the SPA to production until it's proven in staging.
- Use wrappers for early release. The interim pattern lets SPA pages ship inside legacy shells.
- SPA component is always the source of truth. The wrapper is just plumbing.
- Clear scoping rules prevent confusion about where code goes.
- Feature flags for gradual rollout and experimentation.
This architecture means we're never stuck. We can ship features to production today (via interim wrappers) while building toward the full SPA. No pressure. No big bang. Just steady progress.
Top comments (0)