Building professional frontend systems is not only about creating screens with React or choosing a component library. As a product grows, the frontend becomes a critical layer of the system: it connects business domains, consumes APIs, represents complex states, maintains visual consistency, enables user flows, and needs to evolve without blocking teams.
In this article, I share how I think about scalable frontend architecture from a practical perspective, using technologies such as React, Next.js, TypeScript, microfrontends, TurboRepo, Storybook, monorepos, and design systems. The goal is not to explain “what React is” or “what Storybook is,” but to discuss real engineering decisions: how to divide modules, when to share code, when to separate products, how to avoid component duplication, how to maintain clear API contracts, and how to improve developer experience without adding unnecessary complexity.
A solid frontend architecture is not a pretty folder structure. It is a way of working that defines boundaries, ownership, reuse, consistency, performance, and maintainability. It also requires accepting trade-offs: not everything belongs in a shared package, not every product needs microfrontends, and not every abstraction makes the system better.
1. The problem: when frontend stops being “just screens”
In early stages, many frontend applications start simple: a few routes, reusable components, API calls, and local state. This works well while the product is small and the team understands most of the codebase.
The problem appears when the product grows.
More domains, flows, user roles, integrations, screens, UI variations, and contributors begin to appear. What used to be an easy application to understand can become a codebase where a small change produces unexpected side effects.
Common symptoms include:
- Duplicate components with small visual differences.
- Business logic mixed directly into UI components.
- APIs consumed inconsistently across the application.
- Routes or modules without clear ownership.
- States that are difficult to reproduce.
- Visual changes that break screens nobody reviewed.
- Shared packages that become heavy dependencies.
- Slow builds and low confidence during refactors.
When this happens, frontend stops being only a collection of screens. It becomes an internal platform on top of which products are built.
That is where frontend architecture becomes important.
2. What scalable frontend architecture means
For me, scalable frontend architecture is architecture that allows the product to grow without every new feature increasing system complexity disproportionately.
It does not mean overengineering. It also does not mean filling the project with patterns, folders, packages, and abstractions from day one. It means making decisions that make product growth, code maintenance, and team collaboration easier.
A scalable frontend architecture should answer questions such as:
- Where does each domain's logic live?
- Which components are truly shared?
- Which code belongs to a specific product?
- Which contracts exist between frontend and backend?
- What should render on the server and what should render on the client?
- How is UI documented?
- How are visual regressions prevented?
- How is consistency maintained across applications?
- How can one team work without breaking another team's flow?
Architecture is not measured only by folder structure. It is measured by the team's ability to make changes with confidence.
A good frontend architecture should make it possible to:
- Quickly understand where to modify something.
- Add features without duplicating logic.
- Share components without unnecessarily coupling products.
- Maintain visual consistency.
- Integrate with APIs predictably.
- Improve performance without rewriting the entire application.
- Make testing, documentation, and UI review easier.
In real products, the value of architecture becomes visible when the system changes. Frontend always changes: design changes, APIs change, flows change, roles change, and business requirements change. Architecture therefore needs to be designed for evolution.
3. React and Next.js as the system foundation
React remains a strong foundation for complex interfaces because it models UI as component composition. Its value is not only in creating components, but in separating responsibilities: presentation, state, interaction, composition, and business logic.
Next.js adds an important layer when the application needs routing, hybrid rendering, server-side rendering, static generation, asset optimization, layouts, middleware, or API integration. In applications where SEO, initial performance, authentication, or navigation experience matter, Next.js is often a practical choice.
But using React and Next.js does not guarantee scalable architecture. A Next.js application can still become disorganized, with duplicate components, mixed responsibilities, and poorly structured dependencies.
The difference is in how boundaries are defined.
For example, in an application with several business domains, I do not like organizing everything only by technical type:
components/
hooks/
services/
utils/
pages/This structure can work initially, but over time it forces developers to navigate many folders to understand one complete feature.
A more scalable alternative is organizing by domain or module:
src/
domains/
billing/
components/
hooks/
services/
types/
pages/
users/
components/
hooks/
services/
types/
pages/
analytics/
components/
hooks/
services/
types/
pages/
shared/
components/
hooks/
utils/
types/The domain becomes explicit. Code related to billing, users, or analytics lives together. This improves ownership, reduces scattered changes, and makes the system easier for new team members to understand.
In Next.js, this organization can coexist with the router. Routes do not need to contain all product logic. They can act as entry points that compose domain modules:
// app/dashboard/analytics/page.tsx
import { AnalyticsDashboard } from "@/domains/analytics/pages/AnalyticsDashboard"
export default function Page() {
return <AnalyticsDashboard />
}The route stays simple. Product logic lives inside the domain.
4. Organizing by domains and modules
One of the most important decisions when scaling frontend is deciding how the product is separated internally.
Not every module needs to become a package. Sometimes clearly defined folders inside one application are enough. Separation should reflect the product, not only technical preferences.
A domain can represent a real part of the business:
domains/
orders/
payments/
customers/
reports/
inventory/Each domain can contain its own components, hooks, services, validations, types, and helpers:
domains/
payments/
components/
PaymentSummary.tsx
PaymentStatusBadge.tsx
hooks/
usePaymentDetails.ts
services/
paymentsApi.ts
types/
payment.types.ts
pages/
PaymentDetailsPage.tsxThis helps prevent everything from ending up in one global components folder.
Not every component should be shared. A common mistake is moving components into shared or ui too early when they actually belong to one specific domain.
For example, a PaymentStatusBadge may look reusable, but if it depends on payment-domain rules, it probably belongs in domains/payments.
By contrast, a generic Button, Modal, Input, MetricCard, or DataTable can belong in a shared library if it is truly used across different contexts.
A practical rule that has worked well for me:
- If a component knows business rules, it belongs to the domain.
- If a component only represents reusable UI, it can be part of the design system.
- If a helper depends on one specific workflow, it should not go into global utils.
- If something is shared across applications, it should expose a clear and stable API.
Scalable frontend architecture depends heavily on these boundaries. When boundaries are clear, the codebase becomes easier to maintain.
5. Microfrontends: when they help and when they add complexity
Microfrontends can be useful when several applications or teams need to work independently on different parts of a product. However, they are not an automatic solution for every large frontend.
A microfrontend can make sense when conditions such as these exist:
- Independent teams with clear ownership.
- Product domains that are sufficiently separated.
- A real need for independent deployments.
- Different release cycles.
- Applications that share a session but not much internal state.
- Modules that can evolve without strong dependencies on others.
For example, it can make sense to separate products such as:
apps/
dashboard/
billing/
admin/
logistics/Each application can have its own routes, configuration, and deployment while still sharing packages for UI, types, configuration, and utilities.
But there are also scenarios where microfrontends introduce more problems than benefits.
I would not use microfrontends when:
- The team is small and everyone works on the same flow.
- Modules share too much state.
- Separation is artificial.
- There is no clear authentication or session strategy.
- There is no real ownership by domain.
- Operational cost is greater than the benefit.
A common mistake is assuming that microfrontends solve architecture problems automatically. In reality, they make architecture problems more explicit. If a monolithic frontend is disorganized, splitting it into microfrontends can create several disorganized frontends with duplicated dependencies, visual inconsistencies, and fragile contracts.
A simple example of when I would not use microfrontends:
I would not separate "checkout-step-one", "checkout-step-two", and "checkout-step-three"
into independent microfrontends if they belong to the same flow,
share state, and need to be deployed together.In that case, keeping checkout as a domain module in one application is usually better:
domains/
checkout/
steps/
CustomerInfoStep.tsx
PaymentStep.tsx
ConfirmationStep.tsx
state/
services/
types/Separation should reduce complexity, not increase it.
6. TurboRepo and monorepos for sharing packages
When several frontend applications or related products exist, a monorepo can be an excellent way to organize code. TurboRepo helps manage builds, tasks, caching, and internal dependencies efficiently.
A common structure might look like this:
apps/
web/
admin/
dashboard/
packages/
ui/
config/
utils/
types/
eslint-config/
tsconfig/The idea is not to put everything into one repository because it is fashionable. The value comes from controlled code sharing.
For example:
- packages/ui: reusable components.
- packages/types: shared types between applications.
- packages/utils: generic utilities.
- packages/config: shared configuration.
- packages/tsconfig: base TypeScript configuration.
- packages/eslint-config: shared linting rules.
- apps/web: main application.
- apps/admin: back office or administration panel.
- apps/dashboard: independent product or module.
TurboRepo can run tasks only where relevant changes exist using caching and incremental builds. This improves developer experience and reduces CI/CD time.
A simple configuration example:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"lint": {
"outputs": []
},
"test": {
"outputs": ["coverage/**"]
},
"storybook": {
"cache": false
}
}
}The important point is that TurboRepo helps, but it does not replace architectural discipline.
A monorepo becomes difficult to maintain when:
- Every package depends on every other package.
- There are no boundaries between domains.
- The UI package contains business logic.
- Applications import internal files from other packages.
- Internal package APIs are not treated as stable contracts.
- Global dependencies are added without clear criteria.
A bad sign in a monorepo is seeing imports like this:
import { something } from "@company/ui/src/internal/components/LegacyThing"That breaks the idea of packages exposing public APIs. Ideally, packages expose only what consumers need:
import { Button, MetricCard } from "@company/ui"Each shared package should behave like a well-designed internal dependency. Even if it lives in the same repository, it still needs clear boundaries.
7. Reusable components and design systems
Component reuse is one of the strongest promises of modern frontend development, but it is also one of the areas where teams make the most mistakes.
A reusable component is not simply a component that is used twice. It should expose a clear API, be flexible without becoming confusing, and avoid knowing business-specific details.
For example, a MetricCard can belong to a UI library when it only represents a metric:
type MetricCardProps = {
label: string
value: string | number
trend?: 'up' | 'down' | 'neutral'
loading?: boolean
}It can be reused in billing, analytics, operations, or reporting without needing to know what the metric means.
A design system can include primitives and reusable patterns such as:
- Buttons.
- Inputs.
- Modals.
- Alerts.
- Tables.
- Cards.
- Typography.
- Spacing rules.
- Colors and tokens.
- Loading and empty states.
But the design system should not become a place where everything is added.
A useful question before moving something into packages/ui is:
Could this component be used in more than one product without understanding business-specific rules?
If the answer is no, it probably belongs to the domain.
8. Storybook as a documentation and validation tool
Storybook is especially useful when there is a component library or a design system. Its value is not only in “viewing components in isolation.” Its value is in documenting states, enabling visual review, and improving collaboration between engineering, design, and product.
A reusable component almost never has one state. It can have size variants, loading, disabled, error, success, empty states, long data, short data, and edge cases.
Storybook makes those states explicit.
Example:
export const Loading: Story = {
args: {
label: "Monthly usage",
value: "--",
loading: true,
},
}
export const PositiveTrend: Story = {
args: {
label: "Monthly usage",
value: "1,240 kWh",
trend: "up",
},
}Storybook is useful for:
- Documentation for new team members.
- Manual validation before releasing changes.
- Reviewing visual regressions.
- Discussing UI states with designers.
- Making component APIs easier to understand.
Storybook also forces better component API design. If a component is difficult to represent in a story, its API may be too coupled to the context where it originated.
9. API integration and data contracts
A scalable frontend architecture cannot ignore how it consumes data.
In real products, many frontend decisions depend on APIs: response structure, errors, pagination, authentication, permissions, filters, sorting, loading states, and update behavior.
A common mistake is consuming APIs directly inside UI components:
export function UsersTable() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('/api/users')
.then((response) => response.json())
.then(setUsers)
}, [])
// ...
}This mixes transport, state, and rendering.
A better separation is to place data access behind services or hooks:
// domains/users/services/usersApi.ts
export async function getUsers() {
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error('Unable to load users')
}
return response.json()
}Then the component consumes a domain-level abstraction instead of managing transport details directly.
This makes testing, refactoring, and maintenance easier.
With TypeScript, data contracts are especially important. Typing component props is not enough. Responses, errors, filters, payloads, enums, and intermediate states should also have explicit types.
When frontend and backend do not share clear contracts, subtle bugs appear: undocumented optional fields, inconsistent dates, enums that change, error payloads with different shapes, or partial responses.
Scalable frontend architecture needs to treat data contracts as a central part of the system.
10. Performance, SSR, and rendering decisions
Next.js provides several rendering strategies, but choosing between SSR, SSG, ISR, or client-side rendering should not be automatic. It should depend on the page, the data, update frequency, and the desired user experience.
Useful questions include:
- Does the page need SEO?
- Does the data change on every request?
- Does the user need personalized content?
- Can the page be generated ahead of time?
- Is the data expensive to calculate?
- Can responses be cached?
- Is initial loading time more important than client interactivity?
For example, public marketing content may be a good candidate for static rendering. A personalized dashboard often needs dynamic data and client interaction. A product catalog may benefit from ISR. A highly interactive internal tool may not need server rendering at all.
Performance also includes more than rendering mode.
It includes:
- Bundle size.
- Code splitting.
- Image optimization.
- Request waterfalls.
- Caching.
- Data-fetching strategy.
- Re-render frequency.
- Expensive client calculations.
- Third-party scripts.
A scalable architecture should make performance work local and incremental rather than requiring a complete rewrite.
11. Team boundaries, ownership, and engineering rules
Architecture also exists in the way teams work.
Clear ownership reduces duplicated effort and makes code review easier. A module should have a recognizable domain owner or at least a clear responsibility boundary.
Simple engineering rules can help maintain structure:
- Domain modules should not import private internals from other domains.
- Shared packages should expose public APIs.
- UI packages should not contain product-specific business logic.
- Applications should not import internal files from other packages.
- Design-system changes should be reviewed in Storybook.
- Important modules should have clear ownership.
These rules do not need to be complicated. They need to be understandable and useful.
12. Common mistakes when scaling frontend
Scaling frontend also means learning what to avoid.
Moving everything to shared too early
Not everything should be shared. Sharing code before understanding its variations can create rigid abstractions.
A component duplicated twice is not always a problem. Temporary duplication can sometimes help reveal the real pattern before abstraction.
Creating components that are too configurable
A component with too many props becomes hard to understand:
<Button
variant="primary"
size="md"
loading={isSubmitting}
analyticsId="checkout-button"
trackingEvent="checkout_submit"
permission="can_submit_order"
/>Some of these props may be valid, but when a reusable button starts handling tracking, permissions, business rules, and layout-specific behavior, it is probably doing too much.
Using microfrontends without real ownership
Separating applications without clear teams or domains usually adds complexity. Microfrontends require strategies for routing, session, communication, deployment, shared dependencies, and visual consistency.
If there is no strong reason, a modular monolith can be a better solution.
Ignoring backend contracts
When frontend assumes data structures without clear contracts, bugs appear late. TypeScript helps only when the types reflect what the API actually returns.
Having a design system without adoption
A design system has little value if teams keep creating local variants of every component. Adoption requires documentation, good APIs, support, and ongoing evolution.
Confusing architecture with folders
A project can have a beautiful folder structure and still have poor coupling. Real architecture lives in dependencies, contracts, and boundaries.
Not measuring performance
Optimizing without measuring often produces the wrong conclusions. It is better to identify whether the actual problem is bundle size, rendering, network, images, waterfalls, data, caching, or interaction.
13. Practices that have worked well for me
Separate generic UI from domain UI
Generic UI can live in a shared package. Domain UI should stay close to the domain.
packages/ui/
Button.tsx
Modal.tsx
MetricCard.tsx
apps/dashboard/src/domains/billing/
BillingSummary.tsx
InvoiceStatusBadge.tsxThis keeps business-specific rules out of the design system.
Design simple component APIs
A reusable component should be easy to use and difficult to misuse. Props should represent clear behavior.
type ButtonProps = {
children: React.ReactNode
variant?: "primary" | "secondary" | "danger"
disabled?: boolean
onClick?: () => void
}Not every use case needs to fit into one component. Composition is often better.
Keep data contracts near the domain
Types, services, and mappers should live close to the module that uses them. That makes changes easier when APIs evolve.
domains/
reports/
services/
types/
mappers/
hooks/Document states in Storybook
A default story is not enough. Important states should also be represented.
export const Loading: Story = {
args: {
label: "Monthly usage",
loading: true,
},
}
export const PositiveTrend: Story = {
args: {
label: "Monthly usage",
value: "1,240 kWh",
trend: "up",
},
}This helps catch visual issues before they reach production.
Use TurboRepo to accelerate work, not hide complexity
TurboRepo can improve builds and developer experience, but if the monorepo has poorly designed dependencies, the tool does not solve the architectural problem.
The dependency graph should be reasonable:
apps/web -> packages/ui, packages/types
apps/admin -> packages/ui, packages/types
packages/ui -> packages/config
packages/types -> no UI dependenciesA warning sign would be packages/types depending on packages/ui, or packages/ui depending on an application.
Define ownership
Each important module should have clear maintainers or ownership criteria. This helps review changes, avoid duplication, and maintain consistency.
14. Example of a complete monorepo architecture
A possible structure for a product with several applications could be:
apps/
web/
app/
src/
domains/
users/
billing/
analytics/
admin/
app/
src/
domains/
users/
permissions/
operations/
dashboard/
app/
src/
domains/
reports/
monitoring/
packages/
ui/
src/
Button/
Modal/
MetricCard/
StatusBadge/
types/
config/
utils/
eslint-config/
tsconfig/A generic component can live in packages/ui, while a domain-specific adapter remains inside the application.
For example:
// packages/ui/StatusBadge.tsx
type StatusBadgeProps = {
label: string
variant: 'success' | 'warning' | 'danger' | 'neutral'
}Then a user-domain component can translate business state into generic UI:
// domains/users/components/UserStatusBadge.tsx
import { StatusBadge } from '@company/ui'
export function UserStatusBadge({ status }: { status: UserStatus }) {
if (status === 'active') {
return <StatusBadge label="Active" variant="success" />
}
if (status === 'suspended') {
return <StatusBadge label="Suspended" variant="danger" />
}
return <StatusBadge label="Pending" variant="warning" />
}This pattern keeps responsibilities clean:
- StatusBadge knows how to represent visual states.
- UserStatusBadge knows how to translate user-domain states into UI.
15. Lessons learned
The main lesson I have learned while building frontend systems for real products is that scalability does not appear automatically just because modern tools are being used. React, Next.js, TypeScript, TurboRepo, and Storybook are useful tools, but the result depends on architectural decisions.
A scalable frontend needs clear boundaries. Without boundaries, any application can import anything, any component can grow without control, and any change can affect unexpected parts of the product.
I also learned that reuse needs to be intentional. Reusing too early can be as expensive as duplicating. The key is observing real patterns and abstracting once the use case is sufficiently clear.
Microfrontends can be a good decision when there are independent teams, domains, and deployments. But they can also introduce significant operational complexity. They should not be used only because the frontend became large. Sometimes a monorepo with well-defined modules is simpler and more maintainable.
Storybook is valuable because it changes the way teams work with UI. It makes it possible to validate components outside the complete application flow, document states, and facilitate conversations between design, product, and engineering.
TurboRepo can greatly improve developer experience when there are several applications and packages, especially through caching, incremental tasks, and workspace organization. But it requires discipline around dependencies, exports, and ownership.
TypeScript provides far more value than autocomplete. Used well, it expresses contracts between components, domains, and APIs. It helps identify inconsistencies before runtime and increases confidence when refactoring.
Next.js enables better rendering decisions, but not everything needs SSR. Choosing between SSR, SSG, ISR, or client-side rendering depends on the product, the data, and the expected experience.
Ultimately, scalable frontend architecture means building systems where teams can move forward without breaking one another. It means designing for change. It means thinking about the product, not only about components.
16. Signals for recruiters
- Designed scalable frontend architecture with React, Next.js, and TypeScript.
- Experience organizing applications by domains, modules, and clear ownership.
- Built monorepos with TurboRepo, independent applications, and shared packages.
- Developed reusable components, component libraries, and design systems.
- Used Storybook for documentation, visual/manual validation, and collaboration with design.
- Experience defining boundaries between domain UI and generic shared UI.
- Designed typed frontend/backend data contracts and predictable API-consumption layers.
- Practical experience evaluating microfrontends versus modular monoliths.
- Applied SSR, SSG, ISR, and client-side rendering based on product requirements.
- Improved developer experience through package boundaries, caching, incremental builds, and clear public APIs.
- Focused on maintainability, performance, ownership, and safe refactoring in growing frontend systems.
