# How to Design a Scalable Frontend Architecture (From Scratch)

![](https://cdn.hashnode.com/uploads/covers/69c222b930a9b81e3afd3695/ec2affca-667e-470c-ad0c-fa66647937ae.png align="center")

**Day 1 — Designing Scalable Frontend Architecture**

When I first started building frontend apps, everything was fine. Components worked, state was managed, API calls happened. Then the app grew. And one day I opened the codebase and thought — what the hell is going on here.

Components were 400 lines long. State was scattered everywhere. Three different places were calling the same API. Debugging meant spelunking through files with no idea where something lived.

That's when it clicked for me — **frontend architecture isn't just about writing clean code, it's about designing a system that doesn't collapse under its own weight.**

This is blog #1 of my series documenting what I'm learning from [GreatFrontend](https://www.greatfrontend.com/). Let's get into it.

**What even is frontend architecture?**

It's the set of decisions you make before writing a single component — how you'll organize code, manage state, handle data fetching, and structure folders — so that when the app grows, it stays maintainable.

The goal isn't perfection. It's making sure that six months later, a new dev (or future-you) can open the codebase and not immediately want to quit.

Good frontend architecture is:

**Scalable** — adding features doesn't require rewriting existing ones

**Maintainable** — bugs are easy to find and fix

**Collaborative** — multiple people can work without constantly stepping on each other

**Step 1: Think in layers**

The single most useful mental model I've internalized is: separate your concerns into layers. Every frontend app can be broken into three:

The diagram above shows how these layers talk to each other — and more importantly, what they don't do. The UI layer never calls an API directly. That's the rule.

The moment you call `fetch('/api/users')` inside a component, you've created a hidden dependency. Change the API? Now you're hunting through 12 components. Put it in the data layer, and you change it in one place.

**Step 2: Folder structure that doesn't fight you**

Most beginner projects look like this:

```plaintext
components/
utils/
pages/
```

It works for a week. Then components/ has 60 files and you spend more time finding things than building them.

The better approach is **feature-based architecture**:

```plaintext
features/
  auth/
    components/    ← auth-specific UI
    hooks/         ← useLogin, useLogout
    services/      ← authService.ts
  cart/
    components/
    hooks/
    services/
shared/
  ui/              ← Button, Modal, Input (reusable anywhere)
  utils/
  constants/
```

Everything related to auth lives in `features/auth/`. When a new dev joins and needs to touch auth logic, they know exactly where to look. When you delete a feature, you delete one folder.

The `shared/` folder is for stuff that genuinely has no business logic — pure UI components, utility functions, constants. If it's in `shared/`, it should have zero knowledge of your app's domain.

**Step 3: State management — don't overthink it**

I see a lot of people reach for Redux on day one. You don't need it. Use the simplest thing that works, and upgrade when you actually feel the pain.

Here's the decision tree I use:

*   `useState` → local component UI state (is this dropdown open? what's in this input field?)
    
*   **Context / Zustand** → state shared across multiple components (user auth, theme, cart)
    
*   **React Query / TanStack Query** → anything that comes from a server
    

Concrete example — an e-commerce app:

*   Cart items → Zustand (global, client-owned state)
    
*   Product list → React Query (server state, needs caching + refetching)
    
*   "Is the filter dropdown open?" → `useState` (local, nobody else cares)
    

The common mistake is using `useState` for everything, then prop-drilling 5 levels deep. Or worse, putting *everything* in Redux, including whether a tooltip is visible. Global state is expensive to maintain — only put things there that genuinely need to be global.

**Step 4: The API layer — build a service layer**

Never, ever call `fetch` directly in a component. Build a service layer instead.

```ts
// services/userService.ts
export const getUser = async (id: string) => {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('Failed to fetch user');
  return res.json();
};
```

Then wrap it in a hook using React Query:

```ts
// hooks/useUser.ts
import { useQuery } from '@tanstack/react-query';
import { getUser } from '../services/userService';

export const useUser = (id: string) => {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => getUser(id),
  });
};
```

And in your component:

```tsx
// components/UserProfile.tsx
const { data: user, isLoading } = useUser('123');
```

The component has no idea how the data gets fetched. It just asks for it. This is the separation of concerns in action — and it makes testing dramatically easier.

**Step 5: Performance basics**

A few rules that apply to almost every React app:

**Lazy load your routes.** Don't ship the entire app in one bundle.

```tsx
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
```

**Don't re-render what doesn't need to be re-rendered.** `React.memo` on expensive components that receive the same props frequently:

```tsx
const ExpensiveChart = React.memo(({ data }) => {
  // only re-renders if `data` changes
});
```

**Memoize expensive calculations** with `useMemo`. Memoize stable function references with `useCallback` (especially when passing to child components or as `useEffect` deps).

These aren't premature optimizations if you're building something that's going to grow — they're just good habits.

**Step 6: Testing strategy (the short version)**

Three levels:

Three levels:

*   **Unit tests** → individual functions, hooks, pure logic
    
*   **Integration tests** → user flows across multiple components (login form → redirects to dashboard)
    
*   **E2E tests** → full user journeys in a browser (Cypress/Playwright)
    

You don't need 100% coverage. But having *some* integration tests for your critical paths (checkout, auth, core feature) will save you from shipping broken stuff.

### Common mistakes I see (and have made)

❌ **API calls inside components** — the #1 offender. Ties your UI directly to your data source.

❌ **Everything in global state** — modal open state does not need to be in Redux. I promise.

❌ **Flat folder structure** — works until it doesn't. Plan for scale from the start.

❌ **Mixing UI and logic in the same component** — a 300-line component that fetches data, transforms it, handles errors, and renders a table is doing too much.

### The mental model I keep coming back to

Frontend architecture is just one question, asked repeatedly: **"Which layer does this belong to?"**

If it renders → UI layer.

If it's a shared state → state layer.

If it talks to an API → data layer.

Everything else is details.

### What's next

In the next post, I'm diving into how React's rendering actually works — what triggers a re-render, what doesn't, and how to read the React Profiler to find where your app is slow. That one's going to be very hands-on because I've been debugging a gnarly re-render issue at work for the past few weeks.

If you're learning frontend system design too, drop a comment — I would love to know what topics you're finding most confusing.
