Understanding the Vue to Svelte Migration
Migrating from Vue to Svelte represents a shift from a runtime-heavy reactive framework to a compile-time approach where the bulk of the work happens during the build process. Vue relies on a virtual DOM and reactive dependency tracking at runtime, while Svelte compiles your components into highly optimized vanilla JavaScript that directly manipulates the DOM. This fundamental difference means that Svelte applications ship with no framework runtime overhead, resulting in smaller bundle sizes and faster initial load times.
This tutorial walks you through the complete migration process, covering component syntax, reactivity, state management, routing, and testing. You will learn how to translate Vue patterns into idiomatic Svelte code, understand the key architectural differences, and avoid common pitfalls during the migration.
Why Migrate from Vue to Svelte?
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Several compelling reasons drive teams and developers toward Svelte:
- No virtual DOM overhead: Svelte compiles away the framework, producing lean JavaScript that updates the DOM directly. This means faster rendering and lower memory consumption.
- Smaller bundle sizes: Without a runtime library, production builds are significantly smaller. A typical Svelte component compiles to a few kilobytes of vanilla JS.
- True reactivity without proxies: Svelte uses simple assignment-based reactivity that feels natural and requires no special proxy wrappers like Vue's
ref()orreactive(). - Simpler mental model: Single-file components with explicit
script,style, and markup sections reduce the cognitive overhead compared to Vue's Options API or Composition API patterns. - First-class animation support: Svelte includes built-in transition and animation directives that are incredibly easy to use and require zero external libraries.
Key Conceptual Differences
Before diving into code, understand these fundamental shifts:
Compile Time vs Runtime
Vue processes reactivity through a runtime dependency tracking system. When you access a reactive variable, Vue intercepts the access and records the dependency. Svelte, on the other hand, analyzes your component's dependencies at compile time and generates precise update code that only touches the DOM nodes that actually changed.
Single-File Component Structure
Both Vue and Svelte use single-file components, but Svelte's structure is more relaxed. In Vue, you have <template>, <script>, and <style> blocks, with scoped styles achieved via the scoped attribute or CSS modules. In Svelte, styles are automatically scoped to the component by default, and you simply write HTML, JavaScript, and CSS in their respective sections without special wrapper tags for the template.
Reactivity Model
Vue 3 uses ref() and reactive() to create reactive state, and you must access values via .value in the Composition API or use the Options API's data() function. Svelte uses plain JavaScript variables declared at the top level of the <script> block. Simply reassigning a variable triggers reactivity. For derived state, Svelte uses the $: label (reactive declarations), while Vue uses computed().
Step-by-Step Migration Guide
Step 1: Setting Up the Svelte Project
Begin by creating a new Svelte project alongside your existing Vue codebase. This allows you to migrate incrementally rather than doing a full rewrite at once.
npm create svelte@latest my-svelte-app
cd my-svelte-app
npm install
For a production-ready setup with routing, install SvelteKit (the official application framework for Svelte):
npm create svelte@latest my-sveltekit-app
cd my-sveltekit-app
npm install
npm install @sveltejs/kit
Step 2: Translating Vue Components to Svelte Components
Let's start with a basic Vue component and its Svelte equivalent. Consider this Vue 3 Composition API component:
<!-- Vue Counter Component -->
<template>
<div>
<h2>{{ title }}</h2>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
<button @click="decrement">Decrement</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const title = 'Counter'
const count = ref(0)
function increment() {
count.value++
}
function decrement() {
count.value--
}
</script>
<style scoped>
button {
padding: 8px 16px;
margin: 0 4px;
cursor: pointer;
}
h2 {
color: #333;
}
</style>
Here is the equivalent Svelte component. Notice how ref() disappears and event binding uses the on: directive:
<!-- Svelte Counter Component -->
<script>
let title = 'Counter'
let count = 0
function increment() {
count += 1
}
function decrement() {
count -= 1
}
</script>
<div>
<h2>{title}</h2>
<p>Count: {count}</p>
<button on:click={increment}>Increment</button>
<button on:click={decrement}>Decrement</button>
</div>
<style>
button {
padding: 8px 16px;
margin: 0 4px;
cursor: pointer;
}
h2 {
color: #333;
}
</style>
Key takeaways from this conversion:
- Vue's
<template>wrapper is removed; Svelte allows HTML directly at the root of the file. - Vue's
ref()and.valueaccess pattern is replaced with plain JavaScript variables. - Vue's
@clickshorthand becomes Svelte'son:clickdirective. - Vue's
scopedstyle attribute is unnecessary because Svelte styles are automatically scoped. - Template interpolation uses curly braces
{}instead of Vue's double curly braces{{ }}.
Step 3: Converting Vue Directives to Svelte Syntax
Vue's template directives have direct Svelte counterparts. Here is a comprehensive mapping:
<!-- Vue: Conditional Rendering -->
<div v-if="isVisible">Visible content</div>
<div v-else>Fallback content</div>
<!-- Svelte: Conditional Rendering -->
{#if isVisible}
<div>Visible content</div>
{:else}
<div>Fallback content</div>
{/if}
<!-- Vue: List Rendering -->
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
<!-- Svelte: List Rendering (key is optional, often not needed) -->
<ul>
{#each items as item (item.id)}
<li>{item.name}</li>
{/each}
</ul>
<!-- Vue: Attribute Binding -->
<button :disabled="isDisabled" :class="['btn', activeClass]">Click</button>
<!-- Svelte: Attribute Binding -->
<button disabled={isDisabled} class="btn {activeClass}">Click</button>
<!-- Vue: Two-way Binding -->
<input v-model="searchQuery" />
<!-- Svelte: Two-way Binding -->
<input bind:value={searchQuery} />
Step 4: Converting Computed Properties and Watchers
Vue's computed() and watch() translate to Svelte's reactive declarations and reactive statements using the $: label:
<!-- Vue Computed and Watch -->
<script setup>
import { ref, computed, watch } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
watch(fullName, (newValue) => {
console.log('Full name changed to:', newValue)
})
</script>
<!-- Svelte Reactive Declarations and Statements -->
<script>
let firstName = 'John'
let lastName = 'Doe'
// Reactive declaration (equivalent to computed)
$: fullName = `${firstName} ${lastName}`
// Reactive statement (equivalent to watch)
$: console.log('Full name changed to:', fullName)
</script>
The Svelte $: syntax declares that fullName should be recalculated whenever its dependencies (firstName or lastName) change. A reactive statement without a variable assignment (like the console.log line) runs as a side effect whenever its referenced values change, similar to Vue's watch().
Step 5: Converting Props and Events
Vue uses defineProps and defineEmits in the Composition API. Svelte uses export let for props and createEventDispatcher or direct callback props for events:
<!-- Vue Parent Component -->
<template>
<ChildComponent :message="parentMessage" @update="handleUpdate" />
</template>
<script setup>
import ChildComponent from './ChildComponent.vue'
const parentMessage = 'Hello from parent'
function handleUpdate(newValue) {
console.log('Received:', newValue)
}
</script>
<!-- Vue Child Component -->
<template>
<div>
<p>{{ message }}</p>
<button @click="$emit('update', 'child value')">Send Update</button>
</div>
</template>
<script setup>
defineProps(['message'])
defineEmits(['update'])
</script>
Now the Svelte version:
<!-- Svelte Parent Component -->
<script>
import Child from './Child.svelte'
let parentMessage = 'Hello from parent'
function handleUpdate(event) {
console.log('Received:', event.detail)
}
</script>
<Child message={parentMessage} on:update={handleUpdate} />
<!-- Svelte Child Component -->
<script>
import { createEventDispatcher } from 'svelte'
// Props are declared via export
export let message = ''
const dispatch = createEventDispatcher()
function sendUpdate() {
dispatch('update', 'child value')
}
</script>
<div>
<p>{message}</p>
<button on:click={sendUpdate}>Send Update</button>
</div>
Alternatively, Svelte supports callback props for simpler cases, avoiding the event dispatcher entirely:
<!-- Svelte Child with Callback Prop -->
<script>
export let message = ''
export let onUpdate = (value) => {}
</script>
<div>
<p>{message}</p>
<button on:click={() => onUpdate('child value')}>Send Update</button>
</div>
Step 6: Converting Slots to Svelte Slots
Vue's slot system maps cleanly to Svelte's slot mechanism, with some differences in named slots and slot props:
<!-- Vue Slot Usage -->
<template>
<CardComponent>
<template #header>
<h2>Custom Header</h2>
</template>
<template #default>
<p>Main content here</p>
</template>
<template #footer="slotProps">
<p>Footer with {{ slotProps.close }}</p>
</template>
</CardComponent>
</template>
<!-- Svelte Slot Usage -->
<script>
import Card from './Card.svelte'
</script>
<Card>
<h2 slot="header">Custom Header</h2>
<p>Main content here</p>
<p slot="footer" let:close>Footer with {close}</p>
</Card>
The Svelte Card component would expose these slots like this:
<!-- Svelte Card Component -->
<script>
export let close = () => console.log('close called')
</script>
<div class="card">
<div class="card-header">
<slot name="header">Default Header</slot>
</div>
<div class="card-body">
<slot>Default content</slot>
</div>
<div class="card-footer">
<slot name="footer" close={close}>Default footer</slot>
</div>
</div>
Step 7: State Management Migration
Vue's Pinia or Vuex stores translate to Svelte stores, which are simpler and built into the framework:
<!-- Vue Pinia Store -->
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
actions: {
increment() {
this.count++
}
},
getters: {
doubleCount: (state) => state.count * 2
}
})
The equivalent Svelte store:
<!-- Svelte Store (counterStore.js) -->
import { writable, derived } from 'svelte/store'
// Writable store for state
export const count = writable(0)
// Action equivalent
export function increment() {
count.update(n => n + 1)
}
// Derived store for getters
export const doubleCount = derived(count, $count => $count * 2)
Using the Svelte store in a component is straightforward with the auto-subscription prefix $:
<script>
import { count, increment, doubleCount } from './counterStore.js'
// The $ prefix auto-subscribes and auto-unsubscribes
console.log($count) // current value
</script>
<div>
<p>Count: {$count}</p>
<p>Double: {$doubleCount}</p>
<button on:click={increment}>Increment</button>
</div>
Step 8: Routing Migration
If you are using Vue Router, the Svelte equivalent is SvelteKit's file-based routing or the standalone svelte-routing library. For most migrations, SvelteKit is the recommended approach:
<!-- Vue Router Configuration -->
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/users/:id', component: User }
]
With SvelteKit, routes are defined by the file structure under src/routes:
src/
routes/
+page.svelte → /
about/
+page.svelte → /about
users/
[id]/
+page.svelte → /users/:id
Navigation in SvelteKit uses the goto function or the <a> element:
<script>
import { goto } from '$app/navigation'
</script>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<button on:click={() => goto('/users/42')}>User 42</button>
</nav>
Step 9: Lifecycle Hooks Conversion
Vue's lifecycle hooks have direct Svelte equivalents:
<!-- Vue Lifecycle -->
<script setup>
import { onMounted, onUnmounted, onBeforeUpdate } from 'vue'
onMounted(() => {
console.log('Component mounted')
})
onUnmounted(() => {
console.log('Component unmounted')
})
onBeforeUpdate(() => {
console.log('About to update')
})
</script>
<!-- Svelte Lifecycle -->
<script>
import { onMount, onDestroy, beforeUpdate } from 'svelte'
onMount(() => {
console.log('Component mounted')
})
onDestroy(() => {
console.log('Component unmounted')
})
beforeUpdate(() => {
console.log('About to update')
})
</script>
Svelte's onMount is particularly important for client-side initialization like fetching data or accessing the DOM. Note that Svelte does not have an exact equivalent of onBeforeUpdate called beforeUpdate, but the function beforeUpdate serves the same purpose — it runs immediately before the DOM is updated after a state change.
Step 10: Converting Async Data and Promises
Vue's setup() can return promises with <Suspense>, while Svelte has built-in support for awaiting promises directly in the template with the {#await} block:
<!-- Vue Async Setup -->
<template>
<Suspense>
<template #default>
<UserProfile :user="userData" />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
<script setup>
const userData = await fetchUser()
</script>
<!-- Svelte Await Block -->
<script>
let userPromise = fetchUser()
async function fetchUser() {
const response = await fetch('/api/user')
return response.json()
}
</script>
{#await userPromise}
<LoadingSpinner />
{:then user}
<UserProfile user={user} />
{:catch error}
<p>Error: {error.message}</p>
{/await}
Best Practices for Vue-to-Svelte Migration
1. Migrate Incrementally
Do not attempt a full rewrite in one go. Identify the most self-contained components first (like UI primitives: buttons, cards, modals) and convert them one at a time. Both Vue and Svelte can coexist in a project if you use a micro-frontend approach or separate packages.
2. Embrace Svelte's Simplicity
Resist the urge to over-engineer. If Vue's Composition API led you to create many composables with ref() and watchEffect(), you may find that Svelte's reactive declarations handle the same logic with fewer lines of code. Let the simplicity guide your refactoring.
3. Leverage Svelte Stores for Shared State
While Vue uses Pinia for global state management, Svelte stores are simpler and often sufficient for most use cases. Use writable stores for mutable state and derived stores for computed values. For complex scenarios, Svelte stores support custom store contracts that you can implement to add middleware, persistence, or logging.
4. Test Thoroughly During Migration
Set up testing with Svelte Testing Library or Vitest with the @testing-library/svelte package. Write tests for each converted component before moving on to the next one. This ensures behavioral parity between the Vue original and the Svelte replacement.
npm install -D @testing-library/svelte vitest
// Example test for a Svelte component
import { render, screen, fireEvent } from '@testing-library/svelte'
import Counter from './Counter.svelte'
test('increments count on button click', async () => {
render(Counter)
const button = screen.getByText('Increment')
await fireEvent.click(button)
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
5. Mind the Differences in CSS Scoping
In Vue with scoped styles, CSS is scoped using data attributes. In Svelte, styles are scoped by default using a unique class hash added at compile time. If you have global styles that should not be scoped, use :global() in Svelte:
<style>
/* Component-scoped by default */
.card {
border-radius: 8px;
}
/* Explicitly global styles */
:global(.modal-backdrop) {
background: rgba(0, 0, 0, 0.5);
}
</style>
6. Handle Third-Party Vue Libraries
For Vue-specific libraries (like Vuetify, VueUse, or Vue Router), you will need to find Svelte equivalents or write thin wrappers. Many popular Vue libraries have Svelte counterparts: use Svelte Material UI instead of Vuetify, SvelteKit instead of Vue Router/Nuxt, and the built-in Svelte stores instead of Pinia. For utility libraries like VueUse, consider svelte-use or simply implement the needed functionality using Svelte's reactive primitives.
7. Optimize Bundle Size
One of the main benefits of migrating to Svelte is reduced bundle size. After migration, analyze your bundle with tools like vite-bundle-visualizer to ensure you are not accidentally importing heavy Vue dependencies. Remove all Vue-related packages from package.json once migration is complete.
8. Plan for State Management Architecture
If your Vue application has a complex Pinia setup with multiple stores, modules, and plugins, plan the Svelte store architecture carefully. You can create a central stores/ directory with individual store files, and combine them using Svelte's store composition patterns. For very complex global state, consider using Svelte's context API (setContext/getContext) combined with stores for scoped yet shared state.
Complete Migration Example: A Todo Application
Let's walk through a complete migration of a small Todo app from Vue to Svelte. This example demonstrates components, state management, and event handling together.
First, the original Vue application split across components:
<!-- Vue TodoApp.vue -->
<template>
<div class="todo-app">
<h1>Todo List</h1>
<AddTodo @add="addTodo" />
<TodoList
:todos="todos"
@toggle="toggleTodo"
@remove="removeTodo"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import AddTodo from './AddTodo.vue'
import TodoList from './TodoList.vue'
const todos = ref([
{ id: 1, text: 'Learn Vue', completed: false },
{ id: 2, text: 'Build project', completed: true }
])
let nextId = 3
function addTodo(text) {
todos.value.push({ id: nextId++, text, completed: false })
}
function toggleTodo(id) {
const todo = todos.value.find(t => t.id === id)
if (todo) todo.completed = !todo.completed
}
function removeTodo(id) {
todos.value = todos.value.filter(t => t.id !== id)
}
</script>
<!-- Vue AddTodo.vue -->
<template>
<div>
<input v-model="newTodo" @keyup.enter="submit" placeholder="Add a new task" />
<button @click="submit">Add</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['add'])
const newTodo = ref('')
function submit() {
if (newTodo.value.trim()) {
emit('add', newTodo.value.trim())
newTodo.value = ''
}
}
</script>
<!-- Vue TodoList.vue -->
<template>
<ul>
<li v-for="todo in todos" :key="todo.id" :class="{ completed: todo.completed }">
<span @click="$emit('toggle', todo.id)">{{ todo.text }}</span>
<button @click="$emit('remove', todo.id)">×</button>
</li>
</ul>
</template>
<script setup>
defineProps(['todos'])
defineEmits(['toggle', 'remove'])
</script>
Now the complete Svelte migration. Notice how the three Vue components become three Svelte components with cleaner syntax and less boilerplate:
<!-- Svelte TodoApp.svelte -->
<script>
import AddTodo from './AddTodo.svelte'
import TodoList from './TodoList.svelte'
let todos = [
{ id: 1, text: 'Learn Svelte', completed: false },
{ id: 2, text: 'Build project', completed: true }
]
let nextId = 3
function addTodo(event) {
const text = event.detail
todos.push({ id: nextId++, text, completed: false })
// Trigger reactivity by reassigning
todos = todos
}
function toggleTodo(event) {
const id = event.detail
const todo = todos.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
todos = todos
}
}
function removeTodo(event) {
const id = event.detail
todos = todos.filter(t => t.id !== id)
}
</script>
<div class="todo-app">
<h1>Todo List</h1>
<AddTodo on:add={addTodo} />
<TodoList
todos={todos}
on:toggle={toggleTodo}
on:remove={removeTodo}
/>
</div>
<style>
.todo-app {
max-width: 500px;
margin: 0 auto;
font-family: sans-serif;
}
h1 {
text-align: center;
color: #333;
}
</style>
<!-- Svelte AddTodo.svelte -->
<script>
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
let newTodo = ''
function submit() {
if (newTodo.trim()) {
dispatch('add', newTodo.trim())
newTodo = ''
}
}
</script>
<div>
<input
bind:value={newTodo}
on:keydown={(e) => e.key === 'Enter' && submit()}
placeholder="Add a new task"
/>
<button on:click={submit}>Add</button>
</div>
<style>
div {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
input {
flex: 1;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 8px 16px;
background: #4a90d9;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
<!-- Svelte TodoList.svelte -->
<script>
import { createEventDispatcher } from 'svelte'
export let todos = []
const dispatch = createEventDispatcher()
function toggle(id) {
dispatch('toggle', id)
}
function remove(id) {
dispatch('remove', id)
}
</script>
<ul>
{#each todos as todo (todo.id)}
<li class:completed={todo.completed}>
<span on:click={() => toggle(todo.id)}>{todo.text}</span>
<button on:click={() => remove(todo.id)}>×</button>
</li>
{/each}
</ul>
<style>
ul {
list-style: none;
padding: 0;
}
li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid #eee;
}
li.completed span {
text-decoration: line-through;
color: #999;
}
span {
cursor: pointer;
flex: 1;
}
button {
background: none;
border: none;
color: #cc0000;
font-size: 18px;
cursor: pointer;
padding: 0 8px;
}
</style>
This complete example demonstrates the full migration pattern: Vue's v-model becomes bind:value, v-for becomes {#each}, @click becomes on:click, and ref() state disappears entirely in favor of plain variables. The Svelte version is noticeably more concise while retaining full functionality.
Conclusion
Migrating from Vue to Svelte is a strategic decision that can yield substantial benefits in bundle size, runtime performance, and developer experience. The process requires understanding the shift from runtime reactivity to compile-time optimization, converting template directives to Svelte's block syntax, and adapting state management from Pinia or Vuex to Svelte stores. By following this step-by-step guide — starting with component syntax, moving through directives, reactivity, props, events, slots, stores, routing, lifecycle hooks, and async handling — you can systematically convert your Vue application while maintaining feature parity. The key to a successful migration is incremental adoption, thorough testing at each stage, and embracing Svelte's philosophy of simplicity. Once the migration is complete, you will have an application that is faster, lighter, and often easier to maintain, with fewer lines of code and less framework abstraction between you and the DOM.