Building Modular Frontends: a POC with React and Next.js

Andrea Dotta
16 min readJun 3, 2024

--

The article presents a POC that aims to implement some key concepts for developing modular and scalable applications. The first concept is modular architecture, which involves dividing applications into smaller, autonomous components, making the application ready for future division into microfrontends. The second concept is component-driven development, which focuses on designing autonomous components that encapsulate logic, state, and user interface. The separation of concerns is the third concept, maintaining a clear distinction between business logic, presentation, and state management, thus facilitating maintenance and updates. The fourth concept concerns server-side rendering (SSR) and client-side hydration. The use of Next.js for server-side rendering provides faster initial loading and improves SEO, while client-side hydration makes the static HTML interactive, combining performance and interactivity. The final concept is the use of an EventBus, which enhances modularity and testability by allowing components to communicate through events, improving flexibility and simplifying state management.

Can we break the monolith? Even the attempt could lead to new learnings and improvements

In this piece, I want to highlight the benefits of adopting a modular approach for your application. This strategy not only prepares the project for future division into micro frontends but also offers scalability benefits through domain partitioning and low coupling.

You may find this article useful, for example, if you are in the early stages of developing a new product. In such cases, it is common to follow approaches like lean inception and focus on creating a Minimum Viable Product (MVP). In this scenario, a simple architecture is often chosen to reduce design costs and time. Starting with a monolith is not a bad idea, but it is crucial to ensure it is truly modular, easily divisible, and capable of evolving into a more complex system. This allows for rapid and cost-effective initial development, laying the groundwork for future scalability and flexibility.

Here are the five most important aspects highlighted in this overview, summarized more clearly:

  1. Modular Architecture: Dividing applications into smaller, autonomous components enhances maintainability and testability, paving the way for future division into microfrontends.
  2. Component-Driven Development: Designing autonomous components that encapsulate logic, state, and UI ensures isolated development, testing, and maintenance, reducing dependencies.
  3. Separation of Concerns: Clear separation between business logic, presentation, and state management simplifies maintenance and updates, with services handling data access and UI components managing presentation.
  4. Server-Side Rendering (SSR) and Client-Side Hydration: Leveraging Next.js for SSR provides faster initial load and improved SEO, while client-side hydration makes the static HTML interactive, combining performance with interactivity.
  5. EventBus: Using an EventBus enhances modularity and testability by allowing components to communicate through events, improving flexibility and simplifying state management.

Below, I propose for the hypothetical MVP, the design of a modular and theoretically scalable front-end application, developed using a component-driven approach. The project leverages React for UI management, TypeScript for functional business logic, and Next.js 14 for server-side rendering and routing.

Technologies Used

  • React: JavaScript library for building user interfaces.
  • TypeScript: A language that adds static types to JavaScript, improving code reliability.
  • Next.js 14: A React framework for server-side rendering and routing.
  • React Hook Form: Library for managing form state and validation in React applications.
  • MUI & Styled Components: MUI provides Material Design components; Styled Components allow writing CSS to style components.
  • Jest: Testing framework for JavaScript, supporting mocking and snapshot testing.
  • Puppeteer: Node library for controlling Chrome/Chromium, used for browser automation and UI testing.
  • Zustand: A small, fast, and scalable state-management solution.

Let’s Get Started

Find the repository and get started.

Installation

Clone the repository:

git clone https://github.com/andreadotta/modular-design-app.git

Install dependencies:

pnpm i

Start the application:

pnpm run dev

Domain Analysis

The demo application manages a list of users and implements an authentication system, the geolocation using an external service.

User List:

