Porting a React single-page app to Blade
Eight components behind one URL became eight URLs. What that changed, what it improved, what was dropped as React-specific machinery, and the asset bug that made every page load with no stylesheet at all.
This site used to be a React single-page application. It is now a LibxaFrame Blade application, with the same design and the same content. Here is what the port actually involved.
The shape of the difference
The React build was one HTML shell, a client-side router, and eight page components. This is eight URLs, each returning a complete document. Every decision below follows from that.
Client state became URLs
The docs version picker, the news category filter, the changelog type filter
and the sign-in/sign-up toggle were all useState. None of them had an address.
You could not send someone a link to the v1 documentation, or to the security
articles, or to the breaking changes before an upgrade.
They are query parameters now:
/docs?v=v1
/news?category=Security
/changelog?product=desktop&type=breaking
/auth?mode=signup
Each is a page the server renders. Shareable, bookmarkable, linkable, and crawlable, which the SPA versions were not.
This is the part of the port that is a genuine improvement rather than a sideways move. The React implementation was not wrong to use state; it just had no cheap way to make those views addressable.
The documentation went back to being Markdown
It had been pasted into a TypeScript template literal so a bundler could carry it: 850 lines of Markdown wrapped in backticks, escaped where the content contained backticks of its own.
It now lives in src/resources/docs/*.md and is converted by league/commonmark
on the server, with the result cached to disk and reused until the source
changes. Parsing 850 lines of Markdown on every request is waste when it only
changes on deploy.
One detail worth stealing: the heading ids and the sidebar outline are
generated by the same slug() function. Two independent slug
implementations look fine until one of them handles a colon differently, and
then every anchor on the page silently stops working.
Only interactive things kept JavaScript
The theme toggle, the mobile drawer, the command palette, the cookie banner and the scroll chrome are genuinely interactive, so they were reimplemented: about 250 lines of plain DOM code.
Everything else is HTML before it reaches the browser. React, React Router, Framer Motion, Fuse.js, react-markdown, highlight.js and lucide-react are all gone.
The command palette is a good illustration. The React version searched with Fuse.js. The list is a dozen fixed entries; a substring match over a data attribute does the same job without shipping a search library to do it.
Icons went the same way: lucide-react rendered a few dozen static paths, so
those paths are now inlined in a Blade partial under their Lucide names.
Two things that only pretended to work
The newsletter form fired a success toast without sending anything anywhere. It now posts, validates the address, and records it, so the confirmation corresponds to something that happened.
The 404 page existed as a component, but the catch-all route rendered it inside the SPA shell with a 200 status. It is now a real 404 on any unmatched URL, which matters to anything that is not a human with a browser.
The bug that cost the most time
The framework's @vite() directive picks the dev server whenever APP_ENV is
local, with no check that anything is listening on port 5173. Serve the site
without npm run dev running and every page comes out pointing at a dev server
that is not there, with no stylesheet at all.
The page looks completely broken, and nothing in the markup says why.
The fix was to decide on evidence instead of on a config flag. npm run dev
writes a hot file and deletes it on exit; while that file exists, assets come
from the dev server. Otherwise the hashed files from the build manifest are
used. If neither is available, the page carries an HTML comment saying to run
npm run build, which beats a silently unstyled page that looks like the CSS
itself is broken.
Both modes are now correct by default, with nothing to remember and nothing to flip.
What was not carried over
AnimatedCounter, PageTransition, Skeleton, Tooltip, Toast and
VersionBanner were React machinery around content that is now server-rendered.
There is no loading state to skeleton and no route transition to animate. A
single fade-up on page load replaces the Framer Motion choreography, and it
respects prefers-reduced-motion.
Placeholder images were dropped rather than reproduced. Contributor avatars,
partner logos and article thumbnails came from picsum.photos; initials,
wordmarks and tinted panels keep the layout, cost no third-party request, and
cannot break when that service is slow.