Why Next.js Apps Struggle at Scale (And How Feature Layers Solve It)
Table of Contents Table of Contents Architecture overview of the feature layer Class diagram of feature layer Layers responsibilities Data layer Data transfer object (DTO) / Mapper Repository Domain layer Entity Enums IRepo Usecase Params BaseFailure The usage of functional programming in feature layer Architecture overview of the feature layer Business logic in applications is one of the most critical components, playing a major role in a project's maintainability and scalability. Unfortunately, in many large-scale frontend applications, mixing this logic with UI-related code severely compromises maintainability. In this article, we introduce a dedicated feature layer,a separate architectural tier for isolating business logic in medium-to-large projects. We’ll also explore how to organize this layer’s components (data/domain) and its folder structure using basic DDD principles. This article based on Next.js boilerplate repository. To explore all concepts in depth and see a production-ready boilerplate and following these best practices, visit: behnamrhp / Next-clean-boilerplate A Full featured nextjs boilerplate, based on clean architecture, mvvm and functional programming paradigm. Nextjs clean architecture boilerplate Table of content Overview Technologies Architecture Folder Structure Getting started Guildline Overview This project is a starting point for your medium to large scale projects with Nextjs, to make sure having a structured, maintainable and reusable base for your project based on best practices in clean architecture, DDD approach for business logics, MVVM for the frontend part, storybook and vitest for testing logics and ui part and also functional programming with error handling for business logics. Motivation Nextjs and many other new SSR tools provide a really good and new approach to handle frontend applications, with new tools to bring a new good experience for users. But as they're new and they just tried to bring new tools and features and also frontend community, didn't talk about software engineering and best practices approach for this tools. So in many cases we see many teams uses nextjs… View on GitHub Also to know more about higher level architecture in Next.js applications. please take a look at clean architecture in Next.js article. As we mentioned this layer handles the business logic of each feature. For each feature this layer is divided into two parts, data and domain. where the data is divided into: mapper repository while the domain is divided into; usecase entity failure repository interface Class diagram of feature layer The following class diagram shows the relation between domain and data inside feature layer: Layers responsibilities feature layer responsibilities can be divided into data layer responsibilities and domain layer responsibilities. Data layer Data transfer object (DTO) / Mapper: Data transfer object (DTO) are commonly used within the repository layer to convert data fetched from external sources (such as databases or APIs) into domain entities, and vice versa. This conversion ensures that the domain entities remain agnostic of the underlying data storage mechanisms and allows for easier integration with different data sources without affecting the core business logic. for example let's imagine that the response type when a book will be created is as follows: export default class UserMapper { static get toApiRole(): Record { return { [Role.ADMIN]: ApiRole.ADMIN, [Role.USER]: ApiRole.DESIGNER, [Role.MANAGER]: ApiRole.MANAGER, }; } static get toEntityRole(): Record { return { [ApiRole.ADMIN]: Role.ADMIN, [ApiRole.DESIGNER]: Role.USER, [ApiRole.MANAGER]: Role.MANAGER, }; } static mapToEntity( response: WithPaginationResponse, ): WithPagination { try { const usersEntity = response.data.map((user) => new User({ id: user.id, username: user.username, role: user.role ? UserMapper.toEntityRole[user.role] : Role.USER, displayName: user.display_name ?? "", }).toPlainObject(), ); return new WithPagination(usersEntity, response.total).toPlainObject(); } catch (e) { throw new ResponseFailure(e); } } static mapToCreateParams(params: CreateUserParams): CreateUserApiParams { return { username: params.username, password: params.password, display_name: params.displayName, role: UserMapper.toApiRole[params.role], }; } static mapToUpdateParams(params: UpdateUserParams): UpdateUserApiParams { return { username: params.username, display_name: params.displayName, role: UserMapper.toApiRole[params.role], }; } } Main file: us
Table of Contents
- Table of Contents
- Architecture overview of the feature layer
- Class diagram of feature layer
-
Layers responsibilities
-
Data layer
- Data transfer object (DTO) / Mapper
- Repository
-
Domain layer
- Entity
- Enums
- IRepo
- Usecase
- Params
- BaseFailure
-
Data layer
- The usage of functional programming in feature layer
Architecture overview of the feature layer
Business logic in applications is one of the most critical components, playing a major role in a project's maintainability and scalability. Unfortunately, in many large-scale frontend applications, mixing this logic with UI-related code severely compromises maintainability. In this article, we introduce a dedicated feature layer,a separate architectural tier for isolating business logic in medium-to-large projects. We’ll also explore how to organize this layer’s components (data/domain) and its folder structure using basic DDD principles.
This article based on Next.js boilerplate repository.
To explore all concepts in depth and see a production-ready boilerplate and following these best practices, visit:
behnamrhp / Next-clean-boilerplate
A Full featured nextjs boilerplate, based on clean architecture, mvvm and functional programming paradigm.
Nextjs clean architecture boilerplate
Table of content
- Overview
- Technologies
- Architecture
- Folder Structure
- Getting started
- Guildline
Overview
This project is a starting point for your medium to large scale projects with Nextjs, to make sure having a structured, maintainable and reusable base for your project based on best practices in clean architecture, DDD approach for business logics, MVVM for the frontend part, storybook and vitest for testing logics and ui part and also functional programming with error handling for business logics.
Motivation
Nextjs and many other new SSR tools provide a really good and new approach to handle frontend applications, with new tools to bring a new good experience for users. But as they're new and they just tried to bring new tools and features and also frontend community, didn't talk about software engineering and best practices approach for this tools.
So in many cases we see many teams uses nextjs…
Also to know more about higher level architecture in Next.js applications. please take a look at clean architecture in Next.js article.
As we mentioned this layer handles the business logic of each feature. For each feature this layer is divided into two parts, data
and domain
. where the data is divided into:
- mapper
- repository
while the domain is divided into;
- usecase
- entity
- failure
- repository interface
Class diagram of feature layer
The following class diagram shows the relation between domain and data inside feature layer:
Layers responsibilities
feature layer responsibilities can be divided into data layer responsibilities and domain layer responsibilities.
Data layer
Data transfer object (DTO) / Mapper:
Data transfer object (DTO) are commonly used within the repository layer to convert data fetched from external sources (such as databases or APIs) into domain entities, and vice versa. This conversion ensures that the domain entities remain agnostic of the underlying data storage mechanisms and allows for easier integration with different data sources without affecting the core business logic. for example let's imagine that the response type when a book will be created is as follows:
export default class UserMapper {
static get toApiRole(): Record<Role, ApiRole> {
return {
[Role.ADMIN]: ApiRole.ADMIN,
[Role.USER]: ApiRole.DESIGNER,
[Role.MANAGER]: ApiRole.MANAGER,
};
}
static get toEntityRole(): Record<ApiRole, Role> {
return {
[ApiRole.ADMIN]: Role.ADMIN,
[ApiRole.DESIGNER]: Role.USER,
[ApiRole.MANAGER]: Role.MANAGER,
};
}
static mapToEntity(
response: WithPaginationResponse<UserResponse>,
): WithPagination<UserParams> {
try {
const usersEntity = response.data.map((user) =>
new User({
id: user.id,
username: user.username,
role: user.role ? UserMapper.toEntityRole[user.role] : Role.USER,
displayName: user.display_name ?? "",
}).toPlainObject(),
);
return new WithPagination(usersEntity, response.total).toPlainObject();
} catch (e) {
throw new ResponseFailure(e);
}
}
static mapToCreateParams(params: CreateUserParams): CreateUserApiParams {
return {
username: params.username,
password: params.password,
display_name: params.displayName,
role: UserMapper.toApiRole[params.role],
};
}
static mapToUpdateParams(params: UpdateUserParams): UpdateUserApiParams {
return {
username: params.username,
display_name: params.displayName,
role: UserMapper.toApiRole[params.role],
};
}
}
Main file: user.mapper.ts
Repository:
The repository acts as an adapter pattern between the use case and external resource. Also transforms entity-shaped data into a format compatible with the data source. This bidirectional transformation is achieved through the use of data transfer objects (DTOs), which we call mapper in this boilerplate. Additionally, repositories can interact with each other within the same layer, enabling seamless communication between sibling repositories.
export default class UserRepositoryImpl implements UserRepository {
readonly endpoint: BackendEndpoint;
private fetchHandler: FetchHandler;
constructor() {
const di = serverDi(userModuleKey);
this.fetchHandler = di.resolve(FetchHandler);
this.endpoint = di.resolve(BackendEndpoint);
}
update(params: UpdateUserParams): ApiTask<true> {
const options: FetchOptions<UpdateUserApiParams> = {
endpoint: `${this.endpoint.users}/${params.id}`,
method: "PUT",
body: UserMapper.mapToUpdateParams(params),
};
return this.fetchHandler.fetchWithAuth(options);
}
delete(ids: string[]): ApiTask<true> {
const options: FetchOptions<string[]> = {
endpoint: this.endpoint.users,
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: ids,
};
return pipe(
this.fetchHandler.fetchWithAuth<undefined, string[]>(options),
map(() => true),
);
}
}
Main file: user repository
You can see in this repository class we imported backend endpoints and fetch handler boundary, and the repository knows how to work with these dependencies to connect to the third party apis.
To know more about endpoint architecture and di in Next.js applications use these two other articles:
Domain layer
Entity:
Entity is the business object that contains business rules. For example a Book entity can be as follows:
export default class Book {
id: string;
title: string;
autherName: boolean;
status: BookStatus;
constructor({ id, autherName, title, status }) {
this.id = id;
this.title = title;
this.autherName = autherName;
this.status = status;
}
}
note: BookStatus is an enum implemented in the same layer with entity.
Enums:
Enums are used to define a fixed set of values for specific properties within an entity. They ensure type safety and provide clarity on the possible states or options for those properties. for example if there is prop inside Book
entity that shows the status of the book, if the book is published
, sent
, draft
, an enum can be added as follows:
enum BookStatus {
PUBLISHED = "published",
SENT = "sent",
DRAFT = "draft",
}
export default BookStatus;
IRepo:
The repository interface defines a contract for data access operations, abstracting the interaction between the application's domain logic and the underlying data storage mechanisms. for example an IRepo to get a book can be implmented as follows:
interface IGetBookRepository{
execute(): TaskEither<Failure, Book>
}
Usecase:
Usecase represents the main business logic of the application and orchestrates the overall process flow. It interacts with entities and communicates with external systems through repositories (IRepository). Usecase can connect to its siblings in the same layer, other usecases for example, the following diagram illustrates how usecase and repositories can connect to other ones in the same layer:
Params:
Params serve as structured schemas containing input parameters crucial for use case execution. These schemas encompass business rules and validations that necessitate careful handling during processing. Their primary function is to facilitate modularization and decoupling within the application architecture, offering a standardized interface for seamless communication between different layers. For example if it is required to validate the params of the book, the title
, authorName
and status
should be validated, the team is formulating the validation terms, for example the team agreed on the following:
- title: it should not be undefined
- authername: it can be undefined
- status: it should be defined so the parms class will have two methods, the first one to validate each param separately, the second one to validate all the params at once:
get bookCreationParamsValidator() {
return {
title: string().required(),
autherName: string(),
status: string().required(),
};
}
/**
* Schema for all parameters.
* You can use it to validate or cast or use its type
*/
get schema() {
return object(this.bookCreationParamsValidator);
}
}
required
word means that the param should be defined, bookCreationParamsValidator
can be used to validate a param while schema
is used to validate all params.
note: In the boilerplate we used zod but you can use any other similare libraries for this part.
BaseFailure:
BaseFailure mechanisms in the application serve as critical pointer of errors or exceptional conditions encountered during execution. By sticking to functional programming principles, errors are consistently represented on the left side, ensuring clear and predictable error handling pathways. The base failure, represented by the Base Failure class, plays an important role in this process. By extending the Error class and providing a standardized failure message, it serves as a foundational element for creating custom failure classes made specifically to specific error scenarios. These custom failure classes allow for precise and reliable error signaling, enabling straight error handling workflows. Additionally, the use of failure keys facilitates automated error handling processes, enhancing efficiency and robustness. Overall, the integration of failure mechanisms aligns straightforward with the application's functional programming paradigm, ensuring comprehensive error management and enhancing code reliability.
note: during the implementation, BaseFailure is the left side of the reusable type
ApiTask
which is based onTaskEither
fromfp-ts
library. for example to make a failure for a problem in the network, it can be implemented as follows:
export default class NetworkFailure extends BaseFailure {
constructor() {
super("network");
}
}
to get more familiar with failure, take a look at this article How to Automate Failure Handling and Messages with This Design
The usage of functional programming in feature layer
Functional programming plays a crucial role in our feature layer, providing a structured and efficient approach to managing connections between different layers of our system. Beyond its traditional benefits, functional programming enables robust error handling and manipulation throughout the application. By following to functional principles, we ensure that each process within our system is handled independently and comprehensively, enhancing clarity and maintainability.
Through functional programming, we can easily propagate return types across layers, enabling clear communication and transparent data flow. This approach not only enhances code organization and readability but also promote modularization and decoupling, leading to a more maintainable and scalable architecture.
We leverage the fp-ts library for functional programming in TypeScript, which provides a rich set of abstractions and utilities for working with functional concepts. For more detailed information on functional programming concepts, see Functional programming documentation
Conclusion
The Feature Layer is the powerhouse of your Next.js app, cleanly separating business logic (domain) from data handling while leveraging functional programming for robust, scalable code. By structuring entities, use cases, and repositories with clear boundaries, you ensure:
✅ Maintainability – Business rules stay pure and testable.
✅ Flexibility – Swap data sources without breaking logic.
✅ Error Resilience – Functional programming (via fp-ts) streamlines error handling.
If you found this article helpful, I’d be truly grateful if you could:
⭐ Star the repository to support its visibility