Project structure and conventions
Decide where files go and what they are called, import without barrels, and pin pnpm and Node so every machine installs the same project.
What you'll learn
When you finish, you can do these.- 1Choose a folder for each file, by type or by feature
- 2Name components with two or more words, such as BaseButton
- 3Import files so each page loads only what it uses
- 4Pin the versions of Node, pnpm and every package
Picture a Vue project fresh from create-vue, the official project generator: five folders under src/, and every file has an obvious home. A year later it is an online shop with 80 components and a dozen stores, which hold shared data such as the cart. Finding where the cart total is worked out takes a search. Conventions, rules a whole team follows, decide where a file goes and what it is called, so everyone looks in the same place.
The Code tab holds that shop's conventions as files you can copy: structure.txt draws its folders two ways; CartView.vue, the cart page, imports a button; and package.json pins the tools, so every machine installs the same project. You need one idea from the lesson Start a project with create-vue: in an import, @/ means the src/ folder.
Folders by type, then by feature
There are two ways to choose a file's folder, and structure.txt in the Code tab draws the shop both ways.
create-vue starts you with folders by type: pages in src/views/, components (the pieces of a page, such as a button) in src/components/, stores in src/stores/. Add two more early. src/composables/ holds composables, functions whose names start with use and hold logic that several components share. src/api/ holds the functions that call your server, so no page writes a URL itself.
The folders also set a direction for imports. Read the diagram as "may import from": a page uses components and stores, a store calls the api layer, and nothing imports a page.
- src/viewsone file per page
- src/componentswhat you see
- src/composablesreusable logic
- src/storesshared state
- src/apicalls the server
Component tests sit beside the components, in src/components/__tests__/. A TypeScript type, which describes the shape of your data, lives in the file that owns it: api/products.ts, which fetches the products, exports the Product type. src/types/ holds only types that many folders share.
Folders by type stop helping when one change, such as a cart discount, touches four folders: views, components, stores and api. Then group by feature. Everything about the cart moves to src/features/cart/, while shared building blocks such as BaseButton.vue, the button every page uses, stay in src/components/. A feature may import the shared folders and its own files, never another feature's internals.
Names and imports
Name components in PascalCase, each word capitalised, with at least two words: BaseButton.vue, TheHeader.vue, ProductCard.vue. A template may also write a component in kebab case, lowercase with hyphens: <base-button>. A one-word Button would be <button>, the browser's own element. No HTML element has a hyphen, and none ever will, so a two-word component never collides with one.
The official Vue style guide adds two prefixes: Base for small building blocks used everywhere, The for a component that appears once per page. ESLint, the code checker create-vue installs, enforces the two words with its vue/multi-word-component-names rule, which lets App.vue pass.
Other files are named after what they hold: useCartTotals(), the composable that works out the cart total, lives in useCartTotals.ts, and the cart's store, useCartStore, in stores/cart.ts.
Import from another folder with @/ and inside a folder with ./. Here the cart page, CartView.vue in the Code tab, imports the button:
<script setup lang="ts">
import BaseButton from '@/components/BaseButton.vue'
</script>
Its template then writes <BaseButton>Checkout</BaseButton>, and the page shows a Checkout button.
A barrel is an index.ts that re-exports a whole folder, so one line can import anything from it: import { BaseButton } from '@/components'. It reads well, but Vite, the tool that serves your app during development, then loads and runs every file in the folder before the page renders.
In the production build, Rolldown, the part of Vite that joins your files, drops exports nobody uses. A file that does something the moment it loads cannot be dropped, so it ships with every page that touches the barrel. Import the file you need.
Pin the toolchain
The last convention pins the tools. Node runs JavaScript outside the browser, and a package manager installs packages. Two laptops with different versions of either can install different code from the same package list, package.json. Pin both.
This school uses pnpm 12 in place of npm. package.json in the Code tab pins pnpm and Node:
"packageManager": "pnpm@12.11.2",
"engines": {
"node": "^22.18.0 || >=24.12.0"
},
packageManager names the exact pnpm, and pnpm switches to that version inside the project. engines says which Node the project supports, here 22 or 24. A .nvmrc file holding 24 lets nvm, a tool that switches between Node versions, pick that Node when you run nvm use in the folder.
A lockfile pins every package: pnpm records each one's exact version in pnpm-lock.yaml. Commit it to Git with your code. CI, the server that checks every push, should install with pnpm install --frozen-lockfile, which fails instead of quietly changing it. With npm the pair is package-lock.json and npm ci.
Pinned does not mean frozen: renovate.json in the Code tab turns on Renovate, a bot that opens a pull request for each update, with its release notes. Read them before merging a major version.
What goes wrong
The first mistake is a one-word component, Button.vue, that the cart page writes as a lowercase tag:
<script setup lang="ts">
import Button from '@/components/Button.vue'
</script>
<template>
<button>Checkout</button>
</template>
<button> is the browser's own element, so the page shows a plain button without your styles, and the browser prints no warning. ESLint reports two problems: Component name "Button" should always be multi-word on the file and 'Button' is defined but never used on the import.
<script setup lang="ts">
import BaseButton from '@/components/BaseButton.vue'
</script>
<template>
<BaseButton>Checkout</BaseButton>
</template>
A two-word name has no lowercase twin, so <BaseButton> always means your component, and the page shows its styled Checkout button.
The second mistake is a barrel with one heavy neighbour: a sales chart, SalesChart.vue, which imports a module that runs code as it loads.
export { default as BaseButton } from './BaseButton.vue'
export { default as SalesChart } from './SalesChart.vue' // loads a module that runs on import
// src/views/CartView.vue
import { BaseButton } from '@/components'
The cart page needs one button, yet the browser's Network tab, which lists the files a page loads, shows SalesChart.vue and its module, and the module ends up in the cart's production bundle. Import the file itself.
import BaseButton from '@/components/BaseButton.vue'
The Network tab now lists BaseButton.vue and nothing from the chart.
The third is trusting engines to guard the Node version. On Node 20, pnpm install finishes without a word, and npm only prints an EBADENGINE warning.
node -v # v20.20.2
pnpm install # installs, engines ignored
One line in pnpm-workspace.yaml, the file pnpm reads its settings from, turns the check on:
engineStrict: true
The same install now stops with ERR_PNPM_UNSUPPORTED_ENGINE and Unsupported engine … wanted: {"node":"^22.18.0 || >=24.12.0"} (current: {"node":"20.20.2"}).
What to remember
- Start with folders by type and keep imports flowing one way: views, components, composables, stores, api. Move to feature folders when one change touches four folders.
- Component names are PascalCase and at least two words long, so none can collide with an HTML element.
- Import the file you need. A barrel makes every importer load the whole folder, and code that runs on load comes along.
- Pin pnpm with
packageManager, commit the lockfile, and install with--frozen-lockfilein CI. enginesalone is not enforced: npm only warns and pnpm says nothing.engineStrict: trueinpnpm-workspace.yamlmakes pnpm stop, and.nvmrcpicks the right Node.
In your project
Do these in a real project. Each one says how to confirm it worked.- Run
pnpm lint. Rename every file thevue/multi-word-component-namesrule reports, for exampleCard.vuetoProductCard.vue, and update its imports. Lint passes and the pages render as before. - Move one feature into
src/features/<name>/with its view, components and store, and point its imports at@/features/<name>/…. Runpnpm builduntil it passes: an import you missed fails the type-check witherror TS2307: Cannot find module '@/components/…' or its corresponding type declarations. - Add
packageManager,engines,.nvmrcandengineStrict: true. Changeengines.nodeto>=26for a moment and runpnpm install: it stops withERR_PNPM_UNSUPPORTED_ENGINE. Put the range back and the install passes.