How I Structured a Modular Monorepo for a Business Suite — Apps, Packages, and Real Boundaries
Most monorepo guides stop at folder structure. The real work is figuring out what belongs in apps versus packages, why a config-driven inventory beats a hardcoded one, and where the boundaries actually need to exist so you don't end up with spaghetti across your workspace.
The first version of this inventory system had everything in one folder. It worked. It also meant the Excel import logic, the PDF export, and the item creation form all lived in the same feature directory because there was nowhere else obvious to put them. When I started adding CRM screens, I was copy-pasting table setup code between features inside the same app. That’s when the structure stopped being a style preference and became an actual problem.
I’d been building toward a business suite — inventory, CRM, HCM, procurement — and I’d been treating it like one app that would grow. It’s not. These are different products that share infrastructure. That distinction changes everything about how you organize code.
The Core Problem With “One App That Grows”
When you treat a multi-product suite as a single growing app, you end up with a module system instead of an app system. You get src/modules/inventory, src/modules/crm, and at some point you notice the inventory module is importing from the CRM module because someone needed a customer reference on a purchase order, and now untangling that is a full day of work.
The real issue isn’t the imports. It’s that module boundaries inside a single app are only enforced by convention. Nothing stops you from crossing them. And in practice, under deadline pressure, you will.
A monorepo with actual separate apps enforces the boundary at the workspace level. apps/inventory and apps/crm can’t accidentally import from each other unless you explicitly add the dependency. The constraint is structural, not just a team agreement written in a README that nobody reads six months later.
There was also a practical reason. Inventory needs to be deployed independently. If a CRM feature breaks, inventory should still work. That’s hard to guarantee when they share a deployment artifact.
What Goes in Apps vs Packages
This took a few iterations to get right. The rule I landed on:
Apps contain deployable products. Everything a user opens in a browser. Pages, routes, app shell, feature orchestration, navigation.
Packages contain reusable capabilities. Anything two different apps would otherwise duplicate. Database schema, auth, permissions, shared UI, import pipelines, export pipelines, table rendering logic.
The mistake I made early was putting too much business logic inside the app. The inventory feature had a 600-line MaterialsWorkspace.tsx that handled table columns, Excel parsing, PDF generation, stock adjustment state, and the drawer for editing items — all in one component. Technically it worked. It was also impossible to reuse any of it in a procurement feature that needed the same table behavior, or a CRM contact list that needed the same filter system.
The right split looked like this:
apps/
inventory/ → item pages, stock screens, import wizard screens, settings screens
crm/ → contact pages, pipeline screens, deal stages
packages/
db/ → Drizzle schema for all domains, migrations, query helpers
auth/ → Supabase session abstraction
permissions/ → RBAC engine, role resolution
ui/ → shadcn/ui components + design tokens
table-engine/ → TanStack table setup, pagination, search, column definitions API
import-engine/ → Excel/CSV parse, column mapping, validation, preview, commit
export-engine/ → PDF templates, CSV export, print layout
inventory-core/ → inventory business rules, stock calculations
inventory-config/ → dynamic field definitions, views, form layoutsEach package has one job. table-engine knows nothing about inventory. inventory-config knows nothing about how the table renders. The app wires them together.
The Table Engine Problem
TanStack Table is excellent. It’s also verbose. Every time I set up a new table in a feature, I was writing the same 80 lines — creating the table instance, wiring pagination state, handling sorting, controlling column visibility, attaching a search input. Four features in, it became clear this was a package, not feature-level code.
@erp/table-engine exports a single DataTable component and a useDataTable hook. The hook owns all the TanStack state — sorting, pagination, column visibility, row selection, server-side vs client-side mode. The component renders the table, toolbar, pagination controls, loading skeleton, and empty state.
The contract between a feature and the table engine is just the column definitions:
import { DataTable } from '@erp/table-engine'
import { buildInventoryColumns } from '../lib/build-inventory-columns'
function InventoryItemTable({ items, isLoading, onEdit, onAdjust }) {
const columns = buildInventoryColumns(onEdit, onAdjust)
return (
<DataTable
tableId="inventory-items"
data={items}
columns={columns}
isLoading={isLoading}
searchable
defaultPageSize={20}
/>
)
}The column builder lives in apps/inventory/src/features/items/lib/build-inventory-columns.tsx. That file is the only place in the inventory app that knows what columns exist, what cells render, and what the row actions do. The table engine doesn’t know about inventory. The inventory feature doesn’t know about pagination logic.
This matters because CRM contact tables and HCM employee tables can use the exact same DataTable component with completely different column definitions. The shared behavior — sorting, search, column toggle, pagination — is never re-implemented.
Config-Driven Inventory vs Hardcoded Inventory
The fabrication use case — metal sheets, raw materials, grades, finishes, thicknesses — is specific. But if you hardcode those fields into your inventory schema and UI, you’ve built a fabrication inventory system, not an inventory system.
The insight was that what makes fabrication different from food distribution or a clothing retailer isn’t the inventory logic — it’s the metadata. Stock goes up and down the same way. Reorder points work the same way. Import and export pipelines work the same way. What differs is which fields matter and what they’re called.
So packages/inventory-config holds the definition layer:
inventory_item_types → "Raw Material", "Finished Good", "Merchandise"
inventory_field_definitions → name, type (text/number/select/date), required, order
inventory_field_options → the values for select-type fields
inventory_form_layouts → which fields appear in the create/edit form
inventory_table_views → which columns show in the list, and in what order
inventory_import_templates → expected columns when importing via ExcelA fabrication customer gets item types like "Sheet Metal" and "Pipe" with fields like "Grade", "Thickness (mm)", "Finish", and "Alloy". A clothing retailer gets "Apparel" with "Size", "Color", "Material". Both run on identical application code. The tenant’s config drives what they see.
The alternative — building separate product variants per industry — doesn’t scale. You end up maintaining divergent codebases for problems that are 90% identical. Config-driven flexibility is more work upfront and significantly less work over time.
The File Triad Convention
Every domain module follows the same three-file structure:
items.server.ts → DB queries and mutations, never imported by client code
items.schema.ts → Zod schemas for all inputs
items.types.ts → TypeScript types, status enums, interfacesThe server.ts file has import 'server-only' at the top. TanStack Start enforces the server/client boundary, but this makes the intent explicit and catches accidental imports early. Every function in it scopes queries by organizationId — not because we always remember to, but because the convention makes the absence obvious in code review.
The schema file is the single source of truth for what valid input looks like. The same schema validates API requests via Hono and form submissions via React Hook Form. There’s no “client-side validation that roughly matches server-side validation” — there’s one schema, used in both places.
This pattern makes adding a new field to an entity straightforward. You touch three files: add it to the schema, add the type, add the DB column. The form and API validate it automatically once it’s in the schema.
Multi-Tenancy From the Start
The hardest thing to retrofit into a SaaS system is multi-tenancy. If you build a single-tenant app and then try to add organization isolation six months later, you’re touching every query in the codebase.
Every table that holds customer data has an organization_id column. Every server function takes a context: { organizationId: string; userId: string } parameter. Every Drizzle query includes a where(eq(table.organizationId, context.organizationId)) clause.
This isn’t optional. It’s enforced by convention and checked in code review. The pattern is tedious to set up, painless to maintain, and catastrophic to skip.
The auth layer derives organizationId from the server session — it’s never trusted from the client request. A user claiming to be in a different organization gets nothing.
The Separation That Actually Matters
The split that took longest to get right wasn’t apps vs packages. It was separating screens from features from packages.
apps/inventory/src/features/items/screens/InventoryItemsScreen.tsx is a thin orchestrator. It calls a hook, passes data to components, handles navigation. No business logic, no direct DB access, no PDF generation.
apps/inventory/src/features/items/components/InventoryItemTable.tsx renders the table with the right columns and passes events back up. It doesn’t know how items are fetched or what happens when you archive one.
apps/inventory/src/features/items/hooks/useInventoryItems.ts wraps a TanStack Query call. It doesn’t know how the data renders.
apps/inventory/src/features/items/items.server.ts knows the database. It doesn’t know anything about React.
This sounds obvious written out. In practice, it requires active discipline because it’s faster to reach into the server layer from a component when you’re in a hurry. The architecture is only as good as the discipline to enforce it during implementation, and the code review that catches violations before they compound.
What the Monorepo Actually Buys
Turborepo handles the build graph. When packages/db changes, it knows which apps to rebuild. When packages/ui changes, all consuming apps get the update. When apps/inventory changes, apps/crm doesn’t rebuild.
The pnpm workspace means @erp/ui, @erp/table-engine, @erp/db — all internal packages — are just workspace references. No publishing step, no version management overhead for internal packages, no npm dependency drift between packages in the same repo.
Running pnpm ui:add dialog from the repo root adds the shadcn dialog component to packages/ui and makes it available to every app immediately. One command, zero coordination.
The gen:feature script scaffolds a full feature triad — types, schema, server, hook, screen, index barrel — in about two seconds. Starting a new feature goes from “figure out what files to create” to “run the generator and fill in the business logic.”
What I’d Change
The package boundaries were right. The timing wasn’t.
packages/import-engine came after I’d written import logic twice — once for inventory and once when I started on procurement. The second time I wrote it, I already knew it was going to be a package. I should have known after the first time.
Recognizing the pattern earlier saves you a refactor. If you’re writing something that you can imagine another module needing, it’s probably a package already. The bar for extracting to a package should be lower than it feels, especially for anything involving file I/O, PDF generation, or complex state machines.
The other thing: the design token system in packages/ui needed to exist before the apps were built, not after. Retrofitting CSS variables onto components that used hardcoded Tailwind colors is tedious. The token file should be the first thing in the UI package.
Found this useful?
Share it with someone who'd appreciate it.
https://wardvisual.com/blogs/modular-monorepo-architecture-inventory-saas
References

@wardvisual · 🇵🇭 Dasmarinas City, Cavite PH
Full-stack engineer. Business systems, database optimization, and operations software.