99

Concurrent visitors over the last 30 minutes. (~)

Peak concurrent visitors -
Yesterday: 0 / Today: 0

Building Vue Apps with Claude Code

Building Vue Apps with Claude Code

This post assumes you already know the basics of Claude Code.
For the detailed options of individual features such as installation, permission modes, Hooks, and Skills, see Claude Code Essentials.

Vue code written by Claude Code usually works, but the approach can differ from file to file.
One component might use the Composition API while another uses the Options API.
The same state can be declared differently with ref() and reactive(), and option stores and setup stores can end up mixed in one project.
Because the AI's training data mixes many implementation styles, the same project can easily produce different results without instructions.

Let's look at where to put rules and how to verify them when you develop with Vue using Claude Code, and how to carry the same standards through bugs, refactoring, reviews, performance, and deployment.

# Project setup

Throughout this post, we use a single example app that searches for movies and shows them in a list.
We start from a Vue + TypeScript project created with Vite, with ESLint, Prettier, and path aliases already configured.
For this setup process, see Starting a Vue Project with Vite.

Run Claude Code in the project root and enter the /init command.
It analyzes the current project and creates a draft of the CLAUDE.md file.

PROMPT
1
/init

/init summarizes the project structure and the tools in use, but it doesn't solve the problem of mixed coding styles we just looked at.
In the next section, you write which approach to use yourself.

Before that, set up the verification commands.
Even with well-written rules, you can't filter out code that strays from them without a way to check the results.
In particular, errors inside <template> don't show up unless you open the editor, so make type checking a separate script.

/package.json
JSON
1
2
3
4
5
6
7
8
9
10
{ "scripts": { "dev": "vite", "build": "vue-tsc -b && vite build", "preview": "vite preview", "typecheck": "vue-tsc --build", "lint": "eslint .", "test:unit": "vitest run" } }
Adding verification scripts

tsc can't read *.vue files, so use vue-tsc for type checking.
It checks not only <script> but also the expressions inside <template>.
The build script in the Vite template already includes vue-tsc -b, but keep a separate typecheck script so you can check without building.

Register these commands in the project settings so Claude can run them without approval.
If Claude asks for permission every time, the flow of the verification loop breaks.

/.claude/settings.json
JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{ "permissions": { "allow": [ "Bash(npm run typecheck)", "Bash(npm run lint)", "Bash(npm run test:unit)" ], "deny": [ "Read(.env)", "Read(.env.local)", "Read(.env.*.local)" ] } }

Claude can't read the paths registered in deny.
If you restrict files that contain API keys, their values don't end up in the conversation history or logs.

Deny rules block not only reading but also editing and creating new files at the same paths.
If you write a broad rule like Read(.env*), Claude can't even create the .env.example template file that only lists names, so restrict only the names that actually hold values.

.claude/settings.json holds settings the team shares, so commit it to Git.
Put personal settings in .claude/settings.local.json and add it to .gitignore.

# Defining Vue rules

CLAUDE.md is a project instruction file that is read automatically at the start of every session.
What you add here isn't an introduction to the project, but which option to choose when there are several.

In Vue, the situations that need a decision are as follows.

What varies Options Without instructions
Component style Options API / Composition API Mixed from file to file
Script block <script> / <script setup> export default sneaks in
Reactive declarations ref() / reactive() State of the same kind is declared differently
Store definition Option stores / setup stores Each store has a different structure
Logic reuse Composables / mixins Mixins show up
Style isolation scoped / global / CSS Modules Global selectors leak out
Path references Relative paths / aliases ../../../ keeps growing

Decide each item one by one and write it as a rule sentence.
Rather than explaining the reasons at length, it's better to write briefly what to use and what not to use.

/CLAUDE.md
MD
1
2
3
4
5
6
7
8
9
10
11
12
## Vue coding rules - Every Vue component uses `<script setup lang="ts">`. Do not use the Options API. - Use `ref()` by default for reactive state. Do not use `reactive()` except in special cases. - Create derived values with `computed()`. Do not create derived values by updating other state with `watch()`. - Reuse logic with composables. Do not use mixins. - Put composable files at `/src/composables/useXxx.ts`, and start function names with the `use` prefix. - Always add `scoped` to `<style>`. Write global styles only in `/src/assets/main.css`. - Name component files with multi-word PascalCase. (`MovieCard.vue`) - Use the `@/` alias for `import`. Relative paths are allowed only within the same folder. - Declare props only with type-based declarations in the form `defineProps<T>()`. - Communicate between components with props and emits. Use `provide`/`inject` only at the layout level.
Vue coding rules section

For example, the rule to standardize on ref() could be just a matter of taste, but there is another reason.
reactive() loses reactivity with primitives, and reactivity also breaks when you destructure it.
If the two functions are mixed, you have to judge every time which limitation applies, and a wrong judgment becomes a bug where the screen doesn't update.
Sticking to just one greatly reduces the situations you have to judge.

If instructions contradict each other, Claude picks one of them arbitrarily.
If "Use ref()" and "reactive() is handier for object state" are both there, it's the same as having no rule.

When rules grow and CLAUDE.md gets long, split groups of a different nature, such as Vue rules, store rules, and routing rules, into the .claude/rules/ folder.
Markdown files in this folder are loaded automatically as project-scope instructions, just like CLAUDE.md, so you don't need to reference them separately from CLAUDE.md with the @path syntax.

1
2
3
4
5
6
7
├─.claude/ │ ├─rules/ │ │ ├─vue.md │ │ ├─pinia.md │ │ └─router.md │ └─settings.json └─CLAUDE.md
Instruction file layout

