The 5th Generation
The 5th Generation
This is the 5th blog renewal. Since the 4th renewal was in September of '23, it's been about half a year since I last undertook a major overhaul.
While the 4th renewal was about improving the blog's UI and patching up shaky code, the 5th renewal focused on improving and consolidating the project's overall structure to take it to the next level.
The key improvements are as follows.
- Adopting a monorepo
- Redesigning the project structure
- Elevating UI/UX through improved animation
Compared to previous renewals, this one involved a much higher level of improvement from a developer's perspective.
First, I adopted a monorepo for the project. In software architecture, project management approaches are broadly divided into three categories, defined respectively as Monolith, Multi-Repo, and Mono-Repo.
🖼️ Monolith, Multi-Repo, Monorepo
Diagramming each project management approach looks like the above.
- Monolith
- 💡 Has the largest structure among the three approaches. Multiple projects come together to form a single repository. Useful the smaller the project scale is and the more speed is required.
- ✅ High code reusability.
- ✅ Reduced development process overhead.
- ✅ Clear build pipeline and management points.
- ❌ Analyzing the project takes a lot of time.
- ❌ The scope of side effects grows exponentially.
- ❌ Test and build time increases significantly with scale.
- ❌ Very high coupling between each piece of code.
- Multi-Repo
- 💡 Each project is separated, each with its own repository. Useful for large-scale projects where each project can secure its own maintenance. Generally adopted by most services.
- ✅ Easy to establish per-project ownership and expertise.
- ✅ Lower coupling between each piece of code.
- ✅ Optimized configuration and infrastructure can be built for each project.
- ✅ Small scale and relatively clear per-repository roles make distributed work easier.
- ✅ Parallel processing of tests and builds is possible.
- ❌ Duplicate code arises.
- ❌ The more granular per-project configuration gets, the more management points there are.
- ❌ Repositories are prone to becoming isolated silos ("Galapagos syndrome") based on their area of concern.
- ❌ If dependencies between projects grow strong, it becomes difficult to respond promptly to changes in each project.
- Mono-Repo
- 💡 Multiple projects come together into a single repository, but each project is structurally separated. Depending on the developer's skill and configuration, it's an approach that can take the advantages of both monolith and multi-repo to manage large-scale projects more effectively.
- ✅ Can increase code reusability by managing the entire codebase together, while still being structurally separated for management purposes.
- ✅ Consistency can be applied across the entire service codebase.
- ✅ Can respond nimbly to changes in each individual project.
- ❌ High barrier to entry. If understanding of monorepos is lacking, you can end up in the farcical situation of only getting the downsides.
- ❌ Just because the structure is split up doesn't mean the project scale isn't still enormous, and the downsides that come with that still apply.
So then, why did I decide to adopt a monorepo? The reason lies with the Lab and the Blog.
The UI and layout structure of these two sites are very similar. Not only that, but they also use fairly complex business logic in the same way.
For example, both the Lab's project descriptions and the Blog's posts use markdown, and both need logic and components to convert it. Naturally, that's not a small amount of code either, and every time there was a change, I had to manually duplicate the code each time.
Compared to the blog, where writing happens frequently, the Lab page, where changes are relatively rare, tended to easily fall into becoming an isolated silo.
Thinking about it, managing two pages that are this similar by splitting them across two separate repositories is, indeed, inconvenient.
I figured that merging them into a single repository entirely, securing overall project consistency, and reducing duplicate code could create a virtuous cycle for both the Lab and the blog page.
Since I'd worked on a monorepo-based project at a previous company, I had a rough grasp of the concept already, and since it's a current trend, I thought it would be a good opportunity to apply it.
There are a few tools available for using a monorepo, most notably turborepo, Lerna, and NX.
Among these, I decided to go with NX. Here's why.
- turborepo has a low barrier to entry. That's a strength, of course, but usually that also means the features it offers are relatively lacking.
- Since I'm doing this anyway, I might as well go with the harder option — a bit of stubbornness on my part. Trying something difficult means I get to say a bit more about it later.
- For a similar reason, going from Hard -> Easy is manageable, but going from Easy -> Hard is much harder than going from Nothing -> Hard.
- I've used Lerna at a previous company, but NX acquired it. In other words, its future is fairly uncertain.
- I've also used NX before at a previous company, and while it's difficult, there's that much more to explore because of it.
NX is very VSCode-friendly. Using VSCode, you can easily use NX's features and commands through a UI via the Nx Console plugin.
I made full use of this plugin as well to build out the project.
I split things into two broad categories: apps/ and packages/. apps/ holds code related to building the apps, while packages/ holds the components, API, and state management projects that implement those apps.
apps/ is the build target — every project under apps/ gets built and deployed.
Unlike turborepo or lerna, there's only a single package.json and node_modules at the very top-level root.
I didn't dig too deeply into the official docs, but given the lack of any specific mention otherwise, it seems to recommend that all projects share the same metadata and the same dependency management scheme.
Thinking about it, if each project had its own separate dependency boundary, you could end up with different versions of the same dependency, or the same dependency being installed separately multiple times.
Dependencies are affected by many versions, including Node.js and React. If dependency consistency weakens, you could end up in a case where project A can use the latest version of a specific dependency, while project B, due to other factors, is stuck using a lower version.
NX seems to place more emphasis on the benefits gained from maintaining dependency consistency. Fortunately, I too think the benefits gained from consistency are more efficient.
Of course, there are downsides too. In my case, most of my projects use version management based on the npm version command. Using the npm version command bumps the version according to major, minor, or patch, and creates a corresponding commit and tag.
Based on that, when a tag is created, it's detected to trigger the build pipeline.
The problem is that, due to the 1-repository-per-1-package.json policy, this approach makes individual version management impossible.
For now, I decided to just use a single version, so that both the Lab and the blog page get deployed together whenever the version is updated.
I could add extra flags to tags to distinguish build pipelines based on the flag, but since builds don't take that long anyway, periodically building with the latest code doesn't seem so bad either.
Something I discovered while running builds in GitHub Actions: Nx Cloud, which had been perfectly quiet locally, started causing a fuss in GitHub Actions.
It seems there's some code that forces a connection by default when building in certain environments.
Personally, this is exactly the kind of thing I hate most. Errors that only occur in an environment other than local, similar to a build server — those are a pain to debug too. You can disable the Nx Cloud connection during builds by adding the NX_NO_CLOUD=true command.
BASH
NX_NO_CLOUD=true pnpm nx build {name}
Using the command above, I was able to disable Nx Cloud and build.
🖼️ image
As with the characteristics of monorepos described earlier, depending on how you design it, a monorepo can capture the advantages of both monolith and multi-repo — or, conversely, end up with just the downsides of both. Which one you end up with is entirely up to the developer who set it up.
As with anything, a poorly designed monorepo can actually turn into poison. That's why I too needed a structural design befitting the goal of maximizing the benefits of a monorepo as much as possible.
🖼️ Rough structure
Diagramming the broad outline of the structure I had in mind gives roughly the picture above.
- Application - The application modules. This is where the services to be built live. Things like the Next.js app code go here.
- Business Module - Modules closely tied to each application. Can reference API and state management. Things like app design packs go here.
- Global Module - Common modules with no dependency on any specific application. Things like the design system and common code go here.
The API and State modules belong to the common modules; they're marked separately here to highlight their relationship with the business modules.
I split and connected packages according to each area of concern and role. The dependency graph looks like this.
🖼️ Nx Graph
Here's one of NX's strengths in action. You can check the project reference structure via nx graph. Projects connected by an arrow are in a dependency relationship with each other.
The project the arrow points from references the project the arrow points to.
In other words, global-ui-pack references the api, ui-pack, common, and state projects.
NX can broadly create two project types: application and library. An application is software provided to users, while a library is a collection of code that can be referenced by other projects.
Each project has the following role.
- root - The Next.js code for the intro/landing page
- blog - The Next.js code for the blog
- root-ui-pack - Design pack for the intro/landing page
- blog-ui-pack - Design pack for the blog
- global-ui-pack - The common design pack. Mainly consists of components that need both common design and common business logic.
- ui-pack - The design system. Cannot reference business logic, API, or state.
- api - The API module
- common - The common module
- markdown-kit - The markdown module. Contains general-purpose code that converts markdown used on both the intro page and the blog. Code reflecting the markdown characteristics specific to each service is implemented within that service's own business area.
- state - The state management module
This is the structure and set of roles it has.
Of course, even after defining reference rules for a project, there are limits to what can be enforced purely at the conceptual level, since the rules can always be broken due to human error at any time.
Even on a project where everything is done by me, I sometimes forget the rules. This phenomenon naturally becomes even more pronounced as project scale and headcount grow.
NX provides an ESLint plugin by default so that this kind of problem can be enforced through ESLint rules.
JSON
{ "name": "blog", "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/blog", "projectType": "application", "tags": [ "app:blog", "name:blog" ], "// targets": "to see all targets run: nx show project blog --web", "targets": {} }
Each project has a metadata file called project.json. You can specify whatever text you want as an array in that file's tags.
Assign tags to the project in whatever format you like. These tags later become the basis for defining each project's references in ESLint rules.
You can also assign the same tag to multiple projects to manage them as a group.
JSON
{ "rules": { "@nx/enforce-module-boundaries": [ "error", { "enforceBuildableLibDependency": true, "allow": [], "depConstraints": [ { "sourceTag": "name:blog", "onlyDependOnLibsWithTags": [ "lib:blog", "lib:shared", "lib:shared-ui" ] } ] } ] } }
You can define reference rules with the @nx/enforce-module-boundaries rule. A simple template for this is already defined in ./eslintrc.json at the project's top level.
You can define projects that can be referenced from anywhere in allow. For example, a module like common, which can be referenced from anywhere without issue, can beneficially be applied all at once via allow from a management perspective.
You can specify the reference rules you want as an array in depConstraints. Target the tag name you want in sourceTag, and specify the list of referenceable tags in onlyDependOnLibsWithTags. In other words, a project with the name:blog tag can only reference projects tagged lib:blog, lib:shared, or lib:shared-ui, and cannot reference any other project. This makes it possible to actually enforce the defined reference rules in practice.
The existing animations were built around framer-motion. Relying on this dependency led to both large and small performance drops, and since I couldn't touch the dependency's own code, troubleshooting was difficult. I particularly remember the home of the intro page having a very serious rendering issue. (I'd guess the dependency internally makes heavy use of useLayoutEffect.)
Because of this, this renewal focused on implementing several animations myself directly. I built tilt, fade, and viewport-triggered animation execution, among other animation elements, from scratch. I plan to cover these elements in detail in future posts.
This renewal was a meaningful one, incorporating a lot of insight gained from real-world work and improving performance.
In particular, developing the animations myself gave me an opportunity to really think about UX interaction. Plus, it gave me no shortage of topics for future posts...
With the boost from this renewal, I think I'll be able to focus on running the blog again for a while.
