A new Vue project starts out full of files you did not write, in a format (.vue) that is neither plain HTML nor plain JavaScript. Below, you create one with the official scaffolding tool, read its main component block by block, and then write your own: a button that counts its clicks. You need a terminal, a text editor and Node.js.
Create the project
Node.js is the program that runs JavaScript outside a browser. The project scaffolder needs Node 18.3 or newer; if you are installing it now, pick 22, the active long-term support release. Then run one command in a folder where you keep projects:
npm create vue@latest
This downloads and runs create-vue, the official scaffolding tool. Version 3.13 is current. It prints "Vue.js - The Progressive JavaScript Framework" and asks a series of questions. Answer them like this for now:
- Project name:
hello-vue - Add TypeScript? Yes
- Add JSX Support? No
- Add Vue Router for Single Page Application development? No
- Add Pinia for state management? No
- Add Vitest for Unit Testing? No
- Add an End-to-End Testing Solution? No
- Add ESLint for code quality? No
Yes to TypeScript is the only answer that matters here. TypeScript adds types to JavaScript, so your editor can tell you about a mistake before you run anything. In VS Code, install the Vue extension the project recommends (Vue.volar, listed in .vscode/extensions.json); without it, the editor does not type-check .vue files. Everything else can be added later.
The 3.13 release also added a --bare flag: npm create vue@latest -- --bare asks the same questions but leaves out the example components, styles and logo. Keep it for a later project. This time, use the plain command, because the next sections read those example files.
Start the development server
When create-vue finishes, it says "Done. Now run:" and shows three commands:
cd hello-vue
npm install
npm run dev
cd hello-vuemoves into the new project folder.npm installdownloads the dependencies, including Vue 3.5 and Vite 6. Vite is the development server and build tool: it serves your files to the browser and updates the page when you save.npm run devstarts Vite and prints a local address, usuallyhttp://localhost:5173.
Open that address and you will see a welcome page.
How index.html and main.ts start the app
Open the project in your editor. The files that matter for now are in src/:
main.tscreates the app and attaches it to the page.App.vueis the root component, the one everything else hangs off.components/holds the example components the welcome page is made of.assets/holds CSS and the logo.
index.html at the project root has one <div id="app"></div> and a script tag that loads main.ts. That file is short:
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
The first line loads the global styles. createApp(App) builds a Vue application whose root component is App. .mount('#app') renders it into the div with that id. That is the whole bridge between the HTML page and Vue.
Read App.vue top to bottom
A .vue file is called a single-file component, or SFC. It keeps the logic, the markup and the styles of one component in one file. The generated App.vue is longer than you need, so here is its shape, with the <div class="wrapper"> around HelloWorld and most of the styles left out:
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
import TheWelcome from './components/TheWelcome.vue'
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
<HelloWorld msg="You did it!" />
</header>
<main>
<TheWelcome />
</main>
</template>
<style scoped>
header {
line-height: 1.5;
}
</style>
Three blocks. Take them one at a time.
<script setup lang="ts"> is where the logic lives. setup tells Vue that everything declared at the top level of this block is available in the template, with no exporting or returning. lang="ts" says the code is TypeScript. Here the script only imports two components. Importing a component is enough to use it as a tag.
<template> is the markup. It looks like HTML, and mostly it is HTML, with a few additions. <HelloWorld msg="You did it!" /> uses the imported component as if it were an element and passes it a prop, which is a value handed from a parent to a child.
Notice there are two top-level elements, <header> and <main>. Vue 3 allows that. Vue 2 required a single root element, so older tutorials wrap the whole template in one <div>. In Vue 3 that wrapper is optional, and most of the time it only adds an extra div to your page.
<style scoped> is ordinary CSS. The scoped attribute means the header rule applies only to this component's elements, so a header in some other component is not affected.
Build Counter.vue
Create a new file at src/components/Counter.vue:
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">Clicked {{ count }} times</button>
</template>
<style scoped>
button {
padding: 8px 16px;
font-size: 1rem;
}
</style>
ref(0) creates a ref: a small object that holds a value (here the number 0) and lets Vue notice when that value changes. In script you read and change the value through .value, so count.value++ adds one.
@click="increment" runs the function when the button is clicked. The @ is short for v-on:, which listens for a DOM event.
{{ count }} is an interpolation: the double braces print a value as text. In the template Vue unwraps the ref for you, so you write count, not count.value.
Now use it. Replace the contents of App.vue with something smaller:
<script setup lang="ts">
import Counter from './components/Counter.vue'
</script>
<template>
<h1>Hello, Vue</h1>
<Counter />
</template>
Save, look at the browser, click the button. The number goes up. You did not write any code that finds the button or sets its text. You changed a value, and Vue updated the page.
What the compiler does with your template
A browser cannot run a <template> block. When Vite serves Counter.vue, its Vue plugin (@vitejs/plugin-vue, already in your project) compiles the file into plain JavaScript. The template becomes a render function: a function that returns a description of the DOM, which Vue calls a virtual DOM.
The compiler also works out what can change. In <button @click="increment">Clicked {{ count }} times</button>, only the button's text depends on data, so the compiler flags that text and nothing else. When count changes, Vue re-runs the render function and compares the result with the previous one, checking only the flagged parts. It then updates the button's text in the real page and leaves everything else alone.
That is why you do not write DOM updates yourself in Vue: you describe what the page should look like for a given state, and the compiled component does the rest.
Two common mistakes with refs
Both are about .value. The first is forgetting it in script:
function increment() {
count++
}
count is the ref object, not the number. count++ tries to replace that object with a new value, which a const variable does not allow. With TypeScript and the Vue extension, your editor underlines it: Cannot assign to 'count' because it is a constant. Changing const to let only swaps that message for another, because the number you want to change lives in count.value.
Without TypeScript, the first sign may be the click itself: the number stays the same and the browser console shows a TypeError (Chrome words it Assignment to constant variable.). The fix is the line you saw earlier:
function increment() {
count.value++
}
The second is writing .value in the template: {{ count.value }}. The template already unwrapped count into a number, and a number has no value property, so the page shows nothing where the count should be. In the template, leave .value off.
Change one thing before you move on. Make the button count down, or start at 10, or show "once" instead of "1 times". Each change you make is a small test of whether the three blocks make sense to you yet.