Component Architecture

Components live in three layers. The split exists so that vendored shadcn code stays generic and replaceable while your own reusable pieces have a clear home.

The three layers

Primitives sit in shared/components/ui/. These are shadcn components, generated and owned by shadcn. Treat them like vendored code you do not hand-edit. Each one follows shadcn's own structure, a folder with the .vue file and an index.ts holding the cva variants and a barrel export.

Composites sit in shared/components/common/. These are your reusable components built out of one or more primitives. The classic example is a button with a baked-in icon. The composition contract lives here so the primitive underneath never has to know about it.

Feature components are app-level, in app/src/features/ or admin/src/features/. These are tied to a single screen or domain. Not reusable, not shared, no reason to live anywhere central. The app's own src/features/ is that layer, anything there is a feature component simply because it is app-local and not in shared/. Group them by domain subfolder from the start (app/src/features/bookmarks/) rather than flat files. This is a reference codebase, so it is worth establishing the feature-based structure early instead of flattening now and migrating later.

Why primitives stay untouched

The temptation is to open ui/Button.vue and add the icon flag or the extra variant right there. Don't. The moment you edit a primitive you have coupled vendored code to your app, and the next shadcn re-sync either clobbers your change or forces a manual merge. Keeping ui/ generic means you can pull shadcn updates cleanly forever.

So anything app-specific, an icon convention, a business variant, a fixed layout, goes in a common/ wrapper instead. The primitive stays the dumb, generic building block shadcn shipped.

One exception worth calling out. If shadcn already exposes the variation you want through a prop or a cva variant, just use it. A different size or color is not a reason to wrap anything.

When to wrap and when not to

Wrapping has a cost, it is another file and another layer of indirection. The rule of thumb:

  • Built on a primitive and reused across the app, wrap it in common/.
  • A one-off <Button><Icon/></Button> used in a spot or two, inline it. Promote to a composite later if the same composition keeps showing up or starts carrying real logic.
  • Used once and specific to a domain, drop it in the app's feature folder.

What decides the folder is reuse, not what a component is built from or how large it gets. Composites stack freely, a common/ component can be built out of primitives and other common/ components with no depth limit. So a large widget assembled from several primitives and composites is still a common/ component if both apps use it, or an app-level feature component if only one app does. "Built from X" never moves it between layers, only "who uses it" does.

File structure

Primitives keep shadcn's folder-plus-index.ts layout because that is where the variants and barrel export live. Leave it as generated.

Composites default to a single flat .vue file. No folder, no index.ts. That ceremony only pays off once a component grows sub-components or its own variants worth co-locating, and at that point you promote it to a folder. Adding an index.ts for a single-file component is just noise.

Feature components group by domain subfolder, so the folder is the feature (app/src/components/bookmarks/) and a single .vue per component inside it is fine. The same promote-to-its-own-folder rule applies once one of them grows sub-parts.

See also Project Structure for where these directories sit in the wider app.

© Websanova 2026 About Privacy