Moving Business Logic Out of the Browser in a React and Go SaaS

4 Dec 2025 · React, TypeScript, Go, API Design, Security

A browser can make a convincing decision about an order. It can also have that decision replayed, edited or bypassed. The neat UI does not make it an authority.

Problem

The original shape of the system let React make decisions about:

  • Which menu or table data a user could see.
  • Whether a promotion was applicable.
  • Which pub-scoped action should be allowed.
  • How payment-related steps should proceed.

That is convenient at first, but it creates a fragile trust boundary. Anything in the browser can be inspected, modified, replayed, or bypassed.

Constraints and risks

The migration had to avoid breaking users who were already in the middle of a flow. It also had to keep the UI responsive, because moving logic to the backend should not make the product feel heavier.

The main risk is overcorrecting. Not every piece of logic should leave the frontend. Rendering, local validation, and optimistic UI still matter. The key is deciding what the browser may suggest versus what the server must decide.

Design / Investigation

I split responsibilities into two buckets:

Stays in React Moves to Go
Input formatting Payment authorization
View state Tenant-scoped access checks
Local field validation Table, menu, and order eligibility
Optimistic UI Promotion eligibility and server truth
Navigation and display Final order submission

That split keeps the browser useful without making it authoritative.

Example API shape:

type CreateOrderRequest = {
  venueId: string;
  tableCode: string;
  items: Array<{ itemId: string; quantity: number }>;
};

The frontend can assemble that payload, but the backend decides whether the venue, table, and order contents are valid for the authenticated user.

Implementation approach

The backend takes over the checks that matter:

func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) {
    // 1. Authenticate the caller.
    // 2. Confirm venue scope.
    // 3. Validate the order contents against server-side rules.
    // 4. Write the order atomically.
    // 5. Return the server-authoritative result.
}

I like this pattern because it makes the boundary obvious:

  • React renders state and gathers intent.
  • Go validates authority and writes durable state.
  • Supabase/Postgres enforces the tenant boundary underneath both.

The migration strategy was to move one decision at a time. That kept the rollout safe because each step could be verified independently:

  1. Preserve the existing UI.
  2. Add server-side validation.
  3. Switch the client to trust the server response.
  4. Remove the browser-side assumption once the server path is stable.

What I would do differently

I would draw the trust boundary earlier in the project. It is much easier to keep a browser thin from day one than to unwind business logic after it has spread across multiple components.

I would also write more contract tests around the API responses so the frontend and backend can evolve without accidental drift.

The line I now draw

The browser should gather intent, render local validation and make the path feel responsive. The server should decide tenant scope, order eligibility, payment state and the durable write. That line is now visible in the payments service as well as in the database’s tenant policies.

I no longer call a client-side rule “business logic” without asking what happens when a client ignores it. If the answer affects another person’s money or data, it belongs behind a boundary the client cannot redraw.