Article

Scalable Frontend Architecture

A comprehensive guide on organizing React and Next.js applications for scale. Explores feature-based vs. layer-based structures, domain-driven design, API client organization, Server/Client components boundaries, and modular dependency management.

Vladyslav Tsyvinda
Vladyslav Tsyvinda
Scalable Frontend Architecture

Scalable Frontend Architecture: How to Keep Your Project from Turning into a Dump

Most frontend projects start the same way. There is create-next-app, two or three pages, and a components folder where you put everything that isn't a page. For the first few weeks, this works flawlessly. Problems arrive later — when the project survives its third refactoring, fourth developer, and the moment when nobody exactly remembers why the Card component accepts seventeen props and why it is imported in twelve places, each expecting a slightly different behavior.

This article is not about the right way to write React. It is about how to organize code so that after a year of work, the project remains a project, and not an archaeological dig. All examples here are from a real application on Next.js 16 (App Router) with React 19 and TypeScript. It is a personal site with public pages, a blog, an authorized zone, and a few 3D scenes on three.js. It is small in size, but diverse enough in load types to show where architectural decisions start paying off, and where they — on the contrary — get in the way.

We will break down seven topics that most strongly determine whether an architecture will be scalable: why the components folder always turns into a dump, what is the difference between feature-based and layer-based approaches, how to arrange a shared UI layer, what domain-driven thinking means on the frontend, how to organize API clients, where the boundary between server and client components lies, and finally — how to manage dependencies between modules so that coupling does not kill flexibility.

1. Why the components folder turns into a dump

Let's start with the most painful one, because it happens to almost everyone. The components folder is never intended to be a dump. It is intended to be a neat place for reusable pieces of interface. But it has a built-in degradation mechanism, and it works silently.

Imagine a typical sequence of events. You make a button — you put it in components/Button. Logical. Then you make a login form — where do you put it? Also in components, because "it's a component". Then appear LoginForm, RegisterForm, ProfileForm, PostCard, PostsList, CylinderScene, LocationMap. And now you already have forty folders in one directory, where a thing reused everywhere (a button) lies next to a thing used exactly in one place and never again (a login form on the login page).

The root of the problem is not in discipline. The problem is that a flat components folder mixes two fundamentally different things based on a single external characteristic. Both are "components", meaning functions that return JSX. But one of them is part of the shared interface dictionary, and the other is an implementation detail of a specific page. They have a different lifespan, different audience, and different reasons to change. A button changes when the design system changes. A login form changes when authorization logic changes. These are absolutely unrelated events, but the flat folder keeps them at an equal distance from each other.

Next, the second force turns on — the gravity of a known place. When a project already has components, every new component has a zero threshold for getting in there. No need to think, no need to justify, no need to create a new structure. You just drop the file into the existing folder. And anything that has a zero threshold and no backpressure accumulates boundlessly. Because of this, the folder grows not because someone made such a decision, but because no one ever made a different decision.

The third force is the loss of scope signal. When a component lies in the global components, the very fact of its location says: "this can be used anywhere". And sooner or later someone does exactly that — imports LoginForm into some unexpected place, because technically nothing prevents it. Now the login form has two consumers with different expectations, and any change to it is a risk of breaking something in the second place, which the author of the change does not even suspect. This is how the worst kind of coupling is born: accidental, unintentional, noticed only in production.

In the application we are analyzing, this trap was avoided by a conscious decision: a component lives as close to the place of its use as possible, and gets promoted "up" into the shared layer only when it genuinely starts being reused. Look at the structure of the registration page:

src/app/registration/
  page.tsx
  layout.tsx
  page.module.css
  components/
    RegisterForm/
      RegisterForm.tsx
      RegisterForm.module.css
      RegisterForm.test.tsx
      RegisterForm.stories.tsx
      submitHandler.ts
      validation/
        schema.ts
        index.ts
      index.ts
    ConfirmEmail/
      ConfirmEmail.tsx
  ...

RegisterForm never makes it to the global folder. It is physically tied to the /registration route, along with its styles, tests, stories, submit logic, and validation schema. If registration is removed tomorrow, one folder is deleted — and no orphaned components remain in the global dump, nor any dead imports. Locality of placement is not an aesthetic; it is a self-cleaning mechanism.

The key idea is this: a dump forms not because people are lazy, but because the structure puts up no resistance. As soon as you make it so that the "right place" for a component is determined by its actual scope of use, rather than the convenience of a folder, the problem disappears by itself. The components folder becomes a dump exactly when it lacks an answer to the question "who is using this?".