Leave only the project overview and commands in CLAUDE.md.

/CLAUDE.md
MD
1
2
3
4
5
6
7
8
9
10
11
# Movie search app A movie search application based on Vite + Vue 3 + TypeScript. The Vue, store, and routing rules are in the `.claude/rules/` folder. ## Common commands - `npm run dev`: Run the development server - `npm run typecheck`: Type check (including `*.vue` templates) - `npm run lint`: ESLint check - `npm run test:unit`: Unit tests

You can narrow the scope of a rule file with the paths frontmatter.
Vue rules are only needed when working on *.vue files, so if you set the condition, they aren't loaded for other work.

/.claude/rules/vue.md
MD
1
2
3
4
5
6
7
8
9
10
--- paths: - "src/**/*.vue" - "src/composables/**/*.ts" --- # Vue coding rules - Every component uses `<script setup lang="ts">`. - Use `ref()` by default for reactive state.
A conditionally loaded rule

A rule file without paths is loaded unconditionally in every session and has the same priority as .claude/CLAUDE.md.

It's best to keep a single CLAUDE.md file at 100-200 lines or fewer.
The longer it gets, the more context it uses, and the less reliably the instructions are followed.

Don't write formatting rules in CLAUDE.md.
Semicolons and quote styles are already decided by Prettier, so writing them again only uses more tokens and creates room for conflicts.
Leave what tools can enforce to the tools, and write only the design decisions tools can't make in your instructions.

# Giving instructions for SFCs

Vue's SFC (Single File Component) is a structure that works well with AI.
The template, script, and style live in one file, so you can understand the whole context by looking at just one component.
You don't need to find separate style or type files, which reduces the number of files to read and the tokens used.

On the other hand, as a file grows, Claude tends to rewrite the whole thing even for small changes.
If only <template> needs to change but <script setup> gets rewritten too, review costs grow, so state which block to change.

PROMPT
1
Show the release year in the MovieCard component.
A request that doesn't name a block
PROMPT
1
2
Edit only the <template> of @/components/MovieCard.vue to show the release year below the title. Leave <script> and <style> as they are, and don't change the props type either!
A request that names the block

When you create a new component, it's good to decide and pass on the interface first.
When the shape of the props and emits is fixed, the implementation has less freedom, and later changes to match the parent component are kept to a minimum.

PROMPT
1
2
3
4
5
6
7
8
9
10
11
Create @/components/MovieCard.vue. Props: movie: { imdbID: string; Title: string; Poster: string; Year: string } Emits: select: (imdbID: string) => void - Show the poster image, title, and year. - When the card is clicked, pass imdbID with the select event. - If the Poster value is 'N/A', show a fallback image. - Follow the Vue coding rules in CLAUDE.md.
A request that gives the interface first

If you describe styles such as colors or spacing in the prompt every time, each component can end up with different values, and the design falls out of harmony as screens pile up.
If you define the values the project uses as CSS variables in one place and write a rule to use only those variables, styles are mostly locked in too.

