Rebuilding mikneri.dev: CI4 to Laravel 12 + React islands
This site used to run on CodeIgniter 4 with a custom CMS. It worked, it ranked, and it paid for itself in client inquiries. I rebuilt it anyway, because the site is my proof of work, and the proof was aging. The rules I set made it interesting: keep every URL identical, keep every line of SEO copy, hit Lighthouse 100 on accessibility, best practices, and SEO, and do it all on shared hosting with no Node runtime. This is the build log of how that went, including the two architectures I threw away.
The constraint that shaped everything
The site had one non-negotiable job before any pixel moved: it converts search traffic into contract work. That meant server-rendered HTML on every route, crawlable with JavaScript off, and no URL changes at all. Any architecture that gambled with that was out, no matter how modern it looked on paper.
The second constraint was the host. This runs on cheap shared hosting. There is no Node process, no SSR server, no container. Whatever renders HTML had to be PHP answering the request directly.
Two dead ends before the right answer
First attempt was Inertia. It is the default answer for Laravel plus React in 2026, and it was wrong for this. Inertia renders the page as a JSON-fed client app; real server HTML needs the SSR mode, which needs a running Node process, which the host does not have. Without it, the crawl story degrades exactly where this site cannot afford it. CSRF handling across the adapter also fought me in ways that felt like fighting the framework's grain.
Second attempt was prerender snapshots: build-time static HTML of each route, hydrated later. It demoed well and broke subtly. Hydration mismatches between snapshot and live state, and CSRF tokens baked stale into the snapshots. A contact form that fails CSRF on a portfolio site is a lost client, silently.
stderr: lesson. Both dead ends came from the same mistake: reaching for an architecture first and checking the constraint second. The host has PHP. Blade already renders HTML in PHP. The answer was under my feet.
The architecture that shipped: Laravel 12, Blade renders every route as a full server document, and React 19 mounts as islands only where interaction earns its cost. A data-island attribute marks a mount point, one small registry in app.tsx hydrates whatever islands the page declares. No router, no hydration of prose, no JSON bridge. The main bundle is about 62KB gzip; the WebGL world is a lazy chunk that never loads unless a page needs it.
The door
The home page is the part people remember. You land on a low-poly island at sunset: faceted cliffs, palms, a Gerstner-wave ocean, and a wooden surf shack with a glowing door. Scrolling flies the camera through that door, and crossing the threshold drops you into a dark terminal interior where the portrait hero and the rest of the site live. One WebGL renderer carries the whole transition; nothing unmounts, the scene crossfades at the doorframe.
I did not commit to this on faith. Day one was a go/no-go spike with hard numbers: on a 4x CPU-throttled mobile profile, the threshold crossing produced zero frames over 50ms, worst frame 41.7ms. Desktop worst frame was 8.4ms, locked 60fps through the whole scroll. The fallback, an interior-only home, was specced and ready if the spike failed. It did not fail.
The 41.7ms outlier taught me something worth keeping: it was not shader compile, it was the first GPU upload of the point-cloud buffers hitting exactly at the door. The fix is to issue a hidden warmup draw during the camera descent, so the upload cost lands where nobody can see it.
The portrait hero went through its own redesign after I reviewed the first build. The original plan kept my portrait as a permanent particle cloud with cursor repel. Built, it read as scattered dots, not a person. The shipped version rests as a crisp photo, and only shatters locally around the cursor, a soft hole that tracks the pointer while the rest of the image stays sharp. Same assets, opposite hierarchy: the effect serves the photo instead of replacing it.
Page transitions without a router
The plan allowed a client-side router like Taxi or Barba for page transitions. I rejected it and used the native multi-page View Transitions API instead: one CSS at-rule, and supporting browsers crossfade between fully server-rendered documents with zero JavaScript. Everyone else gets a normal fast navigation. A router would have added interception logic against my JS budget to buy a fade I could have for free.
What the numbers say
Main JS about 62KB gzip, CSS about 10KB gzip. Home LCP 532ms on a 4x throttled CPU, CLS 0.00. Accessibility, Best Practices, and SEO all score 100 on every audited route. The LCP element, the ocean poster, went from 1.19MB PNG to 59KB WebP with ImageMagick at quality 82, with media-scoped preloads so a phone never downloads the desktop image.
A few smaller scars, so you do not earn them yourself: Tailwind v4 scans templates at build time, so a new Blade file silently has no styles until you rebuild. A ShaderMaterial uniform used in both vertex and fragment shaders must declare matching precision or the program silently links nothing. And php artisan serve is single-threaded, so throttled LCP numbers on the dev server are fiction; measure on the production host.
stderr: what I'd do differently. I would run the UI/UX audit continuously instead of as a pre-ship gate. Two accessibility bugs (an inert chat panel and mislabeled icon buttons) survived until the final sweep. They were cheap to fix and embarrassing to find late.
The rebuild is the same kind of work I do for clients: hard SEO constraints, a real performance budget, and an experience that outlives the first visit. The full route-by-route work history is on /work, and the door is on the home page, if you want to walk through it.