Frontend architecture
The cost of a client-only SPA
An empty mount point is not a rendering detail. What a Vite single-page app gives up in SEO, first paint and failure modes, and what the alternatives cost.
The Local Chef Bazaar deployment serves a complete HTML document whose entire body is <div id="root"></div>. That is a normal, correct Vite output and the application works perfectly once JavaScript runs. But it is worth being precise about what that means, because "it works in the browser" and "it is a working web page" are different claims.
What actually arrives
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Local_Chef_Bazaar</title>
</head>
<body>
<div id="root"></div>
</body>
</html>No headings, no navigation, no product names, no prices, no meta description, no Open Graph tags. Everything the user sees is constructed after the bundle downloads and executes.
The four consequences
Nothing to index
A crawler that does not execute JavaScript sees an empty page. The catalogue, the cook profiles and the reviews are all invisible to it.
No preview on a link
Sharing a link to a cook's page in a chat app renders as a bare title, because there are no Open Graph tags to describe it.
A blank first paint
On a mid-range Android device the useful content arrives after the bundle has downloaded, parsed and executed. Time to first byte is fine; time to first content is not.
A hard failure mode
If the bundle fails to load, there is no content at all and no server-rendered fallback to show.
The options, honestly costed
| Approach | Gains | Costs |
|---|---|---|
| Client-only SPA | Simplest mental model, cheap navigation, one build step | No indexable content, blank first paint, hard JS dependency |
| Static generation | Real HTML per route, fast, cheap hosting | Content is frozen at build time, so catalogue updates need a rebuild |
| Incremental static generation | HTML per route, rebuilt on demand | Needs a platform that supports revalidation; stale windows to reason about |
| Server rendering per request | Always-fresh HTML, per-user content possible | Every request pays render cost; more moving parts in production |
For a catalogue that changes when a cook adds a dish, incremental static generation is usually the right answer: the listing pages are regenerated on a schedule, and the checkout path is client-rendered anyway because it depends on the signed-in user.
If you stay client-only
The choice is reversible, but the cheap mitigations are worth doing regardless of whether rendering ever moves server-side.
- Write a real
<title>per route instead of leaving the default. In a SPA that means updatingdocument.titleon navigation. - Add a meta description and Open Graph tags so shared links render.
- Set
langon the<html>element to the language the interface actually uses. - Ship the title and description in the initial HTML even if the body is empty; a crawler reads the head.
- Keep the bundle small. Most of the perceived-performance gap here is bundle weight, not architecture.
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
const TITLES: Record<string, string> = {
"/": "Local Chef Bazaar — home cooks near you",
"/browse": "Browse menus",
"/orders": "Your orders",
};
export function RouteMeta() {
const { pathname } = useLocation();
useEffect(() => {
const title = TITLES[pathname] ?? "Local Chef Bazaar";
document.title = title;
const description = document.querySelector<HTMLMetaElement>(
'meta[name="description"]'
);
description?.setAttribute(
"content",
"Order from independent home cooks near you."
);
}, [pathname]);
return null;
}