How I approach and structure Enterprise frontend applications after 4 years of using Next.js
Introduction
In the fast-paced world of frontend development, staying ahead of the curve is essential for building successful enterprise applications. After four years of using Next.js and a powerful toolkit that includes Tailwind CSS, TypeScript, TurboRepo, ESLint, React Query, and more, I have gained valuable insights and best practices to share with fellow developers. In this blog post, we will explore how to architect and structure frontend applications for large-scale enterprises, maximizing performance, maintainability, and scalability.
NB This article expresses personal viewpoints, and the methods I advocate may not be suitable for your specific situations.
Guiding Principles of Effective Enterprise Frontend Architecture
When it comes to architecting frontend solutions for enterprise-scale applications, having a well-defined set of principles can be the North Star that keeps your development efforts on course. In this section, I'll share the guiding principles that have emerged from my experience with Next.js in enterprise environments.
Modularity and Componentization
Principle: Divide and Conquer
In the sprawling landscape of enterprise applications, code can quickly become an unruly beast. Embrace modularity and componentization to break down your frontend into manageable pieces. Think of components as Lego blocks, each serving a specific purpose. This not only enhances code reusability but also simplifies maintenance and collaboration within your development team. Consider not only the segmentation of your application into smaller components, but also the possibility of breaking it down into smaller standalone applications. This is an area where tools like Turbo repo excel.
Separation of Concerns (SoC)
Principle: Keep Your Codebase Neat
To maintain code sanity, adhere to the Separation of Concerns (SoC) principle. Ensure that your components focus on their respective responsibilities, whether it's rendering UI, handling business logic, or managing state. This segregation not only makes code easier to understand but also facilitates testing and debugging.
Scalability by Design
Principle: Plan for Growth
Enterprise applications aren't static; they evolve. Design your frontend architecture with scalability in mind. This means selecting patterns and tools that can accommodate increased traffic, data volume, and feature complexity. Next.js's scalability-friendly design can be a valuable ally in this endeavor.
Maintainability and Code Quality
Principle: Craft with Care
Code is your product's foundation. Prioritize maintainability and code quality from day one. Enforce coding standards, conduct code reviews, and invest in automated testing. A well-maintained codebase is not only easier to work with but also less prone to bugs and regressions. At work I recently developed a component library and a basic style guide to enforce standards on our frontend applications. Don't mind the docs they are not yet doneπ.
Accessibility by Default
Principle: Inclusive from the Start
Accessibility is a non-negotiable aspect of modern web development. Make it a default practice from the beginning. Ensure your application is usable by all, regardless of disabilities. Leverage Next.js's support for accessibility standards and tools to create inclusive user experiences. I use tools like Radix UI for some components that require accessibility like the tabs, dropdowns, etc.
Performance-Oriented Development
Principle: Speed Matters
Enterprise users expect snappy experiences. Prioritize performance at every turn. Optimize assets, minimize unnecessary requests, and leverage Next.js's performance features like automatic code splitting, streaming with suspense and image optimization. A fast application not only pleases users but also positively impacts SEO.
Security First
Principle: Guard Your Castle
Security should be woven into the fabric of your frontend architecture. Protect against common vulnerabilities like Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF). Stay vigilant with security updates and best practices, and consider Next.js's built-in security features as an extra layer of defense.
Internationalization (i18n) and Localization (l10n)
Principle: Think Globally
In our interconnected world, thinking globally is essential. Implement internationalization (i18n) and localization (l10n) from the outset to cater to a diverse user base. Next.js provides excellent support for these features, making it easier to create multilingual applications.
These guiding principles form the bedrock of an effective enterprise frontend architecture when working with Next.js. They act as a compass, ensuring that your development efforts align with the demands of large-scale applications, making them robust, maintainable, and user-friendly. In the following sections, we'll delve deeper into how these principles can be translated into actionable strategies and best practices.
Folder and file structure
In React, organizing your project with a well-thought-out folder structure is crucial for maintainability and scalability. A common approach is to arrange files based on their functionality and purpose. Here's the sample folder structure I typically use for my applications:
ββ src/
β ββ components/
β β ββ ui/
β β β ββ Button/
β β β ββ Input/
β β β ββ ...
β β β ββ index.tsx
β β ββ shared/
β β β ββ Navbar/
β β ββ charts/
β β β ββ Bar/
β ββ modules/
β β ββ HomePage/
β β ββ ProductAddPage/
β β ββ ProductPage/
β β ββ ProductsPage/
β β β ββ api/
β β β β ββ useGetProducts/
β β β ββ components/
β β β β ββ ProductItem/
β β β β ββ ProductsStatistics/
β β β β ββ ...
β β β ββ utils/
β β β β ββ filterProductsByType/
β β β ββ index.tsx
β β ββ hooks/
β β ββ consts/
β β ββ types/
β β ββ lib/
| | ββ styles/
β β β ββ global.css
β β ββ ...
β ββ public/
β β ββ ...
β β ββ index.tsx
β ββ eslintrc.js
β ββ package.json
β ββ tsconfig.json
ββ ...
src/components: This directory contains your UI components. It's further subdivided into
ui
for generic UI components andshared
for components that might be reused across different parts of your application.src/modules: This directory houses your application's different modules or pages. Each module might have its own folder, containing subdirectories for API calls, components and utility functions.
src/pages: If you are using Next.js this folder should only be used as the entry point to your application. No business logic should reside here. Components in the pages folder should only render pages from the modules folder.
src/modules/ProductsPage: This module is related to products, and it contains subdirectories for API calls, components (like
ProductItem
andProductsStatistics
), and utility functions (filterProductsByType
).src/lib: This folder may contain utility functions that can be converted later into packages that are used across multiple applications. It's different from src/utils which may contain utility functions that do not make sense to convert into packages later.
src/styles: This directory holds global styles (
global.css
) and possibly other style-related files.src/public: This folder contains static assets that don't go through the build process. It might include images, fonts, and the
index.html
file.src/consts, src/types: These directories likely contain constants and TypeScript type definitions respectively.
src/hooks: This directory may house custom hooks that are used throughout your application.
eslintrc.js: This is a configuration file for ESLint, a popular JavaScript linting tool. It's used to enforce coding conventions and catch potential errors in your code.
The tsconfig
file is configured such that if you for example want to import a Button
component you can do it like so import { Button } from '@/components/ui'
. Below is a snippet of how to configure that from tsconfig.json
.
{
...
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
TypeScript coding conventions
The conventions I follow are inspired by this guide here. I highly recommend you read it and the code snippets below are coming from that guide.
All types must be defined with type alias
// β Avoid interface definitions unless you need to extend or implement them
interface UserRole = 'admin' | 'guest'; // invalid - interface can't define (commonly used) type unions
interface UserInfo {
name: string;
role: 'admin' | 'guest';
}
// β
Use type definition
type UserRole = 'admin' | 'guest';
type UserInfo = {
name: string;
role: UserRole;
};
Avoid using multiple arguments
// β Avoid having multiple arguments
transformUserInput('client', false, 60, 120, null, true, 2000);
// β
Use options object as argument
transformUserInput({
method: 'client',
isValidated: false,
minLines: 60,
maxLines: 120,
defaultInput: null,
shouldLog: true,
timeout: 2000,
});
Naming Conventions
Although determining the optimal name can be challenging, try to enhance code readability and maintain consistency for future developers by adhering to established conventions:
Variables
-
Locals
Camel case
products
,productsFiltered
-
Booleans
Prefixed with
is
,has
etc.isDisabled
,hasProduct
-
Constants
Capitalized
PRODUCT_ID
- Object constants
Singular, capitalized with const assertion and optionally satisfies type (if there is one).
const ORDER_STATUS = {
pending: 'pending',
fulfilled: 'fulfilled',
error: 'error',
} as const satisfies OrderStatus;
Functions
Camel case
filterProductsByType
, formatCurrency
Generics
A name starts with the capital letter T TRequest
, TFooBar
(similar to .Net internal implementation).
Avoid (popular convention) naming generics with one character T
, K
etc., the more variables we introduce, the easier it is to mistake them.
// β Avoid naming generics with one character
const createPair = <T, K extends string>(first: T, second: K): [T, K] => {
return [first, second];
};
const pair = createPair(1, 'a');
// β
Name starts with the capital letter T
const createPair = <TFirst, TSecond extends string>(
first: TFirst,
second: TSecond
): [TFirst, TSecond] => {
return [first, second];
};
const pair = createPair(1, 'a');
Packages and tools.
In application development, it's common practice to leverage third-party tools to avoid unnecessary duplication of work. Here are some of the packages I utilize when building scalable applications.
React Query/Tanstack Query
React Query is highly beneficial in managing data fetching and synchronization in complex enterprise applications. It provides a unified approach to fetching data from APIs, caching, and handling mutations. In enterprise settings, applications often need to interact with multiple APIs and services. React Query can streamline this process by centralizing data management and reducing boilerplate code.
React Context
React Context is instrumental in managing global state across various components without the need for prop drilling. This is particularly valuable in enterprise applications where shared state, such as user authentication or preferences, needs to be accessible throughout the application.
I generally reserve the use of React Context or other state management tools as a last resort. It's advisable to minimize reliance on global state. Instead, aim to keep your state closer to where it's specifically required.
Cypress
Cypress is an excellent tool for end-to-end (E2E) testing. In enterprise applications, ensuring that critical workflows and features function correctly across different screens and components is paramount. Cypress is by far my favourite tool. Whenever my tests pass it gives me confidence that the code I introduced did not break the application. As enterprise applications evolve, it's crucial to conduct regression testing to catch any unintended side effects of new code changes. Cypress facilitates this by automating the testing process.
React Testing Library:
React Testing Library is essential for unit and integration testing of React components. In enterprise applications, verifying that individual components work as expected is crucial for a robust application. React Testing Library allows for thorough testing of each component in isolation, as well as in conjunction with others.
NextAuth.js:
NextAuth.js simplifies the implementation of authentication and authorization in Next.js applications. In enterprise settings, secure user management is non-negotiable. Enterprises often employ Single Sign-On (SSO) solutions to streamline user authentication across multiple applications. NextAuth.js supports various SSO providers, making it an excellent fit for enterprise authentication needs. NextAuth.js also offers the flexibility to implement custom authentication flows.
I have a blog here that shows you how to customise default User
model in NextAuth.js with TypeScript using module augmentation.
Turbo Repo
This is also my favourite tool. Turbo Repo is a valuable tool for managing monorepos. In large enterprise applications, codebases can be extensive, with various modules, services, and shared code. Turbo Repo aids in organizing, versioning, and deploying these codebases efficiently. In enterprise settings, code sharing across different teams and projects is common. Turbo Repo enables effective code sharing, allowing teams to collaborate on shared libraries and components.
Storybook
Storybook allows developers to isolate UI components and showcase them in a controlled environment. This makes it easy to demonstrate how individual components look and behave without the need to navigate through the entire application. In large enterprise applications, different developers or teams may be responsible for different parts of the UI. Storybook provides a centralized platform for showcasing and discussing UI components, fostering efficient collaboration and ensuring a consistent design language. Here is a sample component library I developed and documented using storybook.(It's still a work in progress btw)
In an enterprise context, these tools collectively provide a comprehensive toolkit for building, testing, and maintaining large-scale applications, addressing critical aspects like data management, state handling, testing, authentication, and code organization.
Coding style for reusable components
When I develop reusable components like inputs, dialogs, etc I try to follow some best practices.
Let's try some best practices for developing a Button
component together and you will see that its more than just the visual design.
Component Reusability
Ensure that your button component is designed to be reusable across different parts of your application. It should be flexible enough to accommodate various use cases.
Props for Customization
Provide props for common customization options like size, color, variant (e.g., primary, secondary), and disabled state. This allows developers to easily adapt the button to fit different UI contexts.
Accessibility Considerations
Implement proper accessibility features such as aria-label, aria-disabled, and focus management. This ensures that users of assistive technologies can interact with the button effectively.
Semantic HTML
Use semantic HTML elements (e.g., ) for your button component. This enhances accessibility and SEO, and ensures proper behavior across different devices.
Mimic the native button element
All these best practices we are following forces us to write predictable code. If you develop a custom button component, make it work and behave like a button. You will see from the example component we will write together that I try to include all props a button can take by extending the native button element.
Error Handling
If the button can potentially lead to an error state (e.g., submitting a form), provide a way to handle and communicate these errors to the user.
Testing
Write unit tests to verify that the button component behaves as expected in different scenarios. Test cases should cover various props and event handlers.
Documentation
Document the usage of the button component, including available props, event handlers, and any specific use cases. Provide examples and code snippets to guide developers. This is where storybook shines.
Cross-Browser Compatibility:
Test the button component in different browsers to ensure consistent behavior and appearance.
Versioning and Changelog
If the button component is part of a shared library, implement versioning and maintain a changelog to keep developers informed of updates and changes.
Coding
For my components I usually have files like these. Button.tsx
, Button.stories.tsx
, Docs.mdx
, Button.test.ts
. If you are using CSS you may have something like Button.module.css
.
components/ui/Button.tsx
This is the main component and the cn
function merges the classes and handles conflicts. It's a wrapper around the tw-merge
library.
import React from 'react';
import {
forwardRef,
type ButtonHTMLAttributes,
type JSXElementConstructor,
type ReactElement,
} from 'react';
import { AiOutlineLoading3Quarters } from 'react-icons/ai';
import type { VariantProps } from 'cva';
import { cva } from 'cva';
import Link from 'next/link';
import { cn } from '@/lib';
const button = cva(
'flex w-max items-center border-[1.5px] gap-2 transition duration-200 ease-linear focus:outline-0 focus:ring ring-offset-1 dark:ring-offset-blue-dark',
{
variants: {
variant: {
outline: '...',
solid: '...',
naked: '...',
},
rounded: {
none: 'rounded-none',
sm: 'rounded',
md: 'rounded-lg',
lg: 'rounded-xl',
full: 'rounded-full',
},
color: {
primary: '...',
danger: '...',
info: '...',
warning: '...',
light: '...',
secondary: '...',
},
size: {
xs: '...',
sm: '...',
md: '...',
lg: '...',
},
disabled: {
true: '...',
},
active: {
true: '...',
},
loading: {
true: '...',
},
fullWidth: {
true: '...',
},
align: {
center: '...',
left: '...',
right: '...',
between: '...',
},
},
compoundVariants: [
{
variant: 'solid',
color: ['secondary', 'warning', 'danger', 'info'],
className: '...',
},
{
variant: 'solid',
color: 'primary',
className: '...',
},
{
variant: 'outline',
color: ['primary', 'secondary', 'warning', 'danger', 'info'],
className: '...',
},
{
variant: 'outline',
color: 'light',
className:
'...',
},
{
variant: 'naked',
color: ['primary', 'secondary', 'warning', 'danger', 'info'],
className:
'...',
},
{
disabled: true,
variant: ['solid', 'outline', 'naked'],
color: ['primary', 'secondary', 'warning', 'danger', 'info', 'light'],
className: '...',
},
{
variant: 'outline',
color: ['primary', 'secondary', 'warning', 'danger', 'info', 'light'],
className: '...',
},
{
variant: 'naked',
color: 'primary',
className: '...',
},
],
defaultVariants: {
size: 'md',
variant: 'solid',
color: 'primary',
rounded: 'lg',
align: 'center',
},
}
);
interface BaseProps
extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'color' | 'disabled' | 'active'
>,
VariantProps<typeof button> {
href?: string;
loadingText?: string;
target?: '_blank' | '_self' | '_parent' | '_top';
as?: 'button' | 'a' | JSXElementConstructor<any>;
}
export type ButtonProps = BaseProps &
(
| {
rightIcon?: ReactElement;
leftIcon?: never;
}
| {
rightIcon?: never;
leftIcon?: ReactElement;
}
);
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const {
as: Tag = 'button',
variant,
color,
rounded,
size,
target = '_self',
loading,
fullWidth,
align,
loadingText,
href,
active,
rightIcon,
leftIcon,
className,
disabled,
children,
...rest
} = props;
const classes = cn(
button({
variant,
color,
size,
disabled,
loading,
active,
rounded,
fullWidth,
align,
}),
className
);
return (
<>
{href ? (
<Link className={classes} href={href} target={target}>
{leftIcon}
{children}
{rightIcon}
</Link>
) : (
<Tag className={classes} disabled={disabled} ref={ref} {...rest}>
{loading ? (
<>
<AiOutlineLoading3Quarters className='animate-spin' />
{loadingText || 'Loading...'}
</>
) : (
<>
{leftIcon}
{children}
{rightIcon}
</>
)}
</Tag>
)}
</>
);
}
);
Button.displayName = 'Button';
components/ui/Button.stories.tsx
This file has the button stories for storybook.
import { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { FaRegSmileWink, FaThumbsUp, FaYinYang } from 'react-icons/fa';
import { FiArrowUpRight } from 'react-icons/fi';
import { Button } from './Button';
export default {
title: 'Components/Button',
component: Button,
parameters: {},
args: {
children: 'Click me!',
},
argTypes: {
children: {
description: 'This is the text of the button, can be a node.',
control: { type: 'text' },
},
color: {
options: ['primary', 'danger', 'info', 'warning', 'secondary', 'light'],
control: { type: 'select' },
description: 'This controls the color scheme of the button',
table: {
defaultValue: { summary: 'primary' },
},
},
variant: {
options: ['solid', 'outline', 'naked'],
control: { type: 'select' },
description: 'This controls the variant of the button',
table: {
defaultValue: { summary: 'solid' },
},
},
size: {
options: ['sm', 'md', 'lg'],
control: { type: 'radio' },
description: 'This controls the size of the button',
table: {
defaultValue: { summary: 'md' },
},
},
loading: {
control: { type: 'boolean' },
description: 'This controls the loading state of the button',
table: {
defaultValue: { summary: false },
},
},
href: {
control: { type: 'text' },
description:
'If this is set, the button will be rendered as an anchor tag.',
},
className: {
control: { type: 'text' },
description: 'Classes to be applied to the button',
},
disabled: {
control: { type: 'boolean' },
description: 'If true, the button will be disabled',
table: {
defaultValue: { summary: false },
},
},
rightIcon: {
options: ['Smile', 'ThumbsUp', 'YinYang'],
mapping: {
Smile: <FaRegSmileWink />,
ThumbsUp: <FaThumbsUp />,
YinYang: <FaYinYang />,
},
description:
'If set, the icon will be rendered on the right side of the button',
},
leftIcon: {
options: ['Smile', 'ThumbsUp', 'YinYang'],
mapping: {
Smile: <FaRegSmileWink />,
ThumbsUp: <FaThumbsUp />,
YinYang: <FaYinYang />,
},
description:
'If set, the icon will be rendered on the left side of the button',
},
loadingText: {
control: { type: 'text' },
description:
'If set, the text will be rendered while the button is in the loading state',
},
target: {
control: { type: 'text' },
description:
'If set, the target will be rendered as an attribute on the anchor tag',
table: {
defaultValue: { summary: '_self' },
},
},
as: {
options: ['button', 'a'],
control: { type: 'select' },
description:
'If set, the button will be rendered as the specified element',
table: {
defaultValue: { summary: 'button' },
},
},
},
} as Meta<typeof Button>;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {},
};
export const Secondary: Story = {
args: {
color: 'secondary',
},
};
export const Danger: Story = {
args: {
color: 'danger',
},
};
export const Warning: Story = {
args: {
color: 'warning',
},
};
export const Light: Story = {
args: {
color: 'light',
},
};
export const Info: Story = {
args: {
color: 'info',
},
};
export const Custom: Story = {
args: {
className: 'bg-[yellow] text-[black] border-[orange]',
style: { borderRadius: '3.5rem' },
},
};
export const WithRightIcon: Story = {
args: {
rightIcon: <FiArrowUpRight className='h-5 w-auto' />,
},
};
export const WithLeftIcon: Story = {
args: {
leftIcon: <FiArrowUpRight className='h-5 w-auto' />,
},
};
export const Disabled: Story = {
args: {
disabled: true,
},
};
export const OutlineVariant: Story = {
args: {
variant: 'outline',
color: 'danger',
},
};
export const NakedVariant: Story = {
args: {
variant: 'naked',
color: 'danger',
},
};
export const Loading: Story = {
args: {
loading: true,
},
};
export const CustomLoadingText: Story = {
args: {
loading: true,
loadingText: 'Processing...',
},
};
export const AsLink: Story = {
args: {
href: 'https://fin.africa',
children: 'Visit fin website',
rightIcon: <FiArrowUpRight className='h-5 w-auto' />,
},
};
export const FullWidth: Story = {
args: {
fullWidth: true,
children: 'Visit fin website',
rightIcon: <FiArrowUpRight className='h-5 w-auto' />,
},
};
components/ui/Docs.mdx
The stories file is OK for documenting how the component works but a markdown file can have more extensive documentation.
The conventions I used to develop the Button
component are the same conventions I try to follow for all my components.
Key takeaways
Have a design system of some sort, weather its an open source solution or you spin up your own.
Make TypeScript your friend. Use TypeScript to your advantage, use it to enforce how you want people to consume your components. A good example on this is on our Button component. It has 2 props
leftIcon
andrightIcon
. We have used TypeScript to make sure that only one of these is set otherwise it errors to the developer.
export type ButtonProps = BaseProps &
(
| {
rightIcon?: ReactElement;
leftIcon?: never;
}
| {
rightIcon?: never;
leftIcon?: ReactElement;
}
);
Document your code and components. Use tools like storybook.
Have some sort of a style guide to make sure that you speak the same language with your team.
Write dump code. Keep your codebase straightforward and focused. Each piece of code should have a single, clear purpose.
Understand how things work under the hood. I published an article here after I found out how React checks if two values are the same.
Conclusion
We've explored some of the methods and tools I utilize. Although I haven't covered all the tools at my disposal, I suggest identifying what suits your particular requirements. It's advisable to stick to technologies you're skilled in, rather than adopting something solely for its novelty.
In the end, clients are most concerned with the final product, not the specific technologies you employ. Whether it's React, Vue, or another tool, prioritize the use of tools and workflows that enable quick deployment for the benefit of your users.
Resources
You may love these ones
Copyright Β© 2024 | All rights reserved.
Made with β€οΈ in ZimbabweπΏπΌ by Joseph Mukorivo.