The Problem

Most side projects start small: one API, one frontend, shared folder. Then you add an admin panel. Then a storefront. Then shared UI components. Then a config package. Then a utils package. Before you know it, you're managing six inter-dependent projects with manual linking, version drift, and builds that take forever.

I built the modular e-commerce platform specifically to confront this scaling problem. It's a Turborepo monorepo with a Fastify backend, an Astro customer storefront (with React islands for interactivity), a React + Vite admin panel, and three shared packages (@modular/ui, @modular/utils, @modular/config).

Why Turborepo

I evaluated Nx and Lerna before settling on Turborepo. The deciding factors:

  • Zero config to start - turbo.json defines the task pipeline; Turborepo infers the dependency graph from package.json workspaces.
  • Remote caching - unchanged packages aren't rebuilt across machines. When I push to CI, only the packages I actually changed get rebuilt.
  • Parallel execution - turbo run build builds shared packages first (respecting the dependency graph), then builds all apps in parallel.

In practice, a full rebuild that used to take 4+ minutes dropped to under 60 seconds after the first run populated the cache.

Fastify Over Express

The backend uses Fastify instead of Express. The performance difference is real (2-3x faster in benchmarks), but what sold me was Fastify's first-class schema validation. Every route defines its request and response schemas with Zod, and Fastify validates them automatically - no middleware, no manual checks.

Combined with TypeScript, this means that by the time a request reaches my handler, I have compile-time type safety and runtime validation. Malformed requests are rejected at the framework level with proper 400 responses.

Route Auto-Registration

This is the pattern I'm most proud of in this project. Instead of importing every route file manually in the main app:

Every module follows a strict folder structure: src/modules/<name>/route/<name>.route.ts. An autoRegisterRoutes.ts utility scans the modules/ directory at startup, dynamically imports every file matching the *.route.ts pattern, and registers it with Fastify.

The benefit: I can add a new module (say, "reviews") by creating the standard folder structure and the route file. No imports to update, no registration to remember. The system discovers and mounts it automatically.

The cost: implicit registration can make it harder to trace where a route is defined. I mitigated this with a startup log that prints every registered route with its module name.

Dynamic RBAC: Permissions Without Code Changes

Early in the project I had the typical hard-coded guard: if (user.role !== 'admin') throw Forbidden(). This works for two roles. It falls apart when you have admin, manager, staff, and viewer - each needing different access to different entities.

The dynamic RBAC system works like this:

  1. Entity registration - when a new Prisma model is defined, a migration script generates CRUD permissions for it: product:create, product:read, product:update, product:delete.
  2. Role-permission mapping - stored in the database, not in code. An admin gets all permissions. A staff member gets product:read and order:read but not product:delete.
  3. Middleware enforcement - a Fastify preHandler decorator checks if the authenticated user's role has the required permission for the current route.

Adding a new entity (e.g., "reviews") automatically creates its permissions. Granting those permissions to a role is a database operation - no deployment needed. This scales with the product without requiring engineering effort for each new resource.

Shared UI Components

The @modular/ui package exports design-system components - buttons, cards, inputs, modals - consumed by both the storefront and admin panel. When I update a button variant or fix a spacing issue, both apps pick up the change on the next build.

The key architectural decision: the UI package doesn't import from Tailwind directly. Instead, it exports unstyled or minimally styled components with data attributes, and each consuming app applies its own theme. The storefront uses a warm, customer-facing palette; the admin uses a dense, data-focused layout. Same components, different skins.

Astro for the Storefront

The customer-facing storefront is built with Astro, which renders static HTML by default and hydrates React "islands" only where interactivity is needed. Product listing pages are fully static (fast, cacheable, SEO-friendly). The cart and checkout are React islands that hydrate on load.

This hybrid approach means the storefront scores near-perfect Lighthouse numbers while still supporting complex client-side interactions where needed.

Prisma: The Shared Schema

The Fastify backend uses Prisma for all database access. The schema defines Products, Categories (hierarchical), Users, Orders, Cart, and the RBAC tables (Roles, Permissions, RolePermissions).

What I especially appreciate about Prisma in a monorepo context: the generated client is typed, the migrations are version-controlled, and the seed script can bootstrap a development environment in seconds.

Lessons Learned

  1. Convention beats configuration at scale. Auto-registration and standard folder structures reduce the cognitive load of navigating a large codebase. New developers can find things by pattern rather than by grep.
  2. Dynamic RBAC is worth the upfront investment. It eliminates an entire class of "add permission check for new feature" PRs.
  3. Turborepo's cache is transformative. The first time CI ran in 45 seconds instead of 4 minutes, I understood why monorepo tooling matters.
  4. Astro's island architecture is underrated. For content-heavy e-commerce, shipping zero JS by default and hydrating only interactive pieces is the right trade-off.

Explore the project at gitlab.com/ionutn0301/modular-e-commerce

Back to Insights