Frontend Dev – Talking to the Backend

August 13, 20267 min readUpdated 8/20/2026

Almost every screen you build is downstream of an API. A beginner writes the request, gets the data, renders it, and calls the feature done. The feature is about a third done, because a single request has four possible outcomes and only one of them is the one they handled.

OutcomeWhat the user should see
It workedThe data. Or an empty state, if there is none.
It is still goingA loading state that does not shift the layout when it resolves.
The server said noWhat went wrong, in their language, and what to do about it.
The request never arrivedOffline, DNS, timeout, CORS. Not the same as the server saying no.

This post is about building that properly once instead of badly forty times.

Build one API layer

The single highest-value structural decision in a frontend codebase is that components do not call fetch. One module owns it. The demo app states the reason at the top of its file:

The single place the frontend talks to the backend. Everything goes through request() so there is exactly one implementation of: where the API lives, how the auth token is attached, and how an error response becomes a thrown Error. Calling fetch directly from components would scatter all three.

Those three responsibilities are exactly the ones you do not want duplicated. Change the base URL, add a header, or decide that 401 should log the user out, and with a layer you edit one file.

Where the API lives

const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8085';

One line, environment-driven, with a local default so a fresh clone runs without configuration. Hard-coding this in components is the thing that makes an app impossible to deploy anywhere but the machine it was written on.

One request function

async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
  const { method = 'GET', body, auth = false, signal } = options;

  const headers: Record<string, string> = {};
  if (body !== undefined) headers['Content-Type'] = 'application/json';

  if (auth) {
    const token = tokenStore.get();
    if (token) headers.Authorization = `Bearer ${token}`;
  }

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

Two details worth stealing. auth is opt-in per call, because public endpoints — the menu, guest checkout — do not need a token and sending one anyway leaks it further than necessary. And signal is threaded through, which is what makes cancellation possible below.

Then a thin, typed surface over it:

export const api = {
  get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
    request<T>(path, { ...opts, method: 'GET' }),
  post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
    request<T>(path, { ...opts, method: 'POST', body }),

Call sites read as api.get<User>('/api/auth/me', { auth: true }) — the type of what comes back is stated once, at the place that knows it.

`fetch` does not throw on 404

This is the single most common mistake, and it is a design decision in the browser rather than a bug. fetch rejects only when the request could not be made — offline, DNS failure, CORS. A perfectly delivered 500 is a fulfilled promise with ok: false.

Which means this, which looks completely reasonable, renders your error page as if it were data:

const data = await fetch(url).then((r) => r.json());

You have to check explicitly:

  if (!response.ok) {
    const errorBody = parsed as ApiErrorBody | null;
    throw new ApiError(
      response.status,
      errorBody?.message ?? `Request failed with ${response.status}`,
      errorBody,
    );
  }

Two smaller traps in the same function, both of which produce confusing crashes:

  // 204 No Content has no body to parse — reading it as JSON would throw.
  if (response.status === 204) {
    return undefined as T;
  }

  const text = await response.text();
  const parsed = text ? (JSON.parse(text) as unknown) : null;

Read the body as text first and parse only if there is any. Calling .json() on an empty response throws a parse error that has nothing to do with the actual problem, and sends you hunting in the wrong place.

Make errors renderable

An error whose entire payload is a string forces every caller to show a generic banner. A good API returns structure — which field, and what was wrong with it — and your error type should carry it through:

export class ApiError extends Error {
  readonly status: number;
  readonly body: ApiErrorBody | null;

  /** Field errors as a lookup, for rendering next to inputs. */
  fieldErrors(): Record<string, string> {
    const result: Record<string, string> = {};
    for (const sub of this.body?.errors ?? []) {
      if (sub.field) result[sub.field] = sub.message;
    }
    return result;
  }
}

Now a form can put "must be at least 8 characters" under the password box instead of shouting "Validation failed" at the top of the page.

Having status also lets callers distinguish cases that need different UI. Roughly:

StatusMeansDo
400 / 422Your input was rejectedShow it against the fields.
401Not signed in, or the token expiredClear it and send them to login.
403Signed in, not allowedSay so. Do not offer login again.
404Not thereOften a real screen, not an error banner.
409Conflict — someone else changed itRefetch and let them retry.
429Rate limitedBack off, and say when to try again.
5xxTheir bugGeneric message plus a retry. Report it.

The backend track's post on API design covers why a server chooses each of these — worth reading, because arguing for a better response shape is part of this job.

Loading, error and empty, in the component

The layer gives you data or throws. The component turns that into the four states. The shape that guarantees the spinner always stops:

      setLoading(true);
      setError(null);
      try {
        const response = await api.post<AuthenticationResponse>(path, body);
        tokenStore.set(response.token);
        setUser(response.user);
      } catch (err) {
        const message =
          err instanceof ApiError ? err.message : 'Could not reach the server. Is the API running?';
        setError(message);
        // Rethrow so the calling form can react to failure (e.g. keep the user on the page).
        throw err;
      } finally {
        setLoading(false);
      }

Three things are deliberate. finally means no early return or rethrow can leave the button spinning. The instanceof check separates "the server said no" from "the server was not there" — genuinely different messages, and the second one is usually your own API not running. And it rethrows, so the caller can also react, rather than swallowing the failure and silently navigating on.

Cancel what you no longer need

A request whose answer is no longer wanted is worse than wasted — it can land after a newer one and overwrite fresher data with staler data. That is the race behind "the search results flicker back to my previous query".

  useEffect(() => {
    const controller = new AbortController();

...and the cleanup that makes it work:

    void restoreSession();
    return () => controller.abort();
  }, []);

The demo app spells out why this is not optional in development either:

In React 18+ StrictMode every effect runs twice in development; without cleanup you get two in-flight requests and the slower one can win, overwriting fresher state. Aborting on unmount also prevents setting state on a component that is no longer mounted.

Do not serialise independent requests

Three awaits in a row take as long as all three added together. If they do not depend on each other, run them at once with Promise.all and wait for the slowest instead of the sum. The demo app fetches products, toppings and crusts that way — three round trips in the time of one.

CORS, in one paragraph

You will hit this on day one of any local setup. The browser refuses to let JavaScript on localhost:5173 read a response from localhost:8085 unless that server explicitly allows the origin. It is enforced in the browser, on the response — which is why the request often shows up fine in the server's logs while your code sees nothing. Two consequences: it cannot be fixed from the frontend, and a browser extension that disables it is hiding a problem you will meet again in production. The fix is a header on the API. For anything non-trivial the browser also sends a preflight OPTIONS first, so the server has to answer that too.

What to reach for once this is boring

Everything above is per-call. The moment two components want the same data you also want deduplication, caching, background revalidation and shared loading state — and that is a library's job, not yours. TanStack Query is the common answer for React. Build the layer by hand once so you know what the library is doing for you, then let it.

The one thing to take from this post

Route every request through one module, and remember that fetch only rejects when the request never happened — a 500 is a successful promise. Those two facts, plus handling all four outcomes instead of one, is most of the difference between a demo and something you would put in front of customers.

Next: Routing, Forms and Validation.