The core element of the application is user management. Users are retrieved from a mock API (https://jsonplaceholder.typicode.com/users), which provides structured data containing name, username, email, and an address with latitude and longitude coordinates. This data is displayed on the application’s main page.

Authentication System:

The application includes an authentication system that allows users to log in and log out, ensuring secure credential management. This module is essential for controlling access to various features of the application.

User Geolocation:

The application has the ability to resolve users’ geographical coordinates into readable country names. When user data is retrieved, the application sends the latitude and longitude coordinates to the OpenStreetMap API to obtain the corresponding country name. This process, called reverse geocoding, transforms the coordinates into detailed geographical information.

Reverse Geocoding Workflow:

  1. Data Retrieval: The application retrieves user data from the mock API.
  2. Geocoding Request: The latitude and longitude coordinates of each user are sent to the OpenStreetMap API through a URL l
  3. API Response: The API responds with detailed location information, including the country name.
  4. Data Update: The application uses this information to update the user’s address, replacing the coordinates with the country name.

Code Structure

We need to clearly distinguish three levels in our application: Module, Application, and Presention

Module

  • This level contains autonomous components that manage independent entities and aggregates.
  • Components at this level are designed to be composed at higher levels.

Application

  • This level manages the entire application.
  • Orchestration of modules.
  • Management of containers.
  • Can include other UI components.

Presentation

  • Provides the base design or its extension for the application and modules.
  • It is a shared dependency at the Application level.
  • To avoid injecting styled components into the modules, it can be considered a shared library, which could be, for example, a package in a monorepo. Similar to MUI, it is accepted that modules have common dependencies.

Folder structure

src:

The main directory of the project containing all the source code.

app:

Contains application-specific code, organized by functionality. This folder includes:

  • (main): folder in Next.js 14 is the primary layout folder, defining the application’s main structure and routing, replacing the old page-based file structure.
  • users: Code related to user management, with files for the page, layout.
  • auth: Authentication management, including layouts and pages.
  • styles: Global and specific style files, such as CSS and global error components.

components: Contains UI components.

  • containers: Container components for managing logic and data, divided into:
  • auth: Contains the authentication container component.
  • users: Contains the user container component.
  • sidebar: Component for the sidebar of the UI.

config: General project configurations, such as environment variables or build configurations.

mocks: Mock data or APIs for testing and local development, using MSW (Mock Service Worker) to intercept and mock network requests.

modules: Contains distinct modules that manage specific functionalities or entities of the application.

  • (module name): Module for authentication management with subfolders for:
  • contexts: React contexts for global state management.
  • hooks: Custom hooks for authentication logic.
  • services: Services for API calls and data management.
  • stores: State management for authentication.
  • types: TypeScript type definitions.
  • ui: UI components specific to authentication.

shared: Contains shared components and utilities across various modules of the application.

  • ui: Generic and reusable UI components, such as boxes, buttons, and loading spinners.
  • utils: Utility functions for common tasks. This includes type definitions and error handling utilities, such as:
  • either.ts: Defines Either type for handling results with left and right functions.
  • errorMessages.ts: Provides standardized error messages for various application errors.
  • fetchData.ts: Contains a function for making API requests with error handling and data validation.

Design Patterns Used

Component-Based Design:

In this mini project, I adopt a Component-Based Design approach. Each component is designed to be autonomous and independent. Components units that encapsulate the logic, state, and user interface necessary to manage a specific entity or aggregate. Each component has its flexible internal architecture that can include various layers such as hooks, services, types, and user interface. This approach ensures that components can be developed, tested, and maintained in isolation, reducing dependencies between different parts of the application.

For example, the ui folder of a component contains the part specific to managing the user interface, while the services folder includes the logic for data access and API calls. The hooks folder manages the component’s state and business logic, and the types folder defines the types and interfaces to ensure the correct typing of data. In this way, each component remains focused on its own responsibility, respecting the Single Responsibility Principle.

Separation of Concerns:

The separation between business logic, presentation, and state management is clearly defined. For example, services in the services folder manage data access logic, while components in the ui folder handle the presentation.

Custom Hooks:

We use custom hooks to encapsulate fetching and state management logic. For example, hooks in the hooks folder contain the logic to manage the state of entities.

Single Responsibility Principle:

Each component and service is responsible for a single functionality or aggregate, in line with the Single Responsibility Principle.

Service Layer Pattern:

The data access logic is encapsulated in a service layer, clearly separating API calls from presentation logic. This approach facilitates the maintenance and updating of services without impacting the rest of the application. For example, get-users.ts handles API calls to fetch user data.

Principles of EventBus Communication

EventBus communication offers several advantages:

  • Decoupling: Reduces rigid dependencies between components, making the code more modular.
  • Testability: Facilitates unit testing since dependencies can be easily replaced with mocks or stubs.
  • Flexibility: Allows changing the implementations of dependencies without modifying the component’s code.

Use Case: Container and Component Communication via EventBus

In our application, we use an EventBus to facilitate communication between the container and its components. This approach enables modularity and clear separation of concerns. Below is an example of how the EventBus is utilized for refreshing user data.

// UsersContainer.tsx
import React from 'react';
import { Box, Toolbar } from '@mui/material';
import CustomButton from '@/ui/buttons/custom-button';
import { UsersList } from '@/users';
import { UserEvents, UserEventKeys } from '@/modules/users/events';
import { createEventBus } from '@/utils/event-bus';

const UsersContainer = ({ initialData, token }) => {
// Create EventBus with a specific scope
const userEventBus = createEventBus<UserEvents>('users-scope');

const emitRefreshUsers = () => {
userEventBus.emit(UserEventKeys.refreshUsers, undefined);
};

return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<h1>Users List</h1>
<Toolbar>
<CustomButton variant="contained" color="primary" onClick={emitRefreshUsers}>
Refresh
</CustomButton>
</Toolbar>
</Box>
<UsersList token={token} initialData={initialData} eventBus={userEventBus} />
</Box>
);
};

