DEV Community

Cover image for Shared Component Architecture in a Monorepo - 4 Case Studies
Mahmudun Nabi Kajal
Mahmudun Nabi Kajal

Posted on Originally published at mahmudunnabikajal.com

Shared Component Architecture in a Monorepo - 4 Case Studies

Shared component library behind PreBook, a WordPress appointment-booking plugin, solo across roughly a year - December 2023 to November 2024.

Solo, over about a year, one 44-component library shared by two apps - documented across four linked case studies.

44 Vue components, 3,639 lines, serve two independently built apps - an admin dashboard and a customer-facing booking frontend - through a shared @components alias, with no published npm package between them. src/admin and src/frontend are built and deployed separately, but both point at the same folder on disk: a change is live in both apps' next build, with nothing to publish and no version to pin.

That setup forced the same four questions repeatedly: what generalizes across two apps versus what stays local, how to keep state thin when two independent store trees touch the same data, how to make an input component survive real-world data instead of a single happy-path test, and how to keep three separate build targets pointed at one source of truth.

Four case studies, one library viewed from four angles: the API boundary, the state layer, the input components, and the codebase structure.

Here's what I learned from each.

1. Designing component APIs that scale across two apps

Two Select dropdowns mounted on the same admin settings page shared one document-level click-outside listener, so closing one could silently close the other.

Select.vue worked fine as a single implementation - until PreBook's payment settings screen mounted two instances on the same form (a currency selector and a country selector). Opening one silently closed the other.

The bug was an identity problem, not a structural one. The original clickOutside handler checked for the .select-wrapper class - it could tell a click landed inside "a" select wrapper, but not which one. With one instance that never mattered; with two, the first instance's listener fired on clicks meant for the second.

The fix: give each mount its own identity - a random uniqueKey at mount time, tagged as a data-select attribute, checked in the click-outside handler instead of the generic class. The component stayed one file. Only the assumption baked into its state - that only one would ever exist on a page - had to go.

Menu drew a different boundary. The admin sidebar started as one ButtonDropdown component doing both the menu's structure and each item's rendering. It got split into Menu.vue (a bare <div>/<slot> wrapper, shared) and MenuItem.vue (permission checks, badges, admin-only links, kept local to the admin app). Sharing MenuItem too would've meant shipping admin-only logic to an app that never renders it, or designing a speculative prop API nobody needed yet.

A third component, the media uploader, answered the question a third way: instead of forking into three single-purpose components for three interaction modes (one file, several files, several with one marked as thumbnail), it stayed one component with a prop that selects the mode.

Three components, three different answers - the shared library isn't one policy applied evenly, it's the same judgment call made fresh each time.

2. Centralizing state management across the same two apps

A BaseModel class centralizing get, all, update, delete, create, and bulk operations; Appointment and Staff subclasses each set only a route and a title, and every store calls through them instead of calling the API directly.

Admin and frontend each had their own Pinia store tree - 17 admin stores, 4 frontend stores, 21 total. Two independent store trees meant the same data (an appointment, a staff member) could get fetched and shaped differently in each app, and a backend response-shape change would need two separate fixes with no guarantee they stayed consistent.

I migrated all 21 stores from Pinia's Options form to the Composition form (defineStore('name', () => {...})), which keeps related state and behavior next to each other instead of split across separate state/actions/getters objects. But the bigger change was what the stores stopped doing afterward: most now import from a shared @model layer instead of making API calls themselves.

One BaseModel class defines get, all, create, update, delete, and bulk operations once. Each resource model just sets a route - Appointment and Staff are under 25 lines each, since the CRUD behavior is inherited. Routed through one model class, a backend shape change gets fixed once, and both apps pick it up on their next build.

3. Engineering a resilient input component for real-world data

Reformatting a string after inserting a digit can insert a new thousands separator ahead of the cursor; restoring the pre-keystroke cursor index then leaves the cursor one character short of where the user actually left it.

InputPrice.vue reformats the visible string on every keystroke to match one of four separator conventions PreBook's payment settings support - Comma-Dot, Dot-Comma, Space-Dot, Space-Comma. 1.234 isn't one number; it's two, depending on which convention is reading it. There's no way to resolve that from the characters alone, so the component has to be told which convention is active and stick to it via a separatorMap keyed off the store's price_separator setting.

The genuinely hard part wasn't the formatting - it was doing it live, per keystroke, without the cursor fighting the person typing. Reformatting on every keystroke can change the string's length ahead of the cursor: typing a digit that pushes the integer part from three digits to four also inserts a new thousands separator. Just restoring the pre-keystroke cursor index then leaves it one character short of where the user actually left it, visibly jumping backward every few keystrokes.

The fix: compare how many thousands separators exist before and after each reformat. If a new one appeared ahead of the cursor, nudge the cursor forward by one; otherwise leave it alone. It's a small correction, but it only shows up once you test with real multi-digit prices - not single test values, which is also why a separator bug resurfaced twice even after a later optimization pass. I'd been testing one locale convention at a time instead of running all four through each change.

4. Structuring the codebase to keep three build targets in sync

A staff profile card's markup before and after: a class list full of one-off bracket values like w-[50%], h-[130px], bg-[#EAECF0], and mt-[-75px], converted to a single named class .staff-profile-slider backed by real SCSS rules.

PreBook's frontend is actually three build targets: the admin Vue app, the customer-facing Vue app, and a Gutenberg block built separately with webpack, because that's what the WordPress block editor tooling expects. All three consume the same shared component library.

I kept that navigable with a consistent resolve.alias convention across both Vite configs - @components, @icons, @utils, and others resolve to the identical target folder in both apps, so import { Select } from '@components' means the same file no matter which app it's written in.

Tailwind purge runs per app rather than compiling one shared stylesheet: both tailwind.config.js files list ../components/*.vue directly in their content scan array, so each app's JIT build keeps only the classes it actually uses. A shared stylesheet would've shipped admin's CSS to frontend and vice versa.

It mostly worked - except in one spot. A frontend component's template had things like w-[50%], bg-[#EAECF0], and mt-[-75px] stacked up: arbitrary-value utility classes standing in for one-off pixel offsets and hex colors with no design token behind them. Technically fine, but unreadable as a class list, and a collision risk in a WordPress environment where generic class names can clash with other plugins' CSS. I moved that one component to a single semantic class per element, backed by real SCSS. Utility classes stayed the default everywhere else - it was specifically the arbitrary-value escape hatch, used repeatedly on one component, that wasn't worth it.


Four different problems, same underlying constraint: two apps sharing one library with no version boundary to catch a mistake before it shipped to both. The full write-ups, with more detail on each decision, are on my site: A Shared Component Architecture in a Monorepo →

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.