Skip to content

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.

3 min readUpdated 2 Oct 20264 sections

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

Response body, in full
<!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

ApproachGainsCosts
Client-only SPASimplest mental model, cheap navigation, one build stepNo indexable content, blank first paint, hard JS dependency
Static generationReal HTML per route, fast, cheap hostingContent is frozen at build time, so catalogue updates need a rebuild
Incremental static generationHTML per route, rebuilt on demandNeeds a platform that supports revalidation; stale windows to reason about
Server rendering per requestAlways-fresh HTML, per-user content possibleEvery 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.

  1. Write a real <title> per route instead of leaving the default. In a SPA that means updating document.title on navigation.
  2. Add a meta description and Open Graph tags so shared links render.
  3. Set lang on the <html> element to the language the interface actually uses.
  4. Ship the title and description in the initial HTML even if the body is empty; a crawler reads the head.
  5. Keep the bundle small. Most of the perceived-performance gap here is bundle weight, not architecture.
Routes with document metadata
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;
}