2. Feature-based vs Layer-based: two axes of one structure

Now about the main crossroad around which spears are broken in every second discussion about architecture. There are two ways to slice an application into pieces, and they answer different questions.

The Layer-based approach slices by technical role. All components together, all hooks together, all services together, all types together. The structure looks like components/, hooks/, services/, utils/, types/. This is intuitive because it repeats how we classify code in our heads: "this is my component, and this is a utility". On a small scale, it reads wonderfully.

The Feature-based approach slices by purpose. Everything related to the blog — components, hooks, types, styles — lies together, in the blog folder. Everything related to registration — together, in the registration folder. The structure repeats product features, not technical roles.

The difference becomes palpable not when you write code, but when you change it. Because changes almost never come in the format of "change all hooks". They come in the format of "add infinite scroll to the blog page" or "make an email confirmation panel show after registration". That is, changes come by features, not by layers. And if your structure is sliced by layers, then one product change spreads across five different folders: a bit in components, a bit in hooks, a bit in types. You keep five open files from different ends of the tree to make one logically cohesive thing. This is the cost of layer-based at scale — it is paid not with disk memory, but with the attention of the developer, who is forced to assemble the feature in their head every time.

Feature-based changes the equation: the change is localized. Look at how the blog is structured in our application:

src/app/blog/
  page.tsx               // server component, ISR
  layout.tsx             // route metadata
  error.tsx              // error boundary
  const/
    seo.ts
  hooks/
    useInfinitePosts.ts  // infinite scroll logic
    useInfinitePosts.test.tsx
  components/
    BlogView/            // client wrapper
    PostsList/
    PostCard/
    BlogScene/           // 3D background
    BlogBackground/
  [slug]/
    page.tsx             // single post page
    const/seo.ts
    components/

Everything that means "blog" is under one roof. The infinite scroll hook lies not in the global hooks, but in blog/hooks, because it has zero meaning outside the blog. SEO constants for the blog are in blog/const. When you need to change the behavior of the post list, you open the blog folder and work within a single subdirectory. The context doesn't scatter.

But it would be unfair to present this as "feature-based wins". The more accurate picture is not "either-or", but two axes. Feature-based perfectly answers the question "where is the code for a specific feature". It poorly answers the question "where is the code shared across all features". Because a button, a site header, a footer, a wrapper for API requests — these are not a feature. If you stuff them into some random feature folder, other features will start pulling cross-imports, and you will get the exact same accidental coupling, only now hidden deeper.

Therefore, in a mature architecture, these two axes coexist. There is a slice by features — every route in src/app/ with its local components, const, hooks. And there is one shared horizontal layer — src/shared/ — where everything cross-cutting is extracted. In our application, it looks like this:

src/
  app/      // slice by features (App Router routes)
  shared/   // horizontal shared layer
  api/      // backend clients (also a horizontal layer)

Inside app, feature-based logic reigns: each page is a self-sufficient module. Inside shared, layer-based logic reigns: there are components, contexts, providers, lib, const. And this is normal because shared is by definition small and stable. Layer-based scales poorly specifically when there are many layers and they grow; on a compact, stable shared layer, its flaws don't have time to manifest, while its benefits — grouping similar things together — work to their fullest.

The rule that follows from this is simply stated but hard to adhere to: slice a feature by features, slice shared things by layers, and never allow one feature to import from another feature. The moment blog starts importing something from profile, it's a signal that the shared thing actually belongs in shared, and it needs to be promoted.

3. Shared UI layer: a dictionary, not a dump

Let's break down that same shared in more detail, because it's often the exact thing that either saves the architecture or becomes a second dump — just one level higher.

The shared UI layer has one function: to be the interface dictionary. It is a set of elements that all features rely on and to which no single feature has exclusive rights. The site header, footer, logo, authorization button, account status banner — this is not part of any single page, this is part of the visual language of the entire product. Look at the composition of the shared components:

src/shared/components/
  SiteHeader/
  SiteFooter/
  Logo/
  AuthButton/
  ApprovalBanner/
  index.ts        // re-export

Each of these elements passed the same test to get here: it is genuinely used in more than one place, and it does not carry the logic of a specific feature. SiteHeader renders on all public routes. AuthButton shows the authorization state regardless of what page you are on. This is truly shared.