/src/assets/main.css
CSS
1
2
3
4
5
6
7
8
9
10
11
:root { --color-bg: #ffffff; --color-text: #1f2328; --color-primary: #42b883; --color-border: #d0d7de; --radius: 8px; --space-1: 4px; --space-2: 8px; --space-3: 16px; --space-4: 24px; }
/.claude/rules/vue.md
MD
1
2
3
4
5
- Use only the CSS variables in `/src/assets/main.css` for colors, spacing, and border radius. Don't write values directly. - If a color or spacing you need doesn't exist, add the variable first and then use it. - Use Flex for layout by default, and use Grid only when you need a two-dimensional layout. - Use only two breakpoints: 768px and 1024px. - Don't push margins outward from inside a component. The parent decides the spacing between components.
Style rules

With rules like these, even a short request like Create a movie card component keeps the same style as the rest of the screens.
If you have design mockups, don't attach them every time; it's better to first define the values extracted from the mockups (design tokens) as variables.

Start work that changes several files together in Plan mode.
When you enter Plan mode with Shift + Tab or the /plan command, Claude only reads files and makes a plan, without editing.
For work that creates the search input, list, card, and composable all at once, it's safer to check the file list and data flow first.

PROMPT
1
2
3
I want to build a movie search screen. Including the search input, loading indicator, result list, and empty-result handling, just make a plan for which files to create and what role each one plays.
A request in Plan mode

What you check in the plan isn't the code but the boundaries.
As long as it's right about which component manages the search term state and whether the API call happens in the component or the composable, the rest of the implementation mostly follows the rules.

If you add a rule to move data-fetching logic into composables, you can keep components from growing bloated.
For composable patterns, see Vue Composition API Core Patterns.

/src/composables/useMovieSearch.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { ref, computed } from 'vue' import { useQuery } from '@tanstack/vue-query' import type { Movie } from '@/types/movie' export function useMovieSearch() { const searchText = ref('') const queryOptions = computed(() => ({ queryKey: ['movies', searchText.value], queryFn: async () => { const res = await fetch(`https://omdbapi.com?apikey=7035c60c&s=${searchText.value}`) const { Search: movies = [] } = await res.json() return movies as Movie[] }, enabled: !!searchText.value })) const { data: movies, isFetching } = useQuery(queryOptions) function search(text: string) { searchText.value = text.trim() } return { movies, isFetching, search } }
Search logic moved into a composable

In TanStack Vue Query, the query options must be wrapped in computed when you pass them, so that changes to the options are detected reactively.
If you write library-specific usage like this as one line in a rule file, you don't have to explain it every time.

# Store and router rules

Pinia stores and Vue Router routes are code with repetitive structure.
Instead of explaining them in the prompt every time, lock them in with rules and slash commands.
For the syntax of each, see Pinia Essentials and Vue Router Essentials.

The first thing to decide in Pinia is whether to use option stores or setup stores.
The two differ only in syntax and have the same features, but if they are mixed, you have to figure out the structure again every time you open a store.

/.claude/rules/pinia.md
MD
1
2
3
4
5
6
7
8
# Pinia store rules - Define stores as option stores (`state`, `getters`, `actions`). Do not use setup stores. - Put files at `/src/stores/name.ts`, name the composable `useNameStore`, and use a lowercase store ID that matches the file name. - Always write `state` as a factory function. - When using `this` in `getters`, define them as regular functions instead of arrow functions. - When changing several pieces of state at once, use `$patch` instead of individual assignments. - Perform async requests and DOM manipulation only in `actions`. Never do them in `getters`.

The last two lines are where things often go wrong.
Getters are read-only computed values, so async requests and side effects must not go in them, but without instructions, data-fetching code may get added to a getter.

Instead of checking these rules every time, you can turn store creation itself into a slash command.
Create a folder under .claude/skills/ and write a SKILL.md, and you can call it with /skill-name.

/.claude/skills/new-store/SKILL.md
MD
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
--- name: new-store description: | Creates a Pinia store file that follows the project rules. Use it when adding a new store or when you need a state management module. pinia, store, state. argument-hint: Store name (e.g. movie) disable-model-invocation: true --- # Create a Pinia store Use `$ARGUMENTS` as the store name and do the following. 1. Create the `/src/stores/$ARGUMENTS.ts` file. 1. By default, follow the rules in `.claude/rules/pinia.md` exactly. 1. As an option store, create only the skeleton of state, getters, and actions, and leave the business logic empty. 1. Import the types you need from `/src/types/`, and define new ones if they don't exist. 1. After creating it, run `npm run typecheck` and confirm it passes.
PROMPT
1
/new-store movie
Creating a store with a slash command

$ARGUMENTS is replaced with whatever you type after the command, so in the example above, it becomes movie.
argument-hint is only a hint shown while you type the slash command, and it doesn't pass any value.

disable-model-invocation: true is an option that turns off automatic invocation so the skill runs only as a slash command.
It fits tasks such as store creation that should run only when the user intends.

Write rules for the router the same way.
This project uses Vue Router 5's file-based routing.
The file paths in the /src/pages folder become the access paths as is, so you don't write the route array by hand, but what the rules need to decide shifts to where route info is written.

/.claude/rules/router.md
MD
1
2
3
4
5
6
7
8
9
10
11
12
# Routing rules - Put page components in `/src/pages`, so the file path becomes the access path as is. - Create the router instance in `/src/routes/index.ts` with `routes` from `vue-router/auto-routes`. Do not write the `routes` array by hand. - Put layouts in `/src/routes/layouts/` and guards in `/src/routes/guards/`. - Set route names and `meta` in page components with the `definePage` macro. - Set `meta.auth: true` on routes that require authentication, and `meta.guestOnly: true` on routes that signed-in users must not visit. - Choose the layout with `meta.layout`. - Define new `meta` properties in the `RouteMeta` interface before using them. - Do not write `beforeEnter` in `definePage`. Per-route guards are not supported, so handle them with `meta` and global guards. - Guards decide based only on `meta` values. Do not write code inside guards that compares path strings directly. - Lazy loading of pages is applied automatically, so do not write it yourself.

The definePage rule is especially important in projects that switched to file-based routing.
The training data has far more code that writes route objects directly in a routes array, so without instructions, Claude may write code that redefines routes that are already generated automatically.

/src/pages/movies.vue
VUE
1
2
3
4
5
6
7
<script setup lang="ts"> definePage({ meta: { auth: true } }) </script>
Route info set with definePage

The rule that keeps guards from comparing path strings directly is especially effective.
Without it, you have to fix the guard files every time you add a route.
If guards look only at meta, they stay the same even when you add routes.

/src/routes/guards/requiresAuth.ts
TS
1
2
3
4
5
6
7
8
9
import type { RouteGuard } from '.' export const requiresAuth: RouteGuard = { guard(to) { if (!to.meta.auth) return true return !!localStorage.getItem('accessToken') }, redirect: to => ({ path: '/signin', query: { redirectTo: to.fullPath } }) }
A guard that looks only at meta

However, the properties of to.meta have no fixed types by default, so even if you write a layout name that doesn't exist in layout, type checking won't catch it.
If you extend the RouteMeta interface, you can enforce the rule with types instead of instructions.

/src/routes/router.d.ts
TS
1
2
3
4
5
6
7
8
9
import 'vue-router' declare module 'vue-router' { interface RouteMeta { auth?: boolean guestOnly?: boolean layout?: 'Default' | 'Empty' } }
Type definitions for meta properties

With this, npm run typecheck catches it the moment you write a value that doesn't match the defined type, such as layout: 'Blank'.
Unlike rules written only as instructions, types can't be broken.
However, RouteMeta also accepts names you haven't defined as the unknown type, so if you misspell the name itself, such as auth as isAuth, type checking won't catch it.

Manage the RouteMeta extension in only one place.
If you already declared it in /src/routes/guards/index.ts while following Vue Router Essentials, move that declaration into this file to keep them together.
Declarations scattered across several places are still merged and work, but you'd have to open every file to see which properties exist.

# The verification loop

So far, we've looked at giving instructions; now let's look at checking the results.
You can verify the types, rules, and behavior of AI-written Vue code as follows.

Check Command What it catches
Types npm run typecheck Template expressions, props types, store state types
Rules npm run lint Vue style rules, unused variables, anti-patterns
Behavior npm run test:unit Rendering output, events, state changes

In most cases, type checking matters most.
That's because errors inside <template> don't show up unless you open the editor.
References to properties that don't exist, ref objects placed where a string should go, and misspelled props names are all caught here.

BASH
1
2
3
4
npm run typecheck ## src/components/MovieCard.vue:14:20 - error TS2551: ## Property 'year' does not exist on type 'Movie'. Did you mean 'Year'?
Where template errors show up

Write unit tests with Vitest and Vue Test Utils.
mount needs a DOM, so along with installing the packages, you also need to set the test environment.

BASH
1
npm i -D vitest @vue/test-utils jsdom
Installing the test packages
/vite.config.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], resolve: { alias: [{ find: '@', replacement: '/src' }] }, test: { environment: 'jsdom' } })
Setting the test environment

