Building Vue Apps with Claude Code
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.
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.
12345678910{ "scripts": { "dev": "vite", "build": "vue-tsc -b && vite build", "preview": "vite preview", "typecheck": "vue-tsc --build", "lint": "eslint .", "test:unit": "vitest run" } }
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.
1234567891011121314{ "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.
# 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.
123456789101112## 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.
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.
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.
1234567├─.claude/ │ ├─rules/ │ │ ├─vue.md │ │ ├─pinia.md │ │ └─router.md │ └─settings.json └─CLAUDE.md
Leave only the project overview and commands in CLAUDE.md.
1234567891011# 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.
12345678910--- paths: - "src/**/*.vue" - "src/composables/**/*.ts" --- # Vue coding rules - Every component uses `<script setup lang="ts">`. - Use `ref()` by default for reactive state.
A rule file without paths is loaded unconditionally in every session and has the same priority as .claude/CLAUDE.md.
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.
1Show the release year in the MovieCard component.
12Edit 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!
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.
1234567891011Create @/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.
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.
1234567891011: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; }
12345- 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.
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.
123I 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.
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.
1234567891011121314151617181920212223import { 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 } }
# 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.
12345678# 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.
123456789101112131415161718--- 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.
1/new-store movie
$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.
123456789101112# 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.
1234567<script setup lang="ts"> definePage({ meta: { auth: true } }) </script>
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.
123456789import 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 } }) }
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.
123456789import 'vue-router' declare module 'vue-router' { interface RouteMeta { auth?: boolean guestOnly?: boolean layout?: 'Default' | 'Empty' } }
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.
# 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.
1234npm run typecheck ## src/components/MovieCard.vue:14:20 - error TS2551: ## Property 'year' does not exist on type 'Movie'. Did you mean 'Year'?
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.
1npm i -D vitest @vue/test-utils jsdom
123456789101112import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], resolve: { alias: [{ find: '@', replacement: '/src' }] }, test: { environment: 'jsdom' } })
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.
12345678910111213141516171819import { 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']) }) })
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.
12345678910111213141516{ "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.
12345678910111213141516import { 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.
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.
1claude --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.
12/plugin install playwright@claude-plugins-official /plugin install chrome-devtools-mcp@claude-plugins-official
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.
12claude mcp add playwright -- npx @playwright/mcp@latest claude mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
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.
123The 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.
# 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.
1Apply a debounce to the search input.
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.
12345678{ "mcpServers": { "nuxt-ui": { "type": "http", "url": "https://ui.nuxt.com/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.
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.
123456789101112--- 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`
Once you save the file, the folder name becomes the slash command as is.
1/check
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.
123456789101112131415161718192021222324--- 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.
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.
123456789Use 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.
# 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.
1Search doesn't work. Fix it.
12345678In @/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.
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.
123Don'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.
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.
123The 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.
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.
12Once 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.
# 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.
- Lock in the target's current behavior with tests.
- In Plan mode, decide what to change and how far.
- Make the change.
- Get
typecheck,lint, andtest:unitall 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.
123Before 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.
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.
1234/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.
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.
123456789101112@/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.
# 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.
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.
1234567Check 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
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.
123Apply 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.
Once verification passes, create the PR.
Claude uses the gh command, so you need to install GitHub CLI and sign in first.
12345678## macOS brew install gh ## Windows winget install --id GitHub.cli --source winget gh auth login gh auth status
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.
12345{ "permissions": { "allow": ["Bash(gh:*)"] } }
12Clean 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.
# 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.
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.
1npx skills add ParkYoungWoong/skills --skill lighthouse
1/lighthouse
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.
12From 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.
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 |
# 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.
1234Move 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.
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 |
123456789/// <reference types="vite/client" /> interface ViteTypeOptions { strictImportMetaEnv: unknown } interface ImportMetaEnv { readonly VITE_OMDB_API_KEY: string }
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.
1234npm run typecheck ## error TS2551: Property 'VITE_OMDB_KEY' does not exist on type 'ImportMetaEnv'. ## Did you mean 'VITE_OMDB_API_KEY'?
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.
123{ "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.
123Before 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.
If you connect the GitHub repository to a Vercel project, deployment runs automatically on every push.
- Sign in to Vercel with your GitHub account and select
Add New>Project. - Import the repository to deploy, and check the detected framework, build command, and output folder (
dist). - Register
VITE_OMDB_API_KEYand its value inEnvironment Variables. - Click
Deployto 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.
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.
123Visit 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.
# Summary
- Create a Vue + TypeScript project with Vite, and configure ESLint, Prettier, and path aliases.
- Add the
typecheckandlintscripts, and register run permissions in.claude/settings.json. - Create a
CLAUDE.mddraft with/init, then write the Vue rules yourself. - Try making a component without rules, then repeat the same request after adding rules and compare the results.
- Plan the file layout of the movie search screen in Plan mode, and build components with the interface decided first.
- Move the search logic into a composable, and catch template errors with
typecheck. - When rules grow, split them into
.claude/rules/, and write the Pinia and router rules. - Create a store with the
/new-storeslash command, and reflect the route rules in types by extendingRouteMeta. - Write unit tests with Vue Test Utils, and automate checks with a
PostToolUsehook. - Install an official skill with
npx skills add vueuse/skills, and compare the results of the same request before and after installing. - Make a review skill and review all the components you've made so far.
- Plant a bug and compare how the response differs when you pass on only the symptom versus what you observed.
- Refactor using the violation list from the review skill as your task list, and confirm the tests still pass.
- Create a branch, open a PR, and run through one loop of reviewing the changes and applying feedback.
- Measure the production build with the Lighthouse skill, fix one flagged item, and measure again.
- Move the API key into an environment variable, ask Claude to read
.env.local, and confirm that the deny rule actually restricts it. - 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.
Thanks for reading to the end.
Likes and kind comments help keep this blog going!