Now the most important thing — how the shared layer differs from a dump, even though structurally they might look identical (a flat list of folders with components). The difference is in the direction of dependencies. The shared layer knows nothing about features. SiteHeader does not import anything from app/blog or app/profile. Dependencies flow strictly in one direction: features depend on shared, shared does not depend on features. The moment this law is violated — the moment some "shared" component pulls an import from a specific page — it stops being shared. It became a detail of that page that is accidentally lying in the wrong place.

This unidirectional flow is not bureaucracy for the sake of bureaucracy. It provides a concrete property: the shared layer can be read and understood in isolation, without holding any feature in your head. And conversely — any feature can be deleted, and the shared layer will survive it because it didn't know about it. Bidirectional dependencies take away exactly this property: as soon as shared starts knowing about a feature, deleting the feature breaks the shared, and changing the shared might break the feature in unpredictable ways.

A separate detail worth noting is the index.ts re-export:

// src/shared/components/index.ts
export { SiteHeader } from './SiteHeader/SiteHeader';
export { SiteFooter } from './SiteFooter/SiteFooter';
export { AuthButton } from './AuthButton/AuthButton';
// ...

This is a small but important thing. The index sets the public interface of the layer. Features import from '@/shared/components', rather than digging directly into internal files. Thanks to this, the internal structure of the component can be reorganized as much as desired — renaming files, breaking them into subcomponents — and this won't touch a single consumer as long as the index exports the same name. The index turns a folder of loose files into a module with a clearly defined surface. Without it, every internal file becomes a potential entry point, and you no longer control what exactly the consumers rely on.

The shared layer includes not only components. Contexts (UserContext), providers (AntdProvider), library utilities (lib/cookies.ts, lib/schemas.ts, lib/site.ts), and constants (const/routes.ts, const/navigation.ts, const/social.tsx) are also extracted there. The logic is the same: these are cross-cutting things needed by many features and belonging to none individually. Route constants are a good example. Instead of scattering strings like "/blog" and "/registration" throughout the code, there is a single source of truth:

// src/shared/const/routes.ts
export const ROUTES = {
  HOME: "/",
  ABOUT: "/about",
  BLOG: "/blog",
  REGISTER: "/registration",
  PROFILE: "/profile",
  blogPost: (slug: string) => `/blog/${slug}`,
} as const;

When a route changes, it changes in one place. When it needs to be found — it is searched for in one place. When a new developer wants to understand the application map — they read one file. It's the same dictionary principle, only applied not to visual elements, but to navigation structure.

4. Domain-driven frontend: types as the shape of the domain

Here it's worth taking a step away from files and folders and talking about thinking. The domain-driven approach is not about directory structure, it is about code describing the business domain, not technical mechanisms. On the backend, this idea has been discussed for a long time; on the frontend, it is often ignored because it seems the frontend is "just rendering". But this is a deceptive simplification. The frontend has its own domain: the user, their authorization state, posts, profiles, forms, and their rules.

The most direct way the domain manifests in code is through types. Look at the user type:

// src/shared/contexts/UserContext.tsx
export type User = {
  id: string;
  email: string;
  firstName: string;
  lastName: string;
  profileImageUrl: string | null;
  emailVerified: boolean;
  approvedByAdmin: boolean;
  createdAt: string;
};

This type is not a random set of fields. It encodes domain rules. emailVerified and approvedByAdmin are two different stages of an account's lifecycle: first, the user confirms their email, then the administrator approves access. The fact that these are two separate boolean fields, and not a single status — is a statement about the domain: a verified email and approved access are independent, you can have one without the other. profileImageUrl is explicitly nullable because avatars are optional in this domain. The type tells a story about how an account works before you even open a single component.

Domain-driven thinking also manifests in how operations are described. Look at the comments near the authorization API functions — they capture domain rules, not technical HTTP details:

/**
 * Register a new account. On success the backend emails a verification link and
 * responds with 201 + a generic message — it does NOT establish a session, so
 * the caller must not treat this as a login. Returns 409 when the email is
 * already registered.
 */
export function register(payload: RegisterPayload): Promise<PasswordResetMessage>{
  return API.post<PasswordResetMessage>('/auth/register', payload);
}

A domain rule is fixed here: registration does not create a session. This is not obvious from the types — both operations return a promise. But it is critical for correctness, because a consumer who treats registration as a login will make a mistake. And this mistake is prevented in the submit code, which respects the rule:

// src/app/registration/components/RegisterForm/submitHandler.ts
await register({ /* ... */ });
// Registration only triggers a verification email; it does not set a
// session, so we switch to a confirmation panel rather than redirecting
// into the authenticated area.
setIsSubmitted(true);

