A step-by-step breakdown of building
create-vue-starter-with-test— from bootstrapping a Vue app to publishing a CLI tool on npm.
Overview
create-vue-starter-with-test is a CLI that scaffolds a fully configured Vue 3 project in seconds:
npx create-vue-starter-with-test my-app
cd my-app
yarn dev
The generated project ships with Vue 3, Vite, Vitest, Vue Router, Pinia, MSW (Mock Service Worker), Sass, ESLint, and Prettier — all wired up and ready to go. This write-up walks through exactly how it was built, PR by PR.
Phase 1 — Bootstrap the Template App
The foundation starts with create-vue, the official Vue scaffolding tool. Run:
npm create vue@latest
This launches an interactive prompt. Here's what was selected:
✔ Project name: vue-starter
✔ Add TypeScript? → Yes
✔ Add JSX Support? → No
✔ Add Vue Router for Single Page Application development? → Yes
✔ Add Pinia for state management? → Yes
✔ Add Vitest for Unit Testing? → Yes
✔ Add an End-to-End Testing Solution? → No
✔ Add ESLint for code quality? → Yes
✔ Add Prettier for code formatting? → Yes
✔ Add Vue DevTools 7 extension for browser debugging? → Yes (https://devtools.vuejs.org)
Then install dependencies and start the dev server:
cd vue-starter
yarn install
yarn dev
At this point you have a working Vue 3 + Vite app with TypeScript, Vue Router, Pinia, Vitest, ESLint, and Prettier all wired up by the scaffolder. The next step is layering in the extra tooling that create-vue doesn't include by default — MSW, Sass, and the test configuration.
Additional packages installed manually:
yarn add -D msw sass
Stack chosen:
-
Vue 3 with the Composition API and
<script setup>syntax - Vite as the build tool and dev server
- TypeScript throughout
- Vitest for unit testing (configured to merge with the Vite config)
- Vue Router for client-side routing
- Pinia for state management
- MSW (Mock Service Worker) for API mocking in tests
- Sass for component styling
Configuration files set up:
-
vite.config.ts— configures the Vue plugin and@path alias -
vitest.config.ts— merges with the Vite config, setsjsdomas the test environment, and supports both__tests__/and*.test.tsfile patterns -
tsconfig.json,tsconfig.app.json,tsconfig.node.json,tsconfig.vitest.json— TypeScript project references for clean separation between app, node, and test code -
eslint.config.ts— ESLint with Vue and TypeScript rules -
.prettierrc.json— Prettier for consistent code formatting -
.editorconfig— consistent editor settings across IDEs
The MSW Setup
MSW is configured for the Node environment (used in Vitest) via src/mock/serverSetup.ts:
import { setupServer } from 'msw/node';
import handlers from './handlers';
const server = setupServer(...handlers);
export { server };
Handlers are organized by resource. The post handler intercepts GET /posts and returns fixture data:
import { http, HttpResponse } from 'msw';
const postHandler = http.get('https://jsonplaceholder.typicode.com/posts', () => {
return HttpResponse.json([{ title: 'title a', body: 'body a' }]);
});
export default postHandler;
This means tests never hit the real network — they're fast, deterministic, and don't depend on external services.
Phase 2 — Build a Real Feature (Posts View)
With the skeleton in place, the first real feature was a Posts view: a page that fetches posts from an API, shows a loading state while fetching, renders cards when data arrives, and shows an empty state when there's nothing to display.
PR #1 — Empty state for posts
PostView.vue handles three states using Vue's v-if / v-else-if / v-else directives:
<div v-if="isLoadingPosts" class="loading">Loading...</div>
<div class="cards-wrapper" v-else-if="hasPosts">
<div class="card" v-for="(post, index) in posts" :key="index">...</div>
</div>
<div class="empty-state" v-else>Oops! Nothing to see here</div>
The hasPosts computed property combines loading and data state cleanly:
const hasPosts = computed(() => !isLoadingPosts.value && posts.value.length);
The test for this view (src/tests/pages/PostView.test.ts) uses MSW to mock the API and @vue/test-utils to mount the component:
beforeAll(() => server.listen());
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
it('should render', async () => {
const wrapper = shallowMount(PostView);
expect(wrapper.find('.loading').exists()).toBe(true); // loading state
await flushPromises(); // wait for fetch + DOM update
const cards = wrapper.find('.cards-wrapper').findAll('.card');
expect(cards.length).toBe(1); // one card from mock handler
});
flushPromises() is the key here — it drains all async queues so the component fully resolves before assertions run.
Phase 3 — Prepare for Publishing
PRs #2 and #3 covered documentation and licensing — a README.md with usage instructions and a LICENSE file with MIT terms. These are non-negotiable for an open-source npm package.
PR #4 — The CLI itself
This is where the project transforms from a starter template into a distributable CLI. Three things were added:
1. package.json configured for npm
{
"name": "create-vue-starter-with-test",
"version": "1.0.0",
"private": false,
"bin": {
"create-vue-starter-with-test": "dist/index.js"
},
"files": ["dist", "template"],
"type": "module"
}
The bin field tells npm what to run when someone calls npx create-vue-starter-with-test. The files field limits what gets published to just dist/ (compiled CLI) and template/ (the project scaffold).
2. tsconfig.cli.json — separate TypeScript config for the CLI
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
This compiles only src/index.ts into dist/index.js — completely separate from the template app's TypeScript config.
3. src/index.ts — the CLI script
The CLI is a Node.js script that does four things when run:
- Validates input — exits with a helpful message if no project name is given
-
Copies the template — recursively copies everything from
template/into the target directory -
Cleans up — removes any
.gitfolder from the copy -
Installs dependencies — runs
yarn installin the new project
#!/usr/bin/env node
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import chalk from 'chalk'; // https://github.com/chalk/chalk
function copyDir(src: string, dest: string) {
fs.mkdirSync(dest, { recursive: true });
for (const file of fs.readdirSync(src)) {
const srcPath = path.join(src, file);
const destPath = path.join(dest, file);
fs.statSync(srcPath).isDirectory()
? copyDir(srcPath, destPath)
: fs.copyFileSync(srcPath, destPath);
}
}
async function main() {
const projectName = process.argv[2];
if (!projectName) { /* ... */ process.exit(1); }
const targetDir = path.resolve(process.cwd(), projectName);
if (fs.existsSync(targetDir)) { /* ... */ process.exit(1); }
const templateDir = path.resolve(__dirname, '../template');
copyDir(templateDir, targetDir);
execSync(`cd ${projectName} && yarn install`, { stdio: 'inherit' });
}
main();
The __dirname trick (via fileURLToPath) resolves the template path relative to the compiled CLI file — this is important because when the package is installed globally via npx, process.cwd() is the user's directory, not the package directory.
PR #5 — .npmignore
Without this file, npm would publish everything including node_modules, source files, and dev config. .npmignore explicitly excludes everything except what's already listed in files in package.json. It's redundant but acts as a safety net.
PR #6 — Move template into template/
This was the structural pivot. Up to this point the Vue app and the CLI tool lived in the same flat structure. PR #6 moved all Vue app files into template/:
Before: After:
src/ src/
index.ts index.ts ← CLI only
App.vue template/
router/ App.vue
views/ router/
... views/
...
The CLI's copyDir call was updated to point at ../template relative to the compiled output in dist/.
Publishing to npm (and making npx work)
Once the CLI and template are in place, publishing follows three steps:
1. Build the CLI
The CLI is written in TypeScript, so it must be compiled before publishing. The build script in package.json runs tsc using the CLI-specific tsconfig:
yarn build
# compiles src/index.ts → dist/index.js
Always run this before publishing. The dist/ folder is what actually ships — if you publish without building, users get nothing.
2. Log in to npm and publish
See the npm publish docs for full options.
npm login # one-time: authenticates with your npm account
npm publish # publishes the package
npm reads package.json to determine the package name (create-vue-starter-with-test), version, and what files to include (dist/ and template/ via the files field). Because private is set to false, npm allows the publish.
For subsequent releases, bump the version first:
npm version patch # 1.0.6 → 1.0.7
yarn build
npm publish
3. How npx resolves it
When a user runs:
npx create-vue-starter-with-test my-app
npx looks up the package name on the npm registry, downloads it temporarily (or uses a cached version), then reads the bin field:
"bin": {
"create-vue-starter-with-test": "dist/index.js"
}
It executes dist/index.js with Node, passing my-app as process.argv[2]. The #!/usr/bin/env node shebang at the top of the file tells the OS to use Node to run it.
The naming convention create-* is intentional — it follows the same pattern as create-react-app, create-vite, and create-vue, which makes the package discoverable and the command feel natural.
Phase 4 — Component Architecture
PR #7 — Atomic Design folder structure
The final major feature was introducing an atomic design component hierarchy. Instead of dumping all components in one flat folder, they're organized by complexity:
src/components/
atoms/ ← smallest building blocks
BaseButton.vue
BaseInput.vue
icons/
molecules/ ← groups of atoms
FormField.vue
organisms/ ← complex UI sections
Card.vue
templates/ ← page-level layout wrappers
PageTemplate.vue
This gives any team using the starter kit a clear mental model for where new components go and how they compose. A FormField (molecule) is built from a BaseInput (atom). A Card (organism) uses the styling primitives but operates at a higher abstraction level. PageTemplate defines layout structure independently of content.
How It All Fits Together
The repository has two distinct roles:
| Layer | Path | Purpose |
|---|---|---|
| CLI tool |
src/index.ts → dist/index.js
|
What runs when users call npx create-vue-starter-with-test
|
| Template app | template/ |
The Vue project that gets copied into the user's directory |
When you run npm publish, npm packages dist/ and template/ per the files field. When a user runs npx create-vue-starter-with-test my-app, npm downloads the package and executes dist/index.js, which copies template/ to ./my-app and installs dependencies.
Phase 5 — GitHub Actions (CI)
PR #8 — Workflow setup
The final piece is automating quality checks on every push and pull request. A GitHub Actions workflow ensures the CLI builds and tests pass before anything merges.
The workflow lives at .github/workflows/ and runs on push to main and on pull requests:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: yarn install
- run: yarn build # compile the CLI
- run: yarn test:run # run Vitest once (no watch mode)
This gives you a green/red signal on every PR — if the CLI fails to compile or a test breaks, the merge is blocked. It also means contributors can't accidentally ship a broken template.
Key Takeaways
Separate concerns early. The CLI and the template app are different things. Keeping them in separate directories (
src/vstemplate/) avoids confusion and keeps the published package clean.Use
__dirnamerelative paths in CLI tools.process.cwd()is the user's working directory. Template paths must be resolved relative to the installed package location.Test with MSW, not mocks. MSW intercepts at the network level, so tests exercise real
fetchcalls without hitting the network. This is closer to production behavior thanvi.mock().vitest.config.tsshould extendvite.config.ts. UsingmergeConfigkeeps the two in sync — especially for path aliases (@), which are needed in both build and test contexts.Atomic design scales. Starting the template with
atoms/molecules/organisms/templatesgives consumers a naming convention and mental model from day one, rather than acomponents/folder that grows chaotically..npmignore+filesinpackage.json. Use both.filesis the whitelist of what to include;.npmignoreis the blacklist. Together they ensure the published package is lean.
Top comments (0)