export default UsersContainer;

Handling Events in the Component

The UsersList component listens for the refresh event emitted by the UsersContainer and refreshes the user data accordingly.

// UsersList.tsx
import React, { useEffect, useState } from 'react';
import { User, UserEventKeys, UsersGrid } from '@/users';
import LoadingSpinner from '@/ui/loading-spinner';
import { EventBus } from '@/utils/event-bus';
import { UserEvents } from '../events';
import { useUsers } from '@/users';

const UsersList = ({ token, initialData, eventBus }) => {
const { data, loading, error, refreshUsers } = useUsers(token);
const [users, setUsers] = useState<User[]>(initialData);

useEffect(() => {
if (data.length > 0) {
setUsers(data);
}
}, [data]);

useEffect(() => {
const handleRefreshUsers = () => {
refreshUsers();
};

eventBus.on(UserEventKeys.refreshUsers, handleRefreshUsers);

return () => {
eventBus.off(UserEventKeys.refreshUsers, handleRefreshUsers);
};
}, [eventBus, refreshUsers]);

if (error) {
return <div>Error: {error}</div>;
}

return (
<div>
{loading ? (
<LoadingSpinner type="circular" color="primary" />
) : (
<UsersGrid data={users} />
)}
</div>
);
};

export default UsersList;

This approach enhances the overall architecture of the application, making it more maintainable and scalable.

Event Definitions for User Events

To manage user-related events, inside a module, we define a set of keys and interfaces to describe the events and their payloads. The UserEventKeys enum lists all possible user events, such as refreshing users, getting users, etc. The UserEvents interface extends BaseEvents to include these specific user events, ensuring that the EventBus can handle them properly.

// src/modules/users/events.ts
import { BaseEventKeys, BaseEvents } from '@/utils/app-events';
import { User } from './types/user';

export enum UserEventKeys {
refreshUsers = 'refreshUsers',
getUsers = 'getUsers',
// others
}

export interface UserEvents extends BaseEvents {
[UserEventKeys.refreshUsers]: undefined;
[UserEventKeys.getUsers]: undefined;
}

Service Layer

