A Small End-to-End App, Part 2 — The Frontend (React + Vite)


In Part 1 we built a typed API over a Postgres database. Now it needs a face. This part adds a React frontend that lists products, searches them, and (in Part 3) talks to the chatbot.

This is Part 2 of 3:

The frontend is a single-page app built with React and Vite. Its only real job is to call the API and render the results, so I kept it deliberately thin — no state-management library, no data-fetching framework, just fetch and component state.

One small wrapper around fetch

Rather than scatter fetch calls and headers across every component, everything goes through one file. It does three jobs: pick the API’s base URL, attach the login token when needed, and turn backend errors into something the UI can show.

const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api";

async function request(path, { method = "GET", body, auth = false } = {}) {
  const headers = {};
  if (body !== undefined) headers["Content-Type"] = "application/json";
  if (auth) {
    const token = getToken();
    if (token) headers["Authorization"] = `Bearer ${token}`;
  }

  const res = await fetch(`${BASE_URL}${path}`, {
    method,
    headers,
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });

  if (!res.ok) {
    // FastAPI returns errors as { detail: ... } — surface that message.
    let detail = `Request failed (${res.status})`;
    try {
      const data = await res.json();
      if (data.detail) detail = data.detail;
    } catch { /* no JSON body */ }
    throw new Error(detail);
  }
  return res.status === 204 ? null : res.json();
}

Two choices worth calling out. The base URL comes from an environment variable (VITE_API_URL) with a localhost fallback, so the same build works in dev and in the cluster without code changes. And because FastAPI reports validation and not-found errors as { "detail": "..." }, the wrapper pulls that message out — so when a request fails, the user sees “Product not found” instead of a bare 404.

On top of that, the actual API surface is just a list of one-liners:

export const listProducts  = (category) =>
  request(`/products${category ? `?category=${encodeURIComponent(category)}` : ""}`);
export const getProduct    = (id) => request(`/products/${id}`);
export const searchProducts = (q) => request(`/products/search?q=${encodeURIComponent(q)}`);
export const chat          = (question) => request("/chat", { method: "POST", body: { question } });

Each backend endpoint maps to one named function. Components never touch URLs.

A page that loads data

With the wrapper in place, a page is mostly “fetch on load, render the result.” Here’s the product listing, trimmed to its core:

export default function ProductList() {
  const [category, setCategory] = useState("");
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    listProducts(category)
      .then(setProducts)
      .catch((e) => setError(e.message))
      .finally(() => setLoading(false));
  }, [category]);

  return loading
    ? <p>Loading…</p>
    : <div className="grid">{products.map((p) => <ProductCard key={p.id} product={p} />)}</div>;
}

The useEffect re-runs whenever category changes — click a filter chip, the dependency changes, the data reloads. That’s the entire pattern, and almost every page in the app is a variation on it: some state, an effect that fetches, a render that maps the result.

Small touches: prices and images

Prices arrive as integer cents from the API (remember Part 1), so the frontend formats them at the edge:

export const formatPrice = (cents) => `$${(cents / 100).toFixed(2)}`;

The cents-to-dollars division happens in exactly one place — the moment before display — which is exactly where you want it.

The one thing that will bite you: CORS

The first time you run the frontend and backend separately, the browser will refuse to make the request. In dev, the React app runs on localhost:5173 and the API on localhost:8000different origins — and browsers block cross-origin calls unless the server explicitly allows them. The fix lives on the backend:

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],   # the dev frontend
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

What surprised me is that CORS is enforced by the browser, not the server — curl ignores it entirely, so the API looks fine from the terminal while the app is broken. That mismatch is the usual source of “but it works in curl!” confusion.

In production this disappears: an ingress serves the static frontend at / and the API at /api on the same origin, so there’s no cross-origin call to block. The VITE_API_URL variable from earlier is what lets the same code handle both setups.

Running it

npm run dev    # Vite dev server on http://localhost:5173

Open it, and you’ve got a working store — browse, filter, search — all driven by the API from Part 1. What’s missing is the interesting part: a chatbot that can actually answer questions about the products. That’s Part 3, where things get genuinely fun.