components/ vs views/: Where Does This New File Go?
A code review comment that shows up in every growing Vue codebase: "This doesn't belong in components/ — it's a page." The folders a create-vue project ships with encode one distinction that keeps large apps navigable, and it is worth stating crisply because it drives routing, testing, and reuse decisions downstream.
| Directory | Holds | Knows about the router? | Reused across routes? |
|---|---|---|---|
src/views/ |
One file per route — page-level composition | Usually yes (pagination links, route params) | No — that is the point |
src/components/ |
Reusable pieces: forms, cards, tables, dialogs | No — receives data via props | Yes |
src/composables/ |
useXxx.ts functions returning shared logic |
Sometimes (useRoute inside) |
Yes |
src/stores/ |
Pinia stores — app-wide state | No | Yes |
src/assets/ |
Images, fonts, global CSS (build-processed) | — | — |
src/router/ |
Route table, guards, lazy imports | — | — |
The test for a component is one sentence: could I render this inside two different routes without editing it? If yes, it is a component and it must not import the router — it receives values through props and reports actions through emits. A CheckoutForm.vue that calls useRouter().push('/success') is welded to one product flow; the route-level CheckoutView.vue owns the navigation and passes @submit="goToSuccess" down. That split is what lets you mount the form in a Storybook-style preview or a unit test with no router installed at all.
Views are allowed to be "dumb compositions": import three or four components, lay them out, own the route-specific glue. A 40-line view is a healthy sign, not an under-implementation. When a view balloons past a few hundred lines, the extraction target is usually a component (visual chunk) or a composable (logic chunk) — never a deeper nested view, because views map one-to-one to URLs and users bookmark URLs.
Composables get their own folder because of a naming contract ESLint and readers both rely on: a file in composables/ exports a function whose name starts with use (useCart.ts → useCart). It may call other composables, may use lifecycle hooks (that only works while a component's setup is active), and returns refs or plain functions. Mixing that naming with non-composable helpers (formatDate.ts) in one folder is tolerated, but keeping utils/ separate makes the call-site rule obvious: use* inside setup, plain functions anywhere.
Past a few dozen files, flat folders stop scaling and teams move to feature folders: src/checkout/{components,composables,stores} — everything one business flow needs, co-located. The discipline that makes feature folders work is the anti-reach rule: no file may import across features through a relative chain (../../pricing/PriceTable.vue). Cross-feature imports either go through a published surface (@/components/index.ts barrel for genuinely shared pieces) or the piece you are reaching for is shared and belongs in the global folders anyway. Deep relative imports are the structural smell — they mean the dependency graph and the folder graph disagree.
A practical convention set that most Vue teams converge on, and that linters (eslint-plugin-vue with vue/multi-word-component-names and a naming rule) can enforce:
- Components:
PascalCase.vue, one per file, multi-word names (UserCard, notCard—Cardcollides with future native elements and reads ambiguously in devtools). - Composables:
camelCasefiles withuse-prefixed exports. - Stores:
stores/counter.tsexportinguseCounterStore. - Route components live in
views/and are imported by name in the router table.
In assets/ vs public/ you will place the images and styles these components reference — a distinction with a real build-time cost.
assets/ vs public/: Two Paths a Static File Can Take to the Browser
Both directories end up in the built app, and putting a file in the wrong one produces a symptom that looks like a bug: a missing image in production that worked in dev. The difference is who processes the file.
src/assets/ goes through Vite. A file there is only in the bundle if something imports it, and once imported it is treated like any other module dependency:
<script setup lang="ts">
import logoUrl from '@/assets/logo.svg' // URL string after build
</script>
<template>
<img :src="logoUrl" alt="logo" />
</template>
At build time the file is emitted to dist/assets/logo-<hash>.svg, and the import resolves to that hashed URL. Hashing is the entire benefit: the filename encodes the content, so the asset can be served with Cache-Control: immutable and every deploy gets fresh files without cache-busting query strings. @vitejs/plugin-vue also rewrites static template references (<img src="../assets/logo.svg">) and CSS url() values automatically, which is why the dev experience works with no ceremony — until a URL is assembled at runtime (:src="'../assets/' + name + '.svg'"), which the compiler cannot see and which therefore 404s in production. When the URL is dynamic, do the import and bind the resolved value.
One threshold is worth memorizing: files under assetsInlineLimit (default 4096 bytes) are not emitted at all — they are inlined as base64 data: URIs in the JavaScript, trading a larger bundle for one fewer request. That is why a tiny icon "disappears" from dist/assets in a build you expected to inspect.
public/ bypasses Vite entirely. Everything in it is copied verbatim to the root of dist/, name and content untouched: no hashing, no minification, no tree-shaking, no inlining. You reference those files by root-relative URL:
<link rel="icon" href="/favicon.svg" />
<img src="/robots-badge.png" />
The rule for what belongs there: files that are not imported by your code but are addressed by URL — favicon.ico, robots.txt, site.webmanifest — plus assets that a third party fetches at a stable address. The cost of that stability is caching: because /logo.png never changes name, browsers and CDNs will hold the old bytes aggressively; ship a v2 under a new name rather than replacing the file in place.
The production-subpath trap closes the topic. A hardcoded /logo.png assumes the app is served at the domain root; deploy it under https://example.com/app/ and every such URL resolves to the wrong path. Vite exposes the configured base as import.meta.env.BASE_URL, so runtime references to public files should be composed:
const badgeUrl = import.meta.env.BASE_URL + 'robots-badge.png'
while bundled assets need nothing — the hashed URL Vite generates already includes base.
Decision table:
| Question | Answer |
|---|---|
| Is the file imported or referenced by your source? | src/assets/ |
| Does it need hashing (cache correctness)? | src/assets/ |
| Under 4 KB and used inline? | src/assets/ — it will inline as base64 |
| Addressed by URL, never imported (favicon, manifest)? | public/ |
| Must keep an exact, stable filename forever? | public/ |
Next: Vue Devtools in the Browser — where the router, stores, and components you just organized become inspectable at runtime. One supporting tool first: the @ alias that made those imports readable. That is configured in vite.config.ts and tsconfig, and the two must agree.
Killing Relative-Chain Imports: The @ Alias in vite.config and tsconfig
Before the alias, deep components import each other with chains like ../../components/UserCard.vue — brittle, noisy, and expensive to refactor: move one folder and every import in the subtree breaks. The @ alias fixes it by meaning src/, everywhere, and it takes exactly two declarations to set up — one for the bundler, one for the type system.
Vite resolves it at build time. In vite.config.ts:
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
fileURLToPath(new URL(...)) instead of a bare string is the portable way to write it — __dirname does not exist in ESM-config files, and a relative string would resolve against the process's working directory, which breaks the moment a tool runs Vite from a subfolder.
TypeScript resolves it for the editor and vue-tsc. In tsconfig.app.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
Both files are required, and the failure modes are asymmetric in a way that confuses people: set only the Vite alias and the app still runs (the bundler is what loads modules) while the editor paints every @/ import red and npm run type-check fails. Set only the tsconfig paths and the dev server 404s on the first import. When these two disagree you get a project that is green in one place and broken in the other — the tell is exactly that asymmetry, and the fix is to remember the alias is consumed by two independent resolvers.
What the alias buys beyond shorter lines: renames become filesystem moves without edit-the-imports churn, lint rules can forbid relative imports above a depth (import/no-relative-parent-imports), and imports read as application paths (@/stores/cart) instead of coordinates. Teams that outgrow one alias add a second — @components, or @@-style public references — but one well-chosen root alias covers most of it.
A companion that removes whole categories of imports is import.meta.glob, which turns a directory listing into a map of lazy imports — the standard way to register routes without a hand-maintained list:
// router/index.ts
const views = import.meta.glob('../views/*.vue') // dynamic imports, code-split
routes: Object.entries(views).map(([path, load]) => ({
path: '/' + path.replace('../views/', '').replace('.vue', ''),
component: load // () => Promise<Component>
}))
Each entry stays a dynamic import(), so Rollup emits one chunk per view and routes load on navigation. Add true as the second argument only when you deliberately want eager, statically bundled imports.
The habit to leave with: treat the folder graph and the import graph as one thing. If two modules are connected, their folders should show it; if a chain of dots appears in an import path, either the file is in the wrong place or the shared piece deserves a stable @/ home. With structure settled, Vue Devtools in the Browser shows you the running result of all these decisions.
One refinement for teams that live in the editor: teach the linter the policy so reviews don't have to. An import/no-relative-parent-imports rule fails any import that climbs above one level, and no-internal-modules can forbid reaching into another feature's internals — together they make @/ the only sanctioned highway between areas. Pair them with a path-alias-aware resolver in eslint-plugin-import so it reads your tsconfig paths, and a misplaced file becomes a red squiggle at save time instead of a review comment three days later.
Quiz
1. A project's favicon must keep the exact URL /favicon.svg and is never imported by any component. Where does it live and how is it served?
2. You import a 2 KB PNG from src/assets/ and run `vite build`. Why might the file be absent from dist/assets?
3. The dev server runs fine, but the editor flags every `@/components/...` import and `npm run type-check` fails. What is wrong?
4. What distinguishes a file in views/ from one in components/, according to the convention this topic argues for?
Flashcards
Question
What distinguishes src/assets/ from public/ in a Vite build?
Click to reveal answer
Answer
assets/ is processed: imported files are hashed (dist/assets/name-hash.ext), minified, tree-shaken, and files under assetsInlineLimit (default 4 KB) are inlined as base64. public/ is copied verbatim to the output root with names intact and must be referenced by stable root-relative URLs; nothing in it is hashed, so cache invalidation is manual.
Question
Where must the @ path alias be declared, and what fails when only one declaration exists?
Click to reveal answer
Answer
In vite.config.ts under resolve.alias (fileURLToPath(new URL('./src', import.meta.url))) for the bundler, AND in tsconfig paths ('@/*': ['./src/*']) for the editor and vue-tsc. Vite-only: app runs, editor shows red squiggles and type-check fails. tsconfig-only: types resolve but the dev server 404s on every aliased import.
Question
When does a dynamic image URL like `:src="'../assets/' + name + '.svg'"` break, and what is the fix?
Click to reveal answer
Answer
It breaks in production: the compiler only rewrites statically analyzable references (explicit imports, static template src, CSS url()), so the concatenated path points at a file that was never emitted under that name. Fix: import each candidate (or use import.meta.glob) and bind the resolved hashed URLs.
Question
What does `import.meta.glob('../views/*.vue')` return, and why is it useful for routing?
Click to reveal answer
Answer
It returns a map of path → () => import(path), i.e. lazy loader functions, at build time. Mapping each entry to a route component gives you one Rollup chunk per view with no hand-maintained import list; pass true as the second argument only when eager static imports are wanted.
Question
State the components-vs-views rule and the router-dependency rule that follows from it.
Click to reveal answer
Answer
A view is a route-level composition (one per route, may read params and navigate); a component is anything that must render unchanged in two different routes — so it never imports the router and instead takes data via props and reports actions via emits. Views stay thin compositions; growth goes into extracted components or composables.
Revision Notes
Key Takeaways
- 1. views/ holds route-level compositions that may know the router; components/ holds reusable, router-agnostic pieces fed by props and emits — the split is role, not syntax
- 2. assets/ is the processed path (hashing, inlining under 4 KB, CSS url() handling); public/ is the verbatim path for URL-addressed files like favicon and manifest
- 3. Dynamic asset URLs are invisible to the compiler — import the asset (or glob the directory) and bind the resolved URL instead of string concatenation
- 4. The @ alias must exist in both vite.config resolve.alias and tsconfig paths, because the bundler and the type system resolve imports independently
- 5. import.meta.glob turns a folder into lazy imports at build time, which is how route tables stay free of hand-written import lists while remaining code-split
Interview Tips
- • "How do you structure a medium Vue app?" — walk the folders (views/components/composables/stores), then state the test for placement: renders unchanged in two routes → component; one per route → view; logic shared by components → composable
- • "What's the difference between assets and public?" — hashing and processing versus verbatim copy; add the cacheability argument (immutable hashed names) and the 4 KB inline threshold to show depth
- • "Why does my code run but the editor shows red on @ imports?" — alias exists for Vite but not tsconfig; explain that two resolvers must be configured in lockstep
- • "How would you auto-register routes?" — import.meta.glob('../views/*.vue') returning lazy loaders, mapped to route components; mention that each stays a dynamic import so Rollup emits per-view chunks
Cheat Sheet
Project Structure and Conventions
Standard layout:
src/
assets/ # imported images, fonts, global CSS (Vite-processed)
components/ # reusable, router-agnostic, props in / emits out
views/ # one file per route; may use useRouter/useRoute
composables/ # useXxx() logic shared across components
stores/ # Pinia stores (useXxxStore)
router/ # route table, guards, lazy imports
main.ts App.vue
public/ # copied verbatim to dist root (favicon, robots.txt, manifest)
Asset decision: imported or hashable → src/assets (hashed name, immutable caching, <4 KB inlines as base64) · addressed by stable URL, never imported → public/ · runtime URLs to public files: import.meta.env.BASE_URL + 'file.png' so subpath deploys survive.
Alias setup (both required):
// vite.config.ts
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }
// tsconfig.app.json
"baseUrl": ".", "paths": { "@/*": ["./src/*"] }
Vite-only → runs but type-check/editor red. tsconfig-only → types pass, dev server 404s.
Conventions worth linting: PascalCase multi-word component filenames · use-prefixed camelCase composables · no relative chains above one level — promote shared pieces to @/ · views thin, extraction goes down (components/composables), not sideways · import.meta.glob('../views/*.vue') for chunk-per-route registration.
Debug a 404 asset: imported? (hashed URL ok) → static src (plugin rewrites) → dynamic concatenation (compiler can't see it) → public file referenced without BASE_URL (breaks under subpath).