Definition and Purpose of the Service Layer

The Service Layer is a layer in software architecture that handles business logic and communication with external resources, such as databases, external APIs, or other services. This layer is fundamental for maintaining a modular and easily maintainable architecture, separating data access logic and business logic from presentation and user interaction.

The Service Layer acts as an intermediary between the presentation layer and external resources, providing a consistent interface for performing business operations. This layer handles:

  • Data Fetching: Retrieving data from external sources such as APIs or databases.
  • Data Adaptation: Transforming raw data into a format usable by the application.
  • Data Validation: Ensuring the integrity and correctness of data before using or saving it.

Implementation Example

In this implementation, we’re leveraging the getUsers service to fetch and validate user data. The getUsers function uses the external service getCountryFromCoordinates to obtain geographic information based on the provided coordinates. This service is passed as a parameter to the userAdapter function, which adapts the user data by adding the corresponding country to the coordinates. The validateUsers function checks that each user has the required fields correctly formatted and returns any validation errors. The fetchData function handles the HTTP request to get user data from a specific URL and, through the adapter function, transforms the received data into a format compatible with our system.

import { isRight, right, left, Either, isLeft } from '@/shared/utils/either';
import { taskEither, TaskEither } from '@/shared/utils/task-either';
import { userAdapter } from './user-adapter';

import { CountryFromCoordinates, User } from '../types/user';
import { ErrorMessage } from '@/utils/error-message';
import fetchData from '@/shared/utils/fetch-data';
import { userValidator } from './user-validator';
import { getCountryFromCoordinates } from '@/geo';

export const getUsers = (): TaskEither<Error, User[]> => {
const geoService = getCountryFromCoordinates;
const validateUsers = (users: User[]): Either<Error, User[]> => {
const validatedUsers = users.map(userValidator);
const errors = validatedUsers.filter(isLeft);
if (errors.length > 0) {
const errorMessages = errors
.map((err) => (err as { _tag: 'Left'; value: Error }).value.message)
.join('; ');
return left(new Error(ErrorMessage('Validation error') + errorMessages));
}
return right(
validatedUsers.map(
(result) => (result as { _tag: 'Right'; value: User }).value,
),
);
};
const adapter = (
input: any,
geoService: CountryFromCoordinates,
): TaskEither<Error, User[]> => {
const adaptTask: TaskEither<Error, User[]> = async () => {
try {
const users = input as any[];
const adaptedUsers = await Promise.all(
users.map(async (user) => {
const adaptedUser = await userAdapter(user, geoService)();
if (isRight(adaptedUser)) {
return adaptedUser.value;
}
throw new Error(adaptedUser.value.message);
}),
);
return right(adaptedUsers);
} catch (error) {
return left(
new Error(
ErrorMessage('Failed to adapt users') +
': ' +
(error as Error).message,
),
);
}
};

return taskEither(adaptTask);
};

return fetchData<User[]>(
'https://jsonplaceholder.typicode.com/users',
(input: any) => adapter(input, geoService),
validateUsers,
'GET',
{},
);
};

The Page component is an entry point for the user interface. It uses the fetchInitialData function to populate the UsersScreen. The fetchInitialData is an asynchronous utility that calls the getUsers service and handles the response. It checks if the response is a Right type from the Either utility, indicating a successful operation, and extracts the user data. For testing purposes, it currently only returns the first 8 users.

src/app/users/page.tsx

import { getUsers } from '@/users/services/get-users';
import { ValidatedUser } from '@/users/types/user';
import { isRight } from '@/shared/utils/either';
import UsersScreen from '@/components/screens/users/users-screen';
// Asynchronous function to fetch initial user data
async function fetchInitialData(): Promise<ValidatedUser[]> {
const result = await getUsers()();
console.log('User page', 'fetchInitialData');
const data = isRight(result) ? result.value : [];
return data.slice(0, 8); // Returns only the first 8 users for testing purposes
}
// Set the revalidation interval to 900 seconds (15 minutes)
export const revalidate = 900;
// Main page component
export default async function Page() {
const initialData = await fetchInitialData();
return <UsersScreen initialData={initialData} />;
}

