Asia/Dhaka
Case Studies

Designing Component APIs That Scale Across Two Applications

image
PreBook's admin and frontend apps each compile the same src/components source into their own Vite bundle through an identical @components alias, with no npm package or version boundary between them.
PreBook has two Vue apps - admin and customer-facing frontend - sharing one component library through an identical @components alias, with no npm package or version boundary between them. Building that library meant asking, component by component: what actually generalizes across both apps, and what should stay local?
Select generalized fully once its state model changed to be per-instance; Menu generalized only as a structural wrapper, with per-item logic staying app-local; MediaUploader folded three interaction modes into one component API.
ProblemTwo apps share one component library with no version boundary - a component's public API has to work for both apps' actual needs, not a guess at what might be needed
What I didDrew the shared/local line per component: Select generalized fully after fixing its state model, Menu generalized only as a structural wrapper, MediaUploader unified three interaction modes behind one prop API
Why it matteredA boundary drawn too far in either direction either breaks on the app that didn't need the extra logic, or duplicates code the library was supposed to prevent
TeamSolo
Part ofA Shared Component Architecture for a WordPress Booking Plugin →
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.
  • src/admin and src/frontend are built and deployed separately, but both alias @components to the same folder - a change reaches both apps on their next build, with no publish step and no version to pin
  • That also means nothing catches a component whose API only ever worked for the app it was first written against
  • Select.vue surfaced this: used across both apps, working fine as a single implementation - until a page mounted two instances at once. PreBook's payment settings screen has a currency selector and a country selector on the same form, and opening one would close the other
Before: a single document click-outside handler checked only for the .select-wrapper class. After: each Select mount gets a random uniqueKey tagged as a data attribute, and the click-outside check compares against that key.
  • The original clickOutside handler checked for the .select-wrapper class - a structural check, not an identity check. It couldn't tell two Select mounts apart, only that a click landed inside "a" select wrapper
  • With one instance, that never mattered. With two, the first instance's listener fired on a click meant for the second and closed the wrong dropdown
  • 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, unchanged in shape. Only the assumption baked into its state - that only one would ever exist on a page - had to go
ButtonDropdown, one component doing both jobs, was deleted and split into Menu.vue (a bare slot wrapper, shared) and MenuItem.vue (access-control checks, badges, admin-only links, kept local to src/admin/views/layout/).
  • The admin sidebar started as one ButtonDropdown component doing both the menu's structure and each item's rendering. It got split into Menu and MenuItem - only one moved to the shared library
  • src/components/Menu.vue is a bare <div>/<slot> wrapper, no props, no logic
  • MenuItem.vue stayed in the admin app - it handles permission checks, badges, and admin-only links that frontend has no use for and no equivalent sidebar to put them in
  • Sharing MenuItem too would mean shipping admin-only logic to an app that never renders it, or designing a speculative prop API nobody needed yet. Splitting at the real seam - structure shared, per-item logic local - avoided both, and still saved the duplication that mattered
Select fully shared once its state model was per-instance, Menu only partly shared as a structural wrapper, and MediaUploader shared by folding three selection modes into one prop-driven API instead of three separate components.
  • MediaUploaderButton/MediaUploaderModal answers the same question a third way: it started as a single-purpose uploader and grew to cover three interactions (one file, several files, several with one marked as thumbnail)
  • Instead of forking into three components, it stayed one, with a prop that selects the mode. Both apps use it without needing to know the other exists
Three components, three different answers - the shared library isn't one policy applied evenly, it's the same judgment call made fresh each time.
Four judgment calls behind the shared-library boundary: why MenuItem stayed local, why Select needed per-instance state, why the library skipped an npm package, and why MediaUploader's modes were folded into one API.
Why not put MenuItem in the shared library too? Its checks, badges, and links are all admin-specific, and frontend has no equivalent sidebar for them. I'd rather draw the boundary at the real seam than guess at a shape nobody needs yet. Why did Select need per-instance state instead of a single shared instance? Two dropdowns can legitimately sit open-adjacent on the same page. State that assumes only one mount exists breaks the moment a second one shows up - only the identity model needed to change. Why keep the library in src/components instead of publishing it as an npm package? Both apps already build from the same source through identical aliases, so a package would just add a version to keep in sync for no benefit. Why fold MediaUploader's three modes into one component instead of three? Three near-identical components drift from each other over time as each gets patched independently. One component with a mode prop can't.
Part of the A Shared Component Architecture for a WordPress Booking Plugin series →

Related case studies

A Shared Component Architecture for a WordPress Booking Plugin - 4 Case Studies

A Shared Component Architecture for a WordPress Booking Plugin - 4 Case Studies

A solo, year-long build of a 44-component Vue library shared between two independently built apps inside a WordPress booking plugin - four linked case studies covering API boundaries, state, resilient input handling, and codebase structure.
Structuring a Large Frontend Codebase for Maintainability at Scale

Structuring a Large Frontend Codebase for Maintainability at Scale

Two independently-built Vue apps and a webpack-built Gutenberg block shared one component library through a consistent alias system and a per-app Tailwind purge - except in the one place plain CSS won.