TL;DR

In one of our current projects, we are developing, testing and deploying several next.js React & Redux universal web apps in a mono-repo. Using lerna, we have an easy time managing all the interdependencies and versioning our apps. We’ve built a design system based on atomic design that helps us organize code and assure consistency across the apps. We implement all our UI code with styled-components.

A working example can be found in this repository.

Our Story

It’s been a while since one of our clients started migrating their monolithic solution to a microservices architecture. The solution is a matching and risk management platform, and at the start, it consisted of several major modules in a single application.

The idea was to decouple modules one by one to have several applications and build product teams around each module that are focused on their particular domain.

The website, where the actual business was happening, was a major component in this transition. We needed to replace it with a modern web application, i.e. a single page application (SPA) to decouple it from the core. Furthermore, the website had different types of users, and instead of replacing it with only one application, we decided to split it into several SPAs, one for each user group. It would allow a clearer distinction between product teams and proportionate allocation of infrastructure resources as well as independent scaling.

One challenge here was the considerable overlap of concerns across all SPAs, i.e. large parts of business logic and UI implementation were very similar. Hence, choosing the combination of technologies and frameworks would depend on the answer to this question: What is the best way to split a big website into several web applications without compromising reusability of the code within and among them?

Our options for having separate apps that have a lot of code in common were:

  1. Keep every app in a separate repository. This would require us to split the shared code into several javascript packages and publish them to NPM. Each app would then need to install those packages separately.
  2. Having all applications together with the shared code in the same repository and referencing the shared code in each app: mono-repo.

We chose to go with the second option, to bypass the need to deploy to a remote place and update dependencies locally after every small change in the shared code. Also, we could still benefit from WebPack development features, e.g. hot module replacement across shared and app-specific code, and have a much faster development process.

We established a framework for structuring our code and we implemented it using a combination of cool technologies.

How Exactly?

After rounds of investigation and reality checks, we narrowed down our selection of stack and framework. Finally, we came up with the following mix, which has (so far) turned out promising:

Atomic Design: A Robust Design System

Based on atomic design, we developed a design system tailored to our needs:

Atomic Design - A Robust Design System

A good thing about the atomic design is that atoms, molecules, and organisms can be designed and tested for all states and edge cases in isolation (for example using storybook) while maintaining the functionality and integrity of design for all possible cases.

Next.js: Universal Web Apps With Little Configuration

We chose next.js, a framework for hybrid rendering of React applications, which enables running the same code on the server or the client for populating pages with required data. The default configuration comes with out-of-the-box code splitting per page among other optimizations, and this makes Next.js an ideal candidate for the backbone of a universal web app.

Styled Components: Goodbye to Stylesheets

For easy styling and theming, we use styled-components, a concrete CSS in JS solution. Actual CSS syntax can be used for creating React components, eliminating the need for any kind of mappings between components and CSS stylesheets.

The theme of each app can be controlled centrally and provided via a React context to all components. This way we avoided falling into common CSS traps like having an infinite number of overlapping stylesheets with conflicting class names, etc.

Lerna: Manage Interrelated Packages With No Pain

Dealing with dependency management and versioning of apps in a mono-repo can be tricky. We use lerna, a tool tailored for dependency management in interconnected JavaScript packages. Automatic semantic versioning is supported with no configuration and this saved us time when building our continuous deployment pipeline.

All Put Together

Our current repository structure, which can be depicted by the following conceptual diagram, consists of two main directories: packages, and apps. Packages withhold the shared code that is consumed by apps.

All abstract UI components i.e. atoms, molecules, and organisms, which are purely React Styled Components, reside in packages/components. Each app picks a few organisms from this package and binds them to the app-specific content and business logic. The produced components would map to the concept of templates in atomic design. The concept is also similar to what is known as “container components” in React.

In each app there are pages, and every page is formed by one or more templates and is associated with a URL. This is handled pretty well by Next.js.

Some handy shared code also lies in packages and is available to all apps as well as other packages.

Atomic Design: All Put Together

In our example repository you can see this conceptual categorization in action, under /packages and /apps.

CI/CD

We adapted our CI flows accordingly so that upon each pull request in the mono-repo, builds are triggered per app sequentially. All produced docker images are uniquely tagged and stored in a registry and are available to the QA team to test their versions of interest.

Our automated deployment flow also changed to facilitate independent deployments of each app to its respective public subdomain. If a package is changed since its last tag, a new tag is automatically created and versioned by lerna’s publish command. Therefore, a number of git tags in @company/A@x.y.z format are created, in which A is the name of an app, and x.y.z is the version number. Tests are performed in the CI layer and as output, a docker image is built and pushed to an image repository. This image then gets pulled in Rancher, a tool we use for managing and orchestrating docker containers, and replaces the older version.

CI_CD

Summary

Building dedicated product teams for each segment of the target audience is a strategy that can be applied for expanding online businesses, in which case maintaining cross-team consistency and code reusability is essential.

Our global atomic design system and the combination of technologies we used to implement it, such as lerna, next.js, and styled-components, have greatly helped us with this objective.


Fardin is a Technical Product Owner at MobiLab. When he’s not working on leading our product teams, he enjoys learning about frontend technologies.