Use Case: Adapter Utilizing an Injected Dependency

In our application, we use dependency injection to adapt user data with geographic information. The user adapter accepts an external service function as a parameter, which is used to fetch the country from geographic coordinates.

const adaptUser = async (
input: any,
geoService: (lat: string, lon: string) => TaskEither<Error, string>,
): Promise<Either<Error, User>> => {
const countryResult = await geoService(
input.address.geo.lat,
input.address.geo.lng,
)();
const user: User = {
id: input.id,
name: input.name,
username: input.username,
email: input.email,
address: {
street: input.address.street,
city: input.address.city,
zipcode: input.address.zipcode,
country: isRight(countryResult) ? countryResult.value : undefined,
},
phone: input.phone,
website: ensureHttp(input.website),
};
return right(user);
};
export const userAdapter = (
input: any,
geoService: (lat: string, lon: string) => TaskEither<Error, string>,
): TaskEither<Error, User> => {
return taskEither(() => adaptUser(input, geoService));
};

Custom Hook

Custom hooks also receive services as parameters, ensuring that the logic for fetching and managing state can be tested independently.

export const useUsers = (geoService: CountryFromCoordinates) => {
const [data, setData] = useState<User[]>([]); // State to hold user data
const [loading, setLoading] = useState(false); // State to manage loading status
const [error, setError] = useState<string | null>(null); // State to hold error messages/**
* Function to refresh user data.
* It sets the loading state, fetches the user data, and updates the states based on the result.
*/
const refreshUsers = useCallback(async () => {
setLoading(true); // Set loading state to true
setError(null); // Reset error state
const result = await getUsers(geoService)(); // Fetch user data
if (isRight(result)) {
setData(result.value); // Update data state if fetching is successful
} else {
setError(result.value.message); // Set error message if fetching fails
}
setLoading(false); // Set loading state to false
}, [geoService]);

return { data, loading, error, refreshUsers }; // Return states and refresh function
};

Test Example

During tests, we can pass a mock implementation of the service to verify the component’s behavior.

const mockGeoService = (lat: string, lon: string): TaskEither<Error, string> => {
return taskEither(() =>
Promise.resolve(
lat === '-37.3159' && lon === '81.1496'
? right('Australia')
: left(new Error('Country not found')),
),
);
};
describe('UserService', () => {
test('fetches and validates users', async () => {
const result = await getUsers(mockGeoService)();
expect(isRight(result)).toBe(true);
if (isRight(result)) {
const users = result.value;
expect(users.length).toBe(1);
expect(users[0].name).to be('Leanne Graham');
expect(users[0].address.country).to be('Australia');
expect(users[0].validated).to be(true);
}
});
});

Containers

“Containers” are React components that represent the main pages or views of the application. They are responsible for orchestrating presentation logic, coordinating interactions between child components, and managing communication via the EventBus. The role of containers, as previously illustrated in the context of the EventBus, includes:

The main responsibilities of “Containers” are:

  • Orchestration of child components: Containers compose various child components to create the complete user interface of the page or view. They determine which components should be displayed and how they should be arranged.
  • Coordination of interactions: Containers coordinate interactions between child components, handling events, updating data, and facilitating communication between components.
  • Communication via EventBus: Containers emit events via the EventBus to communicate with child components, ensuring a modular and decoupled management of state and actions.

Important Note

Containers should never directly manipulate types or perform business actions but should only orchestrate. State management and business actions should be handled through hooks.

Build-Time Page Generation and Client-Side Updates

In our project, we use a combination of build-time page generation and client-side updates to keep user data up-to-date. This approach allows us to benefit from both the performance of static generation and the flexibility of dynamic updates. Here is a detailed description of the mechanism used:

