Write your first components — Single-File Components, template syntax, props, conditional rendering, lists and keys, emits, and composition with slots.
Why: everything on a Vue screen is a component — one .vue file (a Single-File Component, or SFC) holding the logic in <script setup> and the markup in <template>. Note: in a Nuxt app this file goes in app/components/ and Nuxt auto-imports it — no import line needed to use it.
<!-- app/components/Welcome.vue -->
<!-- A component is one .vue file: logic on top, markup below -->
<script setup lang="ts">
const name = 'Ada'
const frameworks = 2 + 1
</script>
<template>
<section>
<!-- Double braces embed any JavaScript expression -->
<h1>Hello, {{ name }}!</h1>
<p>You know {{ frameworks }} frameworks.</p>
</section>
</template>
<!-- Use it like a tag anywhere else — Nuxt auto-imports it:
<Welcome /> -->Why: the template looks like HTML plus three additions you will use constantly — {{ braces }} for text, a colon (:) to bind an attribute to a JavaScript value, and an at-sign (@) to listen to an event. Note: : is shorthand for v-bind and @ is shorthand for v-on; you will almost always see the short forms.
<script setup lang="ts">
const imageSrc = '/photo.svg'
const isDisabled = true
function save() {
console.log('saved at', Date.now())
}
</script>
<template>
<div>
<!-- : binds the attribute to a JavaScript value, not a string -->
<img :src="imageSrc" alt="Logo" />
<button :disabled="isDisabled">Can't click me</button>
<!-- @click listens for the event — pass the function, don't call it -->
<button @click="save">Save</button>
<!-- Any expression works inside a binding -->
<p :class="isDisabled ? 'muted' : 'active'">Status</p>
</div>
</template>Why: props are how a parent passes data down to a child. They are read-only — a component never modifies its own props. Note: defineProps<{…}>() types them, and destructuring with = sets the fallback for an optional one.
<!-- app/components/Badge.vue -->
<script setup lang="ts">
// Describe the props as a type — the ? marks an optional one
// Destructure them; = 0 is the fallback when count is omitted
const { label, count = 0 } = defineProps<{
label: string
count?: number
}>()
</script>
<template>
<span>{{ label }}: {{ count }}</span>
</template>
<!-- app/components/Header.vue — using it -->
<template>
<header>
<!-- Strings go in quotes; everything else needs the : binding -->
<Badge label="Inbox" :count="3" />
<Badge label="Drafts" /> <!-- count falls back to 0 -->
</header>
</template>Why: components constantly show different UI for different data — signed in or not, empty or full, admin or member. v-if / v-else-if / v-else add and remove elements; v-show keeps the element in the page and just hides it with CSS, which is cheaper when something toggles often.
<script setup lang="ts">
type User = { name: string; unread: number; isAdmin: boolean }
const user: User | null = { name: 'Ada', unread: 2, isAdmin: false }
</script>
<template>
<!-- v-if removes the element entirely when the condition is false -->
<p v-if="!user">Please sign in.</p>
<div v-else>
<h2 v-if="user.isAdmin">Admin Console</h2>
<h2 v-else>Your Inbox</h2>
<!-- v-show hides with CSS instead — better for frequent toggles -->
<p v-show="user.unread > 0">{{ user.unread }} unread messages</p>
</div>
</template>Why: rendering arrays is v-for plus a :key. The key tells Vue which item is which between updates, so it can reorder instead of rebuilding. Note: use a stable id from your data — the array index breaks reordering, inserts, and input state.
<script setup lang="ts">
type Task = { id: number; title: string; done: boolean }
const tasks: Task[] = [
{ id: 1, title: 'Learn templates', done: true },
{ id: 2, title: 'Master reactivity', done: false },
{ id: 3, title: 'Ship an app', done: false },
]
</script>
<template>
<ul>
<!-- :key must be stable and unique — use the data's id, never the index -->
<li v-for="task in tasks" :key="task.id">
{{ task.done ? '[x]' : '[ ]' }} {{ task.title }}
</li>
</ul>
</template>Why: props go down, events come up. A child announces that something happened with emit, and the parent listens with @event-name — the child never reaches into the parent directly. Note: defineEmits<{…}>() types each event and its payload.
<!-- app/components/AddButton.vue -->
<script setup lang="ts">
// The events this component can send up, with their payload types
const emit = defineEmits<{ add: [text: string] }>()
</script>
<template>
<button @click="emit('add', 'New task')">Add task</button>
</template>
<!-- app/pages/index.vue — the parent listens with @add -->
<script setup lang="ts">
function handleAdd(text: string) {
console.log('child sent:', text)
}
</script>
<template>
<AddButton @add="handleAdd" />
</template>Why: instead of components with dozens of props, build a frame and let callers fill it. <slot /> renders whatever the caller nests between the tags — this is the pattern behind every Card, Layout, and Modal you will ever write.
<!-- app/components/Card.vue — the component owns the frame -->
<script setup lang="ts">
defineProps<{ title: string }>()
</script>
<template>
<section class="card">
<h3>{{ title }}</h3>
<!-- <slot> renders whatever the caller nests between the tags -->
<slot />
</section>
</template>
<!-- app/pages/index.vue — the caller owns the content -->
<template>
<Card title="Revenue">
<p>$12,400 this month</p>
<button>Details</button>
</Card>
</template>