Skip to main content

Command Palette

Search for a command to run...

How to Design a Scalable Frontend Architecture (From Scratch)

Published
6 min read
K
Full Stack Developer with 3+ years of experience building scalable SaaS web applications using the MERN stack (MongoDB, Express.js, React.js, Node.js). Skilled in developing responsive frontends with React/Next.js and TypeScript, designing RESTful APIs, and optimizing database performance. Experienced in CI/CD pipelines, Docker, and agile practices.

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. 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:

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:

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.

// 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:

// 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:

// 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.

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:

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.

6 views