defineConfig from vitest/config extends the Vite config as is, so even if you switch from importing it from the vite package, the rest of the config keeps working.

Checking a component's rendering output and emitted events is enough, and at this level, results are fairly stable even when you leave it to the AI.

/src/components/MovieCard.test.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { mount } from '@vue/test-utils' import { describe, it, expect } from 'vitest' import MovieCard from './MovieCard.vue' const movie = { imdbID: 'tt0111161', Title: 'The Shawshank Redemption', Poster: 'N/A', Year: '1994' } describe('MovieCard', () => { it('shows the title and year', () => { const wrapper = mount(MovieCard, { props: { movie } }) expect(wrapper.text()).toContain('The Shawshank Redemption') expect(wrapper.text()).toContain('1994') }) it('emits a select event with imdbID when clicked', async () => { const wrapper = mount(MovieCard, { props: { movie } }) await wrapper.trigger('click') expect(wrapper.emitted('select')?.[0]).toEqual(['tt0111161']) }) })
Unit tests with Vue Test Utils

You can automate verification with Hooks so a person doesn't have to ask for it every time.
The PostToolUse hook runs after a tool call succeeds, so it's a good fit for running checks right after Claude edits a *.vue file.

/.claude/settings.json
JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-vue.mjs"] } ] } ] } }

When Claude Code runs a hook, it fills the CLAUDE_PROJECT_DIR environment variable with the root path of the project where the session started, so you don't set it yourself.
If you write only the executable in command and list the arguments as an array in args, the executable is called directly without going through a shell.

A hook receives JSON on stdin.
The script below extracts the edited file path from that JSON, runs ESLint depending on the extension, and if there are rule violations, writes the output to stderr and exits with exit code 2.
In a PostToolUse hook, Claude reads only stderr, and only when the exit code is 2.
Other exit codes, such as 1, show the user just one line of error and aren't passed to Claude, so if you pass ESLint's exit code through as is, the check results never reach the conversation.

/.claude/hooks/check-vue.mjs
JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { spawnSync } from 'node:child_process' let raw = '' for await (const chunk of process.stdin) raw += chunk const filePath = JSON.parse(raw).tool_input?.file_path ?? '' if (!/\.(vue|ts)$/.test(filePath)) process.exit(0) const { status, stdout, stderr } = spawnSync(`npx eslint "${filePath}"`, { shell: true, encoding: 'utf8' }) if (status === 0) process.exit(0) process.stderr.write(stdout + stderr) process.exit(2)

We used a Node.js script instead of a shell script (.sh).
Node.js works the same way on different operating systems, so the team can use it as is even if members use different operating systems.

Running a full type check on every file edit makes the wait long.
It's better to run a fast check on just the edited file in the hook, and to write in your instructions that the type check should run once when the work is done.

To check on-screen behavior as well, connect a browser.
Whether the list updates when you type a search term, or whether a notice appears for empty results, is hard to judge from the code alone.

There are two ways to connect a browser.
Claude in Chrome is a Chrome extension that controls your own browser directly, so it sees the screen under development exactly as a person does and shares your sign-in state.
Install the extension and start with claude --chrome; for setup and requirements, see Claude Code Essentials.

BASH
1
claude --chrome
Starting with Claude in Chrome

If you need an isolated browser, use an MCP server.
Use Playwright MCP for cross-browser automation, and Chrome DevTools MCP for performance metrics or network diagnostics.
Both are listed as plugins in the official Anthropic marketplace, so you can install them with slash commands.

PROMPT
1
2
/plugin install playwright@claude-plugins-official /plugin install chrome-devtools-mcp@claude-plugins-official
Installing as plugins

claude-plugins-official is a marketplace registered by default, so you don't need to add it.
You can also just enter the /plugin command and pick from the list as you browse.

To specify the package name or run options yourself, use the claude mcp add command.

BASH
1
2
claude mcp add playwright -- npx @playwright/mcp@latest claude mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
Registering MCP servers directly

Write the command to run after --.
If you leave out the separator, the options that follow are read as Claude Code's own options, and registration fails.
You can check the registration with the /mcp command, and since MCP doesn't start the development server for you, run npm run dev beforehand.

PROMPT
1
2
3
The development server is running on port 5173. Open it in the browser, type "matrix" into the search box, and check that the list renders and that there are no console errors.
Checking the result in a browser

# Official skills

Before making a skill yourself, check whether the library you use publishes an official skill.
APIs are most accurate when summarized by the people who made them, and when the docs change, the skill is updated with them.

The Vue ecosystem distributes skills differently from the React side.
React collects best practices in one separate repository, such as vercel-labs/agent-skills, while on the Vue side, each library keeps a skills/ folder in its own repository and ships the skills along with the library.

