All posts

A future-proof, plug-and-play folder structure for your frontend apps

On this page

Every frontend framework ships with roughly the same default structure: all pages in one folder, all stores in another, all components in a third. It works great for a demo. Then the app grows, the team grows, and suddenly finding everything related to a single feature means hunting through five different directories.

I've spent years working in codebases organized this way, and I've watched the same problems appear every single time. In this post I'll show you the structure I use instead: a modular, plug-and-play architecture inspired by Domain-Driven Design. It has scaled well on every project where I've applied it.

Why the default structure breaks down

The "organize by file type" approach scatters related logic across the entire repository. Three problems show up as the app scales:

  • Constant context switching. The user profile page lives in pages/, its store in stores/, its components in components/, its API calls somewhere else entirely. Working on one feature means keeping five folder locations in your head.
  • Zombie code. Removing a feature is risky business. You delete the page, but was that store used anywhere else? What about those three components? Nobody is ever quite sure, so dead code accumulates forever.
  • Code duplication. When related files are disjointed, it's hard to know what already exists. Developers recreate logic that's already written because they simply couldn't find it.

None of these are developer discipline problems. They're structural problems, and no amount of code review fixes a structure that works against you.

The plug-and-play idea

The fix is strict feature encapsulation: everything related to a feature lives in one co-located directory, called a module. Need a new feature? Drop in a new module folder. Removing one? Delete the folder. No leftovers, no archaeology, no "is this still used somewhere?" anxiety.

Here's the full structure:

src/
├── api/         # Typed backend integrations (Zod schemas, API calls)
├── ui/          # Design system bridge (base UI wrapper components)
├── shared/      # Globally reusable code
│   ├── cmp/       # reusable components
│   ├── use/       # reusable composables
│   ├── utils/     # reusable utilities
│   └── assets/    # reusable assets
├── modules/     # Feature-based, self-contained business logic
│   └── user/
│       ├── __tests__/
│       ├── components/
│       ├── stores/
│       ├── api.ts
│       ├── constants.ts
│       ├── router.ts
│       └── User.vue
├── css/         # Global styles and design tokens
├── plugins/     # Third-party package initialization (Pinia, Router, etc.)
├── stores/      # Global state management
├── types/       # Global TypeScript definitions
└── constants/   # Global constants

Let's go through the pieces that matter most.

The modules/ directory

This is the heart of the application. Each folder inside modules/ is one feature, fully self-contained: its components, stores, tests, styles, constants, and routes all live together.

Only two files are actually required to bootstrap a module:

  • router.ts: the routing needs to know the module exists.
  • Page.vue: a route needs a component to render.

Everything else is optional. Inside a module, the team has complete freedom to organize files however best serves that specific feature. The macro-architecture is strict; the micro-architecture is yours.

What you get in return:

  • Discoverability. Debugging a feature? Everything is in one folder. Following the flow of the app becomes trivial.
  • Clean deletions. Deprecating a feature means deleting a folder. It's structurally hard to leave dead code behind.
  • Aggressive lazy-loading. Since a module co-locates its logic, images, and SVGs, you can lazy-load the entire thing as one chunk and keep the initial bundle small.
  • An isolated playground. You can maintain a barebones shell app with only the common functionality, plug a single module into it, and develop that feature in total isolation. When it's done, plug it back into the main app.

One rule keeps this whole system honest: a module never imports from another module. If two modules need the same thing, that thing gets extracted to shared/. The moment you allow cross-module imports, you're back to spaghetti with extra steps.

The api/ directory: your backend bridge

All reusable API calls live here, fully typed and validated at runtime with Zod. TypeScript types vanish at runtime, so without validation the backend is one deploy away from silently breaking your UI. Zod validation ensures the data you receive is actually shaped like the data you expected.

The best approach? Backend and frontend teams establish a single source of truth by adopting a design-first approach with OpenAPI. Whenever the backend updates an endpoint, response, or query parameter, the frontend can instantly regenerate fully-typed API clients with a single command. This completely eliminates the guesswork of integration, protects us from silent breaking changes, and ensures that data structures match perfectly across the stack.

The ui/ directory: your design system bridge

Just as api/ bridges the backend, ui/ is the single bridge to your design system. Every base element from the design (buttons, inputs, modals, typography) is built as a component here.

This connects to a rule of mine: never use third-party UI library components directly in business logic. The wrappers live in ui/. When the underlying library changes, you touch one folder.

There's a longer-term payoff here too. ui/ maps to the design system, api/ maps to the backend. Because both are strictly isolated, either can be extracted into a standalone package later (say the company suddenly needs to share UI or API logic with another app) without breaking changes rippling through the codebase.

The shared/ directory

Cross-cutting code that multiple modules need: common components, composables, utilities, assets. Don't over-invest here upfront. Let duplication appear inside modules first, and extract to shared/ when a real pattern emerges. Premature extraction creates abstractions nobody asked for.

Rules that keep the architecture healthy

Structure alone isn't enough. A few rules I enforce on every project:

  • No magic strings. const moduleName = 'users' instead of scattering 'users' around the codebase.
  • Name-based routing only. Navigate via route names, never hardcoded URL paths. Paths change; names survive. Nested route names should reflect their hierarchy (user.profile, not profile), which makes debugging navigation issues much easier.
  • Every API request and response is typed. Backend data is the fuel of the frontend. Untyped fuel blows up engines.
  • Every bug fix ships with a test. The test proves the bug is dead and guarantees it never quietly returns.
  • Document every ui/ component. The design system bridge only works if people can discover what already exists.

What about monorepos and micro-frontends?

Since folder structure inevitably leads to the repository question, here is my short version.

Micro-frontends: almost certainly not. Splitting a frontend across multiple isolated repositories fractures the developer experience, breeds duplication, and makes enforcing shared standards nearly impossible. Unless you're a huge enterprise with deeply siloed teams and dedicated DevOps support, the overhead will eat you alive.

Monorepos: sometimes. If you're housing a frontend, a backend, and a shared component library together, workspace tooling solves real problems: atomic commits across the stack and no dependency hell.

A single repository: usually the answer. With the modular architecture above, a single repo scales remarkably far. If a part of it (like your UI library) outgrows the app, extract it into a package at that point. Start simple and add complexity only when the pain is real.

Wrapping up

Thank you all for reading. Please share this post with your friends and colleagues if you found it useful.

If you haven't already, follow me on Linkedin