Build-Time Page Generation the “Page”

User pages are generated at build time using Next.js. This means that when the site is built, user data is fetched once and used to generate the HTML page. To ensure that the data does not become outdated, we mention here that we use a revalidation mechanism that reloads the data periodically.

Code Example

async function fetchInitialData(): Promise<ValidatedUser[]> {
const result = await getUsers()();
console.log('User page', 'fetchInitialData');
const data = isRight(result) ? result.value : [];
return data.slice(0, 8); // only 8 users for testing
}
// Set the revalidation interval to 900 seconds (15 minutes)
export const revalidate = 900;
// Page component
export default async function Page() {
const initialData = await fetchInitialData();
return <UsersScreen initialData={initialData} />;
}

Client-Side Updates

For dynamic data updates, we use a combination of useState, useEffect, and useCallback in a custom hook called useUsers. This allows us to keep the data updated on the client and reflect any changes in real-time.

Description of the Mechanism

  1. Build-Time Page Generation: User pages are generated at build time, using data fetched once and stored in static HTML.
  2. Revalidation: Data is reloaded periodically (every 15 minutes) to ensure it does not become outdated.
  3. Client-Side Updates: We use a custom hook (useUsers) to manage the client-side data state and update the UI in real-time.

Revalidation

In our project, we use a revalidation mechanism to ensure that user data is regularly updated. This is particularly useful for keeping the data synchronized without requiring manual requests. The revalidation is set to occur every 15 minutes (900 seconds).

To configure this, we have implemented the following in our code:

export const revalidate = 900; // Revalidate every 15 minutes

Additionally, we specify the Node.js runtime environment in our next.config.mjs file to ensure that our server-side code runs efficiently. This configuration is necessary for the revalidation to work properly in a server-side context:

/** @type {import('next').NextConfig} */const nextConfig = {
const nextConfig = {
serverRuntimeConfig: {
// Will only be available on the server side
runtime: 'nodejs',
},

By setting runtime: 'nodejs', we ensure that our application leverages the Node.js runtime environment, which is optimized for server-side operations, including data fetching and revalidation. This configuration is crucial for maintaining performance and reliability in our data handling processes.

Observability

For application observability, Sentry has been chosen, a tool that allows for effective monitoring and management of logs, traces, and metrics. Here’s how Sentry is integrated into the app’s ecosystem:

  • Logs: Sentry automatically captures and organizes error logs generated by the application, including stack traces, error messages, and operational context. Logs can be enriched with custom messages and additional information using functions like captureMessage and captureEvent.
  • Traces: Sentry’s traces allow for viewing a user’s complete interaction with the application by tracking the chain of requests and responses between modules or internal components. This helps identify performance bottlenecks and isolate the origins of errors.
  • Metrics: Sentry supports the creation and monitoring of custom metrics that can help assess the application’s performance. Metrics such as Web Vitals or other frontend performances can be tracked to obtain a detailed view of the application’s behavior in production.

To ensure everything functions correctly, the organization and authentication token for Sentry are configured in the .env and .env.local files of the application, specifying the NEXT_PUBLIC_SENTRY_ORG variable and the SENTRY_AUTH_TOKEN in the .env.sentry-build-plugin file. Moreover, with Sentry, it is possible to automatically direct errors to different projects depending on the module.

If you’ve made it this far, thank you! I hope this POC can serve as a useful foundation for creating your own POCs. I hope I haven’t overwhelmed you with too much information, but in the next articles, I will cover some specific topics and we will try to split some modules into a separate microfrontend, putting it to the test to see how much work it will require!

Bibliography

This bibliography highlights some resources that I can recommend for understanding modular and microservices architecture. The articles cover topics such as Module Federation, breaking monoliths into microservices, micro frontends, front-end platform engineering, and best practices for modularizing React applications. These insights are invaluable for developers looking to enhance their knowledge and skills in creating scalable and maintainable software systems.

--

--