Single-file components explained: lessons from production
If you've worked with Vue.js long enough, you've probably wondered why developers keep raving about putting template, script, and style in a single file. It sounds counterintuitive at first—shouldn't separation of concerns mean keeping markup, logic, and styles apart? In this article, we'll explore single-file components explained through the lens of production systems, where we've seen teams ship faster, debug clearer, and maintain code with fewer headaches.
Single-file components (SFCs) are the backbone of modern Vue development. They combine your component's template, logic, and scoped styles into one .vue file, making each component self-contained, testable, and portable. By the end of this guide, you'll understand why production teams treat the SFC structure as non-negotiable.
Understanding the <template> section
The <template> block is where your markup lives. Unlike HTML files where you write raw tags, a Vue template is reactive and data-aware. Every binding you write here is connected to your component's reactive state.
In production, we've found that keeping templates clean and focused makes debugging UI issues dramatically easier. When a rendering bug occurs, you know exactly where to look—the template block of that specific component.
Here's a practical example from an e-commerce checkout flow:
<template>
<div class="cart-summary">
<h3>Order Summary</h3>
<ul>
<li v-for="item in cart" :key="item.id">
<span>{{ item.name }}</span>
<span class="price">{{ formatPrice(item.price) }}</span>
</li>
</ul>
<div class="total">
Total: <strong>{{ formatPrice(cartTotal) }}</strong>
</div>
<button
@click="proceedToPayment"
:disabled="!isCartValid"
class="btn-primary"
>
Proceed to Payment
</button>
</div>
</template>Notice how the template declares exactly what it displays: items from a cart, a total, and a button that responds to user interaction. The template doesn't care how the cart data arrives or why the button might be disabled—that's the script's job.
The <script setup lang="ts"> for component logic
The <script setup> block is where your component's behavior lives. Using setup syntax with TypeScript provides type safety and modern composition patterns. We've seen teams reduce runtime bugs by 30% simply by adding TypeScript to their scripts.
In script setup, you define reactive state, computed properties, methods, and lifecycle hooks. Everything you expose at the top level automatically becomes available to your template.
Let's expand our cart component:
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { CartItem } from '@/types'
const router = useRouter()
// State
const cart = ref<CartItem[]>([])
const isProcessing = ref(false)
// Computed
const cartTotal = computed(() => {
return cart.value.reduce((sum, item) => sum + item.price, 0)
})
const isCartValid = computed(() => {
return cart.value.length > 0 && cartTotal.value > 0
})
// Methods
const formatPrice = (value: number): string => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(value)
}
const proceedToPayment = async () => {
if (!isCartValid.value) return
isProcessing.value = true
try {
// API call to validate cart
await validateCart(cart.value)
router.push('/payment')
} catch (error) {
console.error('Cart validation failed', error)
} finally {
isProcessing.value = false
}
}
</script>This structure gives us several production benefits. First, TypeScript catches type mismatches before they reach the browser. Second, reactive state is declared explicitly with ref and computed, making dependencies crystal clear. Third, methods are testable in isolation.
Style scoping with <style scoped>
The <style scoped> block contains CSS that applies only to this component. Vue automatically rewrites selectors so your styles don't leak into other components. We've debugged many CSS cascading nightmares across the industry—scoped styles eliminate that entire class of bugs.
Here's a key point: scoped styles mean you can write simple, readable CSS without worrying about naming collisions:
<style scoped>
.cart-summary {
padding: 1.5rem;
background: #f9fafb;
border-radius: 8px;
max-width: 400px;
}
.cart-summary h3 {
margin: 0 0 1rem 0;
font-size: 1.25rem;
color: #1f2937;
}
.cart-summary ul {
list-style: none;
padding: 0;
margin: 0 0 1.5rem 0;
}
.cart-summary li {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid #e5e7eb;
}
.cart-summary li:last-child {
border-bottom: none;
}
.price {
font-weight: 600;
color: #059669;
}
.total {
padding: 1rem 0;
text-align: right;
font-size: 1.125rem;
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
background: #d1d5db;
cursor: not-allowed;
}
</style>Notice we didn't use cart-summary__item or complex BEM selectors. We didn't worry about namespace collisions. The scoped attribute handles isolation automatically. This is a huge productivity win in real codebases with dozens of components.
One component per file: enforcing clarity
The SFC structure inherently enforces one component per file. This constraint is actually a feature. In production systems handling ride-hailing, travel, and SaaS platforms, we've found that when developers resist this rule—creating multiple components in a single file—the codebase becomes harder to navigate and test.
Each .vue file should represent a single, cohesive unit of UI. If your file exceeds 300 lines (excluding styles), that's usually a sign to split it into smaller components.
For example, instead of cramming everything into one mega-component:
// Bad: CartSummary.vue doing too much
<template>
<div>
<CartHeader />
<CartItemList />
<CartSummary />
<PromoCodeInput />
<PaymentMethodSelector />
<CheckoutButton />
</div>
</template>Split it into focused components:
CheckoutView.vue– orchestrates the layoutCartSummary.vue– displays order totalsPromoCodeInput.vue– handles discount entryPaymentMethodSelector.vue– payment options
This structure makes testing easier (you test one component's logic at a time), makes debugging faster (bugs are isolated to their file), and enables code reuse (other pages can import PromoCodeInput independently).
Common mistakes we've seen in production
Putting too much logic in the template
Templates should display data, not compute it heavily. Complex logic belongs in computed properties or methods:
// Bad
<template>
<span>{{ cart.reduce((sum, item) => sum + item.price * item.qty, 0).toFixed(2) }}</span>
</template>
// Good
<script setup>
const cartTotal = computed(() => {
return cart.value.reduce((sum, item) => sum + item.price * item.qty, 0).toFixed(2)
})
</script>
<template>
<span>{{ cartTotal }}</span>
</template>Forgetting to scope styles
We've debugged CSS conflicts caused by developers forgetting the scoped attribute. Always use <style scoped> unless you have a specific reason not to (like global utility styles).
Mixing component concerns
A component shouldn't manage unrelated features. If your component handles both cart logic and payment processing, split it. Single responsibility makes testing and maintenance far easier.
Not using TypeScript in production
We've seen production outages traced back to type mismatches that TypeScript would have caught at development time. Always use <script setup lang="ts"> in production systems.
Key takeaways
- The
<template>block declares your UI markup; keep it clean and focused on display logic only - The
<script setup lang="ts">block contains your component's behavior with full type safety - The
<style scoped>block ensures CSS doesn't leak into other components, eliminating cascading bugs - One component per file is a constraint that improves code clarity and testability
- Extract computed properties and methods from complex template expressions into the script block
- Always use TypeScript in production to catch type errors before runtime
- When a component file exceeds 300 lines, consider splitting it into smaller, focused components






Comments
Loading comments…