About a hundred React pages that the server renders for the first visit, one component library, typed routes, and a three.js scene that draws the Warehouse from the same data as the tables.
Pages without an API
Each page is a React component in resources/js/pages, and its folder mirrors the URL. A controller names the page and passes its props; TypeScript types describe what arrives. There is no global store: the props are the state, and after every change the server sends fresh ones.
The layout follows from the folder. Pages under auth get the sign-in layout, pages under mobile the Handheld layout, settings pages a nested layout, and everything else the application layout with the sidebar and the app switcher.
The React Compiler runs as part of the build and memoises components automatically. Hand-written useMemo survives almost only in the Floor, where it guards expensive geometry.
Rendered on the server first
A page opened from its address, a bookmark, or a link from elsewhere arrives as finished HTML. Laravel collects the props as usual and posts the page to the SSR server, a Node.js process that runs the same React pages and layouts as the browser and answers with the markup. The visitor sees the page before any JavaScript has loaded; search engines and link previews receive the same HTML.
The browser then loads the JavaScript, and React hydrates the page: it builds the same component tree, finds the markup already in place, and attaches its event handlers instead of drawing everything again. This only works if both sides produce identical markup. app.tsx for the browser and ssr.tsx for the server therefore take the page title, the layouts, and the providers from one shared file, lib/inertia-app.tsx.
Only the first visit goes through the SSR server. Every later click is an ordinary Inertia visit that returns the props as JSON, and the page changes in the browser.
Some parts exist only in a browser. three.js needs a WebGL canvas, so the Floor pages ask useIsHydrated() whether they already run there: the server and the first render in the browser show a skeleton, and the scene starts right after hydration. The hero on the home page receives its floor data as a deferred prop, which only the browser requests, so the server sends the loading drawing with the reach truck.
When the SSR server is down or takes longer than two seconds, Laravel sends the page without the markup and the browser renders it alone. Nothing is missing then; the page only appears a moment later.
BrowserVisitorLaravelWeb appNode.jsSSR serverGET /ordersBuilds the propsPOST /renderRenders the pageHTMLHTML + propsPage is visibleReact hydratesEvery later clickX-Inertia: trueNot involvedProps as JSON
The first visit through the SSR server, and every click after it.
shadcn/ui, Tailwind, and themes
The interface uses stock shadcn/ui components in the base-nova style, built on Base UI primitives. Their source lives in resources/js/components/ui, 40 files, and is kept as close to the original as possible. Icons are from lucide, the typefaces are Geist and Geist Mono.
Tailwind CSS 4 is configured in CSS, not in JavaScript. app.css defines the theme tokens as CSS variables in OKLCH, once for light and once for dark. Components use only tokens such as bg-card or text-muted-foreground, so a new screen works in both themes without extra work.
The chosen theme is stored in a cookie as well as in the browser. The server reads the cookie and sets the dark class before the page is sent, so a dark page never flashes white first.
The figures in this Documentation are hand-written SVG inside React components. They use the same tokens, which is why they change with the theme, and most have a second, upright layout for phones.
Typed routes with Wayfinder
Laravel Wayfinder reads the routes and controllers and generates a TypeScript function for each. A page imports, for example, show from @/actions/App/Http/Controllers/PalletController and calls show(pallet.id) to get the URL and the HTTP method. Forms spread store.form() into Inertia’s <Form> component.
The generated files are not committed; the Vite plugin rebuilds them whenever a route changes. Renaming a route or changing its parameters turns every outdated link into a type error at build time, before a visitor can click it.
Forms, tables, and filters
Simple forms use Inertia’s <Form> component. Dialogs and multi-step forms use the useForm hook. Validation always happens on the server, and the errors come back as props next to the fields.
Lists are sorted, filtered, and paginated on the server. Two PHP traits, SortsTables and PaginatesTables, give every list controller the same behaviour: a whitelist of sortable columns, a stable tie-breaker, and page sizes of 25, 50, 100, or 250 remembered per list in a cookie. On the client, the filters live in the query string, so a filtered list can be shared as a link; typing in a search field waits 300 milliseconds before it asks the server.
Navigation links prefetch their page on hover. After any change, the prefetch cache is emptied so that no stale page is shown.
Live data by polling
The screens stay current without WebSockets. Inertia’s usePoll hook asks the server for the page’s props again at a fixed rhythm, and React updates what changed. Where a full reload would be wasteful, the request names the props it wants: the sidebar asks only for workCounts, the Floor only for pallets, crew, and docks.
Polling pauses where it would get in the way: on create and edit forms, in the settings, in the checkout, and in this Documentation. On the Handheld, the putaway and replenishment screens stop polling while you hold a task.
For a demo with a handful of visitors, this is simpler and more robust than a socket server, and the delay of a few seconds is invisible next to the pace of the crew.
FloorEvery 5 secondspallets, crew, docksHandheldEvery 10 secondslists of waiting workWMS and ERPEvery 15 secondsall props of the pageSidebarEvery 30 secondsworkCounts0 s10 s20 s30 s
FloorEvery 5 secondspallets, crew, docksHandheldEvery 10 secondslists of waiting workWMS and ERPEvery 15 secondsall props of the pageSidebarEvery 30 secondsworkCounts0 s10 s20 s30 s
Who asks for fresh data, and how often.
The Handheld
The Handheld is the same application in a narrow layout: a sticky header, one column, large touch targets. It needs no app and no camera. A hardware scanner behaves like a keyboard that types the code and presses Enter, so the scan field is an ordinary text input that keeps the focus. Without a scanner, you type the code.
The field compares what was scanned with what the task expects and shows a mismatch immediately; the server checks again when the step is confirmed.
Shipping labels and trolley labels are PDFs in A6, rendered on the server with DomPDF from Blade templates. The barcodes are Code 128, drawn as SVG and embedded in the label. Closing a shipping unit on the Handheld downloads its label straight away.
The Floor in three.js
The Floor is a React Three Fiber scene: three.js objects written as React components. It receives the same kind of props as any other page. FloorController sends the bays, gates, packing stations, pallets, crew, and docks; the scene computes every position from them. Nothing about the hall is drawn by hand, so a new aisle in the master data appears on the Floor by itself.
Thousands of similar objects would be far too many separate meshes. Rack parts, pallet boards, cartons, and lamps are instanced: one geometry, drawn many times in a single call, with a colour per instance. Labels with their barcodes are painted into one shared texture.
The crew figures come from Blender as GLB files with named animation clips such as Walk, Push, Pick, Pack, Drive, and Sit. The scene blends between clips and scales the walking clip to the ground speed, so feet do not slide. Vehicles have no clips; their parts are moved by code. Where a figure walks is derived from the work it holds in the WMS, with routes that respect aisles, right of way, and each other.
The walk view adds a first-person camera with collision. Each step is tested against racks, walls, and the footprints of the crew, separately per axis, so you slide along a rack instead of getting stuck on it.
The view from above renders on demand: when nothing moves, no frame is drawn. The scene is rebuilt only when the polled pallets actually differ from the previous answer.
In that view, shading is baked instead of computed per frame. Contact shadows under pallets, darker edges, and the shade inside the racks are part of the instance colours and of flat shadow meshes, so the light theme needs no post-processing at all. The walk view and the hero on the home page use the full chain: ambient occlusion at half resolution, bloom in the dark theme, filmic tone mapping, and SMAA.
The resolution adapts. The pixel ratio is capped at 2 (1.5 on touch devices) and stepped down when the frame rate falls below 40 frames a second, then up again above 50.
All fifteen models together weigh 2.3 MB, thanks to meshopt compression and quantised meshes. The 3D code is split from the main bundle and loaded only in the browser, when a page with a scene opens.
Build and tooling
One tool, Vite+ (vp), covers the dev server, the production build, linting, and formatting. vp check runs Oxlint with type-aware rules and treats warnings as errors, and checks the formatting with Oxfmt, which also sorts Tailwind classes. tsc --noEmit checks the types in strict mode.
The build plugins do the rest: the Laravel plugin with the fonts, the Inertia plugin that resolves pages and, in development, renders first visits on the server, the React plugin with the React Compiler, Tailwind, and Wayfinder.
A production build has two halves. vp build writes the bundle for the browser to public/build; vp build --ssr writes the one for the SSR server to bootstrap/ssr. npm run build:ssr runs both.