Provider Skill Install
VueUse vueuse-functions npx skills add vueuse/skills
Nuxt UI nuxt-ui npx skills add nuxt/ui

You can tell whether there is an official skill by checking the skills/ folder of that repository.
Once you know this pattern, you can apply it to other libraries as well.

vuejs/core, vuejs/router, and vuejs/pinia don't have a skills/ folder yet.
There is no official skill that teaches the framework syntax itself; skills are only provided per library.

The VueUse skill helps you choose functions.
There are more than 200 functions, so if you don't know the list, you might implement something that already exists; with the skill, you can find the function that fits your requirements.

PROMPT
1
Apply a debounce to the search input.
A request with the VueUse skill installed

For this request, the skill's role is to steer Claude toward refDebounced or useDebounceFn instead of implementing it directly with setTimeout.

In this skill, each function comes with an invocation rule.

Invocation rule Meaning
AUTO Used automatically when the situation applies
EXTERNAL Used only when the required external dependency is already installed
EXPLICIT_ONLY Used only when the user explicitly asks

The skill's body states that prompts or instruction files can override these rules.
This means project rules take precedence over external skills.

The Nuxt UI skill isn't only for Nuxt; it also works in Vue + Vite projects.
SKILL.md contains only an overview, and detailed APIs for each component, such as props, slots, and events, come from an MCP server.
That's because adding the APIs of all 125 components to the body would be more than the context could handle.
It's a design that keeps the decision criteria in the skill and the bulk reference material outside.

/.mcp.json
JSON
1
2
3
4
5
6
7
8
{ "mcpServers": { "nuxt-ui": { "type": "http", "url": "https://ui.nuxt.com/mcp" } } }
Connecting the Nuxt UI docs MCP

Areas without official skills can be filled in with community skills.
There are collections such as vuejs-ai/skills that cover Vue 3 in general, providing the Composition API, Vue Router, Pinia, and testing as separate skills.

Even if an organization account name looks official, it sometimes isn't.
The README of vuejs-ai/skills states that it is a community project, not an official one.
Before installing, check the README for whether it's official and when it was last updated.

External skills can conflict with project rules.
If a skill recommends setup stores while your project rules lock in option stores, you get the contradictory-instructions situation we looked at earlier.
After installing, read the skill's body once, and if anything conflicts, write in CLAUDE.md which one to follow.

npx skills add creates a skills-lock.json file in the project root that records the source and hash of the installed skills.
Commit this file to Git so everyone on the team uses the same skills.

For libraries without official skills, supplement them with a docs MCP.
If you connect a server such as Context7, Claude can answer by referring to the latest docs for Vue, Pinia, and Vue Router.
For APIs whose recommended usage changes between versions, answers that refer to the docs are more accurate than answers from memory.

# Writing skills

Make your own skills for the project-specific rules and procedures that official skills don't cover.

A skill consists of one folder and one SKILL.md file.
It works with just name and description in the frontmatter, and the body describes the procedure to perform.

/.claude/skills/check/SKILL.md
MD
1
2
3
4
5
6
7
8
9
10
11
12
--- name: check description: Runs type checking, linting, and unit tests in order. --- # Run verification Run the following commands in order, and if any command fails, report its output as is. 1. `npm run typecheck` 1. `npm run lint` 1. `npm run test:unit`
The simplest form of a skill

Once you save the file, the folder name becomes the slash command as is.

PROMPT
1
/check
Running it as a slash command

Instead of saying "verify it" and checking the results every time, you can run the same procedure in the same order.

As rules and commands grow, you need to decide what goes where.
The following three locations have different characteristics.

Location When it loads What to put there
CLAUDE.md Always Short decisions that apply to the whole project
.claude/rules/ Always; with paths, only when working on matching files Rule sets by area
.claude/skills/ Name and description always, body when used Tasks with procedures, slash commands

CLAUDE.md and rule files take up context in every session, so keep them short.
Skills, on the other hand, load their body only when actually used, so they cost little even when long.
Move work that has several steps or needs reference docs into skills.

Good candidates for skills in a Vue project are as follows.

  • Initial project setup: from Vite creation to ESLint, Prettier, path aliases, and folder structure, all at once
  • Adding a page: creating the page component, writing definePage, and connecting the layout, as one bundle
  • Component review: only reading and pointing out rule violations

Of these, you can restrict the tools the review skill uses so that files don't change during the review.

/.claude/skills/vue-review/SKILL.md
MD
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
--- name: vue-review description: | Reviews whether Vue components follow the project rules, without changing files. Use it when asked to review components or check for rule violations. vue review, SFC, code review. argument-hint: Path to review (e.g. src/components) allowed-tools: Read, Grep, Glob disallowed-tools: Edit, Write --- # Vue component review If a path is given as an argument, review only the `*.vue` files under that path; if there is no argument, review all of `/src`. Only read files and never change them. Check the following items in order and report only the violations. 1. Does it use `<script setup lang="ts">`? 1. Does it avoid `reactive()` and mixins? 1. Does `<style>` have `scoped`? 1. Are props declared with type-based `defineProps<T>()`? 1. Does it avoid creating derived values with `watch()`? (Could `computed()` replace it?) 1. Does it avoid putting data-fetching logic directly inside the component? 1. Does `import` avoid relative paths to parent folders instead of the `@/` alias? Give the location of each violation in `file:line` format.

allowed-tools is a list of tools that can be used right away without approval; it isn't a setting that restricts the other tools.
To actually block editing, you need to use disallowed-tools or a deny rule in the permission settings.