Instead of redirecting to the authorized zone — a switch to the confirmation panel. UI logic stems directly from the domain rule. This is a domain-driven frontend in action: UI behavior is not invented out of convenience; it is a consequence of how the domain actually works.

Another place where the domain shapes the code is session state management. Look at how UserProvider decides whether to even hit the backend at all:

const [loading, setLoading] = useState(
  initialUser === null && hasCookie(AUTH_COOKIES.SESSION_FLAG)
);

useEffect(() => {
  if (initialUser === null && hasCookie(AUTH_COOKIES.SESSION_FLAG)) {
    void fetchUser();
  }
}, [fetchUser, initialUser]);

Domain knowledge is encoded here: requesting a profile only makes sense when there is a sign of a session. Access and refresh cookies are HttpOnly, JS does not see them, but there is a separate JS-visible flag has_session that the backend sets upon login. The frontend does not blindly try to yank /auth/me for every guest — it first checks the flag. This decision is born not out of technical convenience, but out of understanding how authorization is structured in this domain: a session has an observable client-side marker, and all client logic is built around it.

The point of this section is not to introduce some new folders. The point is that scalability is not only about file structure; it is about how accurately the code reflects the reality it serves. When types and functions speak the domain language, a new developer learns the business logic by reading the code. When they speak the language of mechanisms — they only learn the mechanisms, while the domain remains in someone's head and disappears when that person leaves.

5. API clients organization: transport separately, domain separately

Working with the backend is where the architecture either holds up or quietly falls apart due to a thousand tiny duplications. The most common mistake is calling fetch right inside components. Every such call drags along identical scaffolding: base URL, headers, error handling, body serialization, retry logic. Initially, it's a few lines, then those few lines multiply across thirty components with minor variations, and when you need to change one shared thing — like adding a header to all requests — you edit thirty places and forget one.

The solution is to separate transport and domain into two distinct layers. In our application, this is the src/api/ folder with the following structure:

src/api/
  index.ts      // transport layer: API.{get,post,put,...}, ApiError
  auth.ts       // domain authorization operations
  posts.ts      // domain blog operations
  profile.ts    // domain profile operations

The transport layer is index.ts. It knows everything about how to communicate with the backend, and nothing about what exactly is being transferred. All cross-cutting concerns are solved here once and for all:

const request = async <T>(path: string, options: RequestOptions = {}): Promise<T> => {
  const { body, params, headers, _isRetry, ...rest } = options;

  const finalHeaders = new Headers(headers);
  finalHeaders.set('X-Timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);

  let serializedBody: BodyInit | undefined;
  if (body !== undefined) {
    if (body instanceof FormData || body instanceof Blob || typeof body === 'string') {
      serializedBody = body as BodyInit;
    } else {
      serializedBody = JSON.stringify(body);
      if (!finalHeaders.has('Content-Type')) {
        finalHeaders.set('Content-Type', 'application/json');
      }
    }
  }

  const response = await fetch(buildUrl(path, params), {
    ...rest,
    headers: finalHeaders,
    credentials: 'include',
    body: serializedBody,
  });

  if (response.status === 401 && !_isRetry && hasCookie(AUTH_COOKIES.SESSION_FLAG)) {
    try {
      await refreshTokens();
      return request<T>(path, { ...options, _isRetry: true });
    } catch (e) {
      throw e;
    }
  }

  const contentType = response.headers.get('Content-Type') || '';
  const isJson = contentType.includes('application/json');
  const data = isJson ? await response.json().catch(() => null) : await response.text();

  if (!response.ok) {
    throw new ApiError(response.statusText || 'Request failed', response.status, data);
  }

  return data as T;
};

Look how many cross-cutting decisions are concentrated in one place here. The timezone is added to every request. Cookies always go with the request (credentials: 'include'). The body is serialized smartly — FormData and Blob pass as is, objects become JSON with the correct Content-Type. Errors are normalized into a single ApiError type carrying the status and response body. And the most interesting part — retry logic on 401: if there is a session flag, the transport itself tries to refresh tokens and retry the request exactly once (protection against infinite loops via the _isRetry flag). No component knows or should know about this. For a component, a request either returns data or throws an ApiError — and that's it.

Above the transport sits the domain layer. These are thin, typed functions, one file per area. They don't know how fetch works — they only know which endpoint corresponds to which domain operation and what shape the response takes:

// src/api/posts.ts
export type Post = {
  id: string;
  slug: string;
  title: string;
  // ...
};

export type PostsPage = {
  items: Post[];
  nextCursor: string | null;
};

export function getPosts(params: GetPostsParams = {}): Promise<PostsPage>{
  const { cursor, limit } = params;
  return API.get<PostsPage>('/posts', {
    params: { cursor: cursor ?? undefined, limit: limit ?? undefined },
  });
}

export function getPostBySlug(slug: string): Promise<Post>{
  return API.get<Post>(`/posts/${slug}`);
}

This separation yields several properties that manifest themselves specifically at scale. First, response types live next to the functions that fetch them — Post and getPosts are in one file, and when the backend changes the shape of a post, there is one obvious place to reflect that. Second, components become clean from transport mechanics: the registration form calls register(payload), not constructs a fetch with headers. Third, domain files read like a table of contents for the API — opening auth.ts, you see the full list of authorization operations with their comments on domain rules, without any transport noise.

It's separately worth noting how transport solves the execution environment problem. The exact same request works both on the server and in the browser because it determines the context at startup:

const isServer = typeof window === 'undefined';
const SERVER_API_ORIGIN = (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '');

const base = isServer ? SERVER_API_ORIGIN : API_URL;

In the browser, requests go to the relative /api/... and pass through the Next proxy (rewrite in next.config.ts). On the server, where a relative path doesn't exist, the exact same code directly addresses the full origin of the backend. The component calling getPosts knows nothing about this — and that is the core point. The detail of "where are we executing right now" is encapsulated in the transport, not leaking into every call. This allows moving the exact same domain function between server and client components without any changes — which directly brings us to the next topic.

6. Separation of Server and Client Components

App Router brought a distinction to the frontend that hadn't explicitly existed there before: code that executes on the server during rendering, and code that executes in the browser. By default, in App Router, a component is a server component. It becomes a client component only with the 'use client' directive at the top of the file. This is an inversion of habits — previously all React was client-side; now, client-sidedness must be explicitly requested. And depending on where you draw this line, performance, SEO, and application complexity all hang in the balance.

Server components execute on the server, do not enter the client bundle, and can directly access data. Client components enter the bundle, hydrate in the browser, and only they can hold state, effects, event handlers, and access browser APIs. The most common mistake is making everything a client component out of habit, losing all the benefits of server-side rendering. The opposite mistake is trying to make something a server component that fundamentally requires a browser, and running into crashes on window.

The correct pattern is to keep the server component as high and as thin as possible, and push client-sidedness down as low as possible, exactly to where it is genuinely needed. Look at the blog page:

// src/app/blog/page.tsx
import { getPosts } from "@/api/posts";
import { BlogView } from "./components/BlogView/BlogView";

// ISR: prerender the list as static HTML (so crawlers see real posts/links),
// refreshed periodically.
export const revalidate = 300;

export default async function BlogPage() {
  let initialData: InitialPostsData | undefined;
  try {
    const page = await getPosts({ limit: POSTS_PAGE_SIZE });
    initialData = { items: page.items, nextCursor: page.nextCursor };
  } catch {
    initialData = undefined;
  }

  return <BlogView initialData={initialData} />;
}

This is a server component — note that it is async and directly awaits data, without a single useEffect. It loads the first batch of posts during rendering on the server; via revalidate = 300 Next turns this into ISR — the page is pre-rendered as static HTML and refreshes once every five minutes. This means search crawlers get real posts and real links directly in the HTML, without executing JS. For SEO, this is fundamental.

And further down — what needs the browser is passed down into the client component BlogView, along with the already loaded initial data via the initialData prop. BlogView manages infinite scroll, reacts to scroll events, holds the list state — things impossible without a browser. The boundary is drawn precisely: the server component does exactly what is best done on the server (initial data loading, static HTML), and the client component does exactly what is impossible otherwise (interactivity).

Also note the error handling in the server component. A request failure is not thrown outward, but swallowed into undefined. The comment explains why: the page is pre-rendered at build time, and a thrown error would fail the entire build. An unavailable backend should not block the deployment. This is also part of thinking about server components — they have a different cost of failure because they execute at build time, not at view time.

A separate case is code that doesn't just need a browser, but is fundamentally incompatible with the server. 3D scenes on three.js access window and WebGL, which do not exist on the server. Such code cannot even be attempted to be rendered server-side, so it is loaded dynamically with SSR disabled:

// CylinderScene is loaded dynamically to never execute on the server
const CylinderScene = dynamic(
  () => import('./components/CylinderScene/CylinderScene'),
  { ssr: false }
);

Here, the boundary between server and client is drawn not for optimization's sake, but for correctness: attempting SSR on this component would simply crash on the first access to window.

The general rule that emerges from all this: pull data on the server and pass it down ready-made, and push interactivity down into the client leaves of the tree. Server and client components provide their benefits only when a meaningful boundary is drawn between them, not an accidental line. Every 'use client' placed too high drags the whole subtree into the bundle; every one placed at the right depth isolates client weight where it is unavoidable.

7. Dependency management between modules

All previous topics converge at a single point — the direction of the arrows. You can have perfect folders, a flawless shared layer, and clean API clients, but if dependencies between modules flow chaotically, the architecture will still turn into a tightly coupled knot where it's impossible to change one thing without touching everything.

Let's map out the dependency graph of our application. It has clear layers, and the arrows point only downward:

    features (src/app/*)
        │
        ├──────────────┐
        ▼              ▼
 shared (UI,      api (domain:
 contexts,        auth, posts,
 const, lib)      profile)
        │              │
        │              ▼
        │         api/index (transport)
        │              │
        └──────┬───────┘
               ▼
        lib (cookies, schemas, site)

Features depend on shared and api. Domain API functions depend on the transport. Transport depends on low-level utilities. And that's all. What is not here is just as important as what is here. There are no upward arrows: shared does not depend on features, transport does not depend on domain functions. There are no sideways arrows between features: blog does not depend on profile, registration does not depend on login. The graph is acyclic and unidirectional.

Why is this critical specifically for scalability? Because the cost of changing a module equals the amount of things that depend on that module. When dependencies flow downwards and don't form cycles, each layer can be changed without fear for the layers below it. You can rewrite the entire transport index.ts — add caching, swap out the request library, change retry logic — and not a single domain function will notice, because they depend on the API.get/post interface, not on its internals. You can rewrite any feature — and nothing else will break, because no one depends on features.

Now imagine the opposite — what happens when there is no discipline. shared/SiteHeader starts importing something from app/profile because a convenient helper lies there. Now shared depends on a feature. app/blog imports a component from app/profile because "it almost fits". Now two features are coupled. Transport starts knowing about a specific domain model. Cycles appear: A depends on B, B depends on A. In such a graph, a safe change does not exist. Touching any file is roulette because consequences radiate in all directions simultaneously. This is the very "legacy mass" everyone has encountered: it's not the bad quality of individual code, but rather the chaotic dependency graph where everything knows about everything.

A loosely coupled modular architecture is not about aesthetics. It is about a concrete economic property: the cost of change remains local and predictable, no matter how much the project grows. We've already seen several mechanisms that support this property throughout the article, and now it's visible that they all serve a single goal.

Re-exports (index.ts in a module) define a narrow public surface for the module, so consumers depend on intent, not on accidental internal structure. The shared layer accepts the unidirectional flow as law, so it can be read and changed in isolation. The transport layer hides request mechanics behind a stable interface, so domain functions aren't coupled to implementation. Folders keep their own stuff to themselves and don't reach out to neighbors, so a feature can be deleted in one move. A single source of truth for routes and navigation eliminates scattered duplicate strings, so coupling via "magic constants" doesn't arise at all.

Each of these techniques individually looks like a triviality, almost like a matter of taste. Together, they form an architecture where the dependency graph remains a tree, not a spiderweb. And this — not the choice of a specific framework, state library, or styling solution — determines whether a project will live to see its second year in a state where making changes is still pleasant.

Conclusion

If everything had to be reduced to a single thought, it would be this: frontend scalability is a property of the relationships between pieces of code, not of the code itself. The components folder becomes a dump not because of a lazy team, but because the structure doesn't ask "who uses this?". Feature-based wins not because it's trendy, but because changes arrive by features. A shared layer saves you exactly as long as its dependencies flow in one direction. API clients hold up when transport is separated from the domain. Server and client components yield their benefits only when a meaningful boundary is drawn between them, not an accidental line.

Architecture is not a set of concrete rules to be followed for the sake of the process itself. It is a tool to reduce cognitive load on the developer. When every team member knows exactly where to put a new file, where to look for a bug, and what consequences deleting a specific folder will have, the project ceases to be a source of stress.

Frontend scalability is not measured by lines of code or even CI build speed. It is measured by the time it takes to onboard a new colleague, and the ease with which the system absorbs inevitable product changes a year or two after launch. Build your application as a set of independent, predictable, and typed modules — and your frontend will never turn into a digital dump.