For work that reads many files, such as reviews, you can also hand it to a subagent.
A subagent runs in a separate context and passes only the result to the main session, so even if it checks 20 components, only a summary is left in the main conversation.
In this case, write the items to check directly in the prompt.

PROMPT
1
2
3
4
5
6
7
8
9
Use the Explore agent to go through all components under src/components. Check these four things. - Files that don't use <script setup lang="ts"> - Files that use reactive() or mixins - Files where <style> has no scoped - Files with data-fetching logic placed directly inside the component List only the violating files in `file:line` format.
Delegating a review to a subagent

# Finding the cause of bugs

If you only say Search doesn't work, Claude looks at the code and guesses the cause.
Sometimes it's right, but when it's wrong, code that had no problem gets changed too, and nothing records what the cause was.
To narrow down the cause, pass on what you observed as is.

There are four things to pass on.

Item Details
Steps to reproduce On which screen and after what action it happens
Expected vs. actual What you expected and what actually happened
Raw output Console errors, terminal output, and network responses, as is without summarizing
Scope Related file paths and recent changes

Of these, not summarizing the original error message is especially important.
Unlike I got a type error, a message like error TS2551: Property 'year' does not exist on type 'Movie' can include the error details, the file, the line number, the misspelled property name, and more, so the problem can be identified much more accurately.

PROMPT
1
Search doesn't work. Fix it.
A request with only the symptom
PROMPT
1
2
3
4
5
6
7
8
In @/pages/movies.vue, when I type "matrix" into the search box and press Enter, the list doesn't update and the previous results stay as they are. The console prints the following warning. [Vue warn]: Property "movies" was accessed during render but is not defined on instance. In the Network tab, the omdbapi.com request succeeded with 200, and the response contains a Search array. The related files are @/composables/useMovieSearch.ts and @/components/MovieList.vue.
A request with observed facts

Symptoms that keep recurring in Vue have a known set of candidate causes.
If you write this list in a rule file, the search scope narrows without explaining it every time.

Symptom Where to look
The screen doesn't update after changing a value Destructuring a reactive() object, a missing .value on a ref
Store values don't show up on screen Destructuring the store without storeToRefs()
A value is undefined in the template Mismatched props names, access before async data arrives
State gets mixed up when sorting or deleting list items Using the index as the key in v-for
The screen stays the same when the route changes Using only onMounted on a path that reuses the same component

It's better to ask for a diagnosis before a fix.
If you ask Claude to narrow the candidate causes down to a few and explain how to check each one, you can confirm the scope without changing any code.

PROMPT
1
2
3
Don't fix the code yet. Narrow down the candidate causes of the symptom above to at most 3, and tell me how to check each one. Suggest ways to check that don't change the code, using console output or Vue Devtools.
Asking only for the cause, without fixes

If you connect a browser, you can have Claude make these observations itself.
When you connect Claude in Chrome, Playwright MCP, or Chrome DevTools MCP, Claude operates the screen and checks the console and network responses, so the step where a person copies and passes on errors goes away.

PROMPT
1
2
3
The development server is running on port 5173. Open it in the browser, type "matrix" into the search box, and press Enter. Then read the console errors and the response of the omdbapi.com request, and tell me why the list doesn't appear.
Reproducing directly in the browser

Once you have confirmed the cause, write a test before fixing it.
You need a test that fails under that condition to confirm that the fix actually solved the problem, and it also keeps the same bug from coming back.

PROMPT
1
2
Once you have confirmed the cause, first write a test that fails under that condition. After you confirm the test fails, make the fix, and finally run the unit tests.
Writing a reproduction test first

When there are several symptoms, don't pass them all on at once.
If the causes differ, the fixes get mixed together, and you can't tell which change fixed what.

# Refactoring

When you work with AI, code piles up quickly, and some of it stays slightly off from the rules.
If you leave code that strays, it becomes a reference for the next task and the same approach gets copied, so it costs less to bring it back in line before it piles up.
Refactoring is the work of cleaning up these deviations regularly.

Refactoring is a change that doesn't change behavior.
Without a way to confirm the behavior stays the same, you can't tell refactoring apart from feature changes, so there is a set order.

  1. Lock in the target's current behavior with tests.
  2. In Plan mode, decide what to change and how far.
  3. Make the change.
  4. Get typecheck, lint, and test:unit all to pass.

If you skip the first step, the only way to check after refactoring is to look at the screen with your own eyes.

PROMPT
1
2
3
Before refactoring @/components/MovieCard.vue, first write tests that check how it works now. Checking the rendering output and the select event is enough. Stop after confirming the tests pass, and don't refactor yet.
Locking in current behavior with tests

What to fix is already written in the rule files.
Each line of .claude/rules/vue.md becomes a check item as is, so you can extract the violation list with the review skill we made earlier and use that list as your task list.

PROMPT
1
2
3
4
/vue-review src/components Of the reported violations, fix only the reactive() usage and missing scoped first. Run npm run typecheck each time you fix a file, and move on to the next file when it passes.
Using the violation list as a task list

Refactorings that keep coming up in Vue projects are mostly the following five.

Target Why change it
State declared with reactive() Loses reactivity with primitives and breaks on destructuring
Derived values made with watch() State grows, and keeping the two values in sync falls on people
Data requests inside components Components grow, and the same request can't be reused
Relative paths that climb to parent folders Paths break every time you move a file
Components written with export default Their structure differs from the rest of the files, so they cost more to read

Of these, splitting a component that has grown large takes judgment.
Splitting by line count creates meaningless pieces, so a person decides where to split and includes that boundary in the instructions.

PROMPT
1
2
3
4
5
6
7
8
9
10
11
12
@/pages/movies.vue has grown past 300 lines. Split only the search input part into a SearchBar component. Props: modelValue: string Emits: update:modelValue: (value: string) => void search: () => void - movies.vue keeps managing the search term state. - SearchBar handles only input and submission, and doesn't call the API directly. - Existing tests must still pass after the split.
Asking for a split with defined boundaries

Avoid requests like Refactor everything.
When many files change at once, reviewing gets hard, and you lose a point to roll back to when something goes wrong.
It's better to keep a cycle of making the smallest change, verifying it, and then committing.

# PR review

If the review skill checks whether a single file follows the rules, a PR review uses different criteria.
It looks at how the changed files connect to each other, and whether this change has disturbed where state lives and the component boundaries.
That's because each file can follow the rules while the overall structure still goes wrong.

For the general flow of branches, PRs, and merges, see Understanding GitHub Flow Through Examples.
Here, we focus on what criteria to use when looking at changes in a Vue project.

Before opening a PR, check only the changes locally first.
If you target the difference between the working branch and main, files you didn't touch this time are left out of the review.

PROMPT
1
2
3
4
5
6
7
Check the difference between main and the current branch, and review only these changes. Don't modify any files, and summarize the following four things in order. 1. Where the state added or moved by this change lives 2. Whether data flow between components goes outside props and emits anywhere 3. Parts that break the rules in .claude/rules/ 4. New components or composables without tests
Reviewing the changes

After applying the feedback, run the verification commands again.
New type errors can appear while you apply feedback, so it's safer to bundle applying and verifying into one instruction.
If you note the items you won't apply along with the reason, the same feedback won't come up again in the next review.

PROMPT
1
2
3
Apply feedback items 1 and 3. Item 2 is out of scope for this work, so leave it as is, and write one line in the PR description about why it's left. When you're done, run typecheck, lint, and test:unit in order and confirm they pass.
Applying feedback and re-verifying

Once verification passes, create the PR.
Claude uses the gh command, so you need to install GitHub CLI and sign in first.

BASH
1
2
3
4
5
6
7
8
## macOS brew install gh ## Windows winget install --id GitHub.cli --source winget gh auth login gh auth status
Installing GitHub CLI and signing in

gh auth login uses the browser and interactive input, so run it yourself instead of leaving it to Claude.
If you don't sign in, gh pr create fails before sending the request.
Register the gh command in the project settings too, so Claude doesn't ask for approval every time.

/.claude/settings.json
JSON
1
2
3
4
5
{ "permissions": { "allow": ["Bash(gh:*)"] } }
Allowing gh commands
PROMPT
1
2
Clean up the commits on the current branch and create a PR. Write the title in one line, and in the description, include the changed screens, the role of the new files, and the results of the verification commands.
Creating a PR

Be sure to read the PR description yourself before submitting it.
Claude writes it by looking only at the commit history, so it can sometimes include work that wasn't actually done.

# Measuring performance

Once the screens are done, check performance with numbers.
Lighthouse is a tool that actually opens a page once and scores its performance, accessibility, best practices, and SEO.

Scores measured on the development server (npm run dev) are meaningless.
That's because source maps and HMR code are loaded along with it, and the bundle isn't minified.
Measure on the result of npm run build > npm run preview!

The performance score is a combination of several metrics.
In a Vue project, the metrics you can actually work on are the following three.

Metric Meaning Common causes
LCP Time until the largest content is displayed First-screen bundle size, poster image file size
CLS How much the screen shifts Images without set sizes, lists inserted after loading
INP Time until the screen responds to input Recalculating a large list on every input

INP is measured only when a user actually types or clicks, so instead of INP, Lighthouse's performance score includes TBT (Total Blocking Time), the total time of long tasks that block input.

You can automate the measurement with a skill.
The following skill detects the framework from the project's config files, asks whether to measure the development server or the production build, and then handles the build and running the preview server.

BASH
1
npx skills add ParkYoungWoong/skills --skill lighthouse
Installing the Lighthouse skill
PROMPT
1
/lighthouse
Measuring with a slash command

If you pass on the measurement results as they are, Claude organizes the items to improve.
It's good to also set the scope of what may be changed.

PROMPT
1
2
From the Lighthouse results, pick three items that lower the performance score, and find the cause of each in the code. Before fixing anything, tell me what you'll change, and don't change any behavior visible on screen.
Asking for improvements based on the results

Common improvements in Vue projects are as follows.
Most of them can be added to the rule files in advance, so it's better to write them as rules from the start than to fix them later.

Improvement How
Shrink the first-screen bundle Keep the automatic lazy loading of file-based routing, and make an exception only for the first screen with importMode
Remove shifts caused by images Set width and height on <img>
Lazy-load offscreen images Set loading="lazy" on <img>
Reduce requests on every input Debounce the search term with VueUse's refDebounced
Find what makes the bundle large Check its makeup with npx vite-bundle-visualizer

If you aim only for the score, on-screen behavior can end up being changed.
Find the cause of this item and improve it is much safer than Raise the performance score to 90 or higher.
After improving, check with unit tests or E2E tests that the behavior hasn't changed.

# Deployment

At the deployment stage, you need to check things such as whether a 404 occurs when you type an address directly or refresh, and whether environment variables were included in the build correctly.

First, move values you wrote directly in the code, such as API keys, into environment variables.
Vite exposes only variables with the VITE_ prefix to client code, so include the naming rule in your instructions.

PROMPT
1
2
3
4
Move the OMDb API Key into an environment variable. Name the variable VITE_OMDB_API_KEY, and add .env.local to .gitignore, since I'll create it and put the value in myself. Write only the name in .env.example. Also define the type of import.meta.env.
Asking to move values into environment variables

Because of the deny rules registered in Project setup, Claude can't read .env.local and can't create a new file at the same path either.
So you create .env.local yourself, and your instructions should contain the variable name, not the value.

Deny rules restrict Claude from opening or editing the file directly.
However, they can't stop a program that Claude runs from reading that file.

Restricted Not restricted
Claude reading directly: Read, Grep, @file mentions, commands like cat A script Claude runs reading it: node script.js
Claude editing directly: Edit, Write on the same path A build tool reading values and including them in the output: npm run build

Deny rules apply to the tools and commands Claude Code runs.
To restrict access at the process level, you need to enable the sandbox.

/src/vite-env.d.ts
TS
1
2
3
4
5
6
7
8
9
/// <reference types="vite/client" /> interface ViteTypeOptions { strictImportMetaEnv: unknown } interface ImportMetaEnv { readonly VITE_OMDB_API_KEY: string }
Environment variable type definitions

By default, ImportMetaEnv has an index signature that accepts any name.
So even if you declare the types, code with a misspelled name like import.meta.env.VITE_OMDB_KEY passes as any.
If you declare strictImportMetaEnv in ViteTypeOptions, this signature goes away, and type checking catches it the moment you use a name that isn't defined.

BASH
1
2
3
4
npm run typecheck ## error TS2551: Property 'VITE_OMDB_KEY' does not exist on type 'ImportMetaEnv'. ## Did you mean 'VITE_OMDB_API_KEY'?
When the name is misspelled

Variables with the VITE_ prefix are included in the build output as plain strings.
Anyone can see them in the browser, so you must not put secret keys in them.

Next is the refresh problem.
To use Vue Router's HTML5 mode, you need a server setting that sends every request to index.html.
Without this setting, moving from the list to a detail page works, but opening a detail page's address directly returns a 404.

/vercel.json
JSON
1
2
3
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }

Check the build settings before deploying, too.
Vercel detects the framework and fills in the build command and output folder automatically, but if you changed the scripts in package.json, they can get out of sync.

PROMPT
1
2
3
Before deploying, check that the build works. Run npm run build and tell me the output folder name, the output size, and any warnings. Then tell me the Vercel build command and output folder values that fit this project.
Checking the build settings

If you connect the GitHub repository to a Vercel project, deployment runs automatically on every push.

  1. Sign in to Vercel with your GitHub account and select Add New > Project.
  2. Import the repository to deploy, and check the detected framework, build command, and output folder (dist).
  3. Register VITE_OMDB_API_KEY and its value in Environment Variables.
  4. Click Deploy to run the first deployment.

The main branch is deployed to production, and other branches and PRs are deployed as previews, so the PR you created earlier gets its own address that you can use for checking.
You can set different environment variable values for production, preview, and local development.
The local .env.local isn't uploaded to the repository, so unless you register the values separately, they don't exist in the deployment environment.

Vite's environment variables are replaced with strings at build time.
Even if you change a value in Vercel, it isn't reflected until you deploy again.

Check the deployment status in Deployments on the Vercel dashboard.
Each deployment keeps a build log, so when it fails, the quickest approach is to copy the last error in the log and pass it on as is.
If it passes locally but fails only on deployment, the cause is usually one of a known few.

Symptom Cause
Build fails with type errors Only dev was run locally, without typecheck
A file with different letter case can't be found macOS doesn't distinguish letter case, but the deployment environment does
An environment variable is undefined The value wasn't registered in Vercel, or it wasn't redeployed after registering
404 only on detail paths The rewrite setting in vercel.json is missing

Once deployment is done, you can leave the checking to Claude too.

PROMPT
1
2
3
Visit the deployed address and check that search works. Also check that a detail path like /movies/tt0111161 doesn't return a 404 when typed directly into the address bar. If there are console errors, give me the original text without summarizing.
Checking the deployment

# Summary

  1. Create a Vue + TypeScript project with Vite, and configure ESLint, Prettier, and path aliases.
  2. Add the typecheck and lint scripts, and register run permissions in .claude/settings.json.
  3. Create a CLAUDE.md draft with /init, then write the Vue rules yourself.
  4. Try making a component without rules, then repeat the same request after adding rules and compare the results.
  5. Plan the file layout of the movie search screen in Plan mode, and build components with the interface decided first.
  6. Move the search logic into a composable, and catch template errors with typecheck.
  7. When rules grow, split them into .claude/rules/, and write the Pinia and router rules.
  8. Create a store with the /new-store slash command, and reflect the route rules in types by extending RouteMeta.
  9. Write unit tests with Vue Test Utils, and automate checks with a PostToolUse hook.
  10. Install an official skill with npx skills add vueuse/skills, and compare the results of the same request before and after installing.
  11. Make a review skill and review all the components you've made so far.
  12. Plant a bug and compare how the response differs when you pass on only the symptom versus what you observed.
  13. Refactor using the violation list from the review skill as your task list, and confirm the tests still pass.
  14. Create a branch, open a PR, and run through one loop of reviewing the changes and applying feedback.
  15. Measure the production build with the Lighthouse skill, fix one flagged item, and measure again.
  16. Move the API key into an environment variable, ask Claude to read .env.local, and confirm that the deny rule actually restricts it.
  17. Deploy to Vercel, then check by typing a detail path directly into the address bar.

A person decides, locks the decision in documents, and checks with tools that it was followed.
If only instructions grow without decisions and verification, code piles up quickly, but its direction easily drifts.