Introduction
By day 80 of your Vue.js journey, you've built components for lists, forms, and layouts—but what happens when you need to swap entire components at runtime based on user interaction or application state? Dynamic components: Vue & Nuxt best practices explained is exactly what you need to master this pattern.
This article walks you through the <component :is="current"> pattern, explores real-world use cases like tabs and wizards, and shows you how to preserve component state with KeepAlive. You'll also learn how to organize dynamic component mappings in a way that scales.
Understanding the <component :is> syntax
Vue's built-in <component> element is a meta-component that renders whatever component you bind to its :is prop. Instead of hardcoding component names in your template, you point to a variable that holds your component reference—allowing you to swap entire components without conditional rendering sprawl.
Here's the simplest example:
<template>
<div class="container">
<component :is="currentComponent" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import HomeView from './views/HomeView.vue'
import ProfileView from './views/ProfileView.vue'
const currentComponent = ref(HomeView)
const switchTo = (component) => {
currentComponent.value = component
}
</script>The :is prop accepts either an imported component object or a string name if you've registered it globally or locally. When the prop changes, Vue unmounts the old component and mounts the new one. This is clean, declarative, and keeps your template simple.
Tabs: the most common use case
Tabs are the textbook example of dynamic component switching. You have multiple views—Settings, Billing, Notifications—and clicking a tab button should swap out the entire component. Without dynamic components, you'd end up with nested v-if statements. With them, your code stays maintainable.
<template>
<div class="tabs-container">
<div class="tab-buttons">
<button
v-for="(component, tabName) in tabs"
:key="tabName"
@click="activeTab = tabName"
:class="{ active: activeTab === tabName }"
>
{{ tabName }}
</button>
</div>
<div class="tab-content">
<component :is="tabs[activeTab]" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import SettingsTab from './tabs/SettingsTab.vue'
import BillingTab from './tabs/BillingTab.vue'
import NotificationsTab from './tabs/NotificationsTab.vue'
const tabs = {
Settings: SettingsTab,
Billing: BillingTab,
Notifications: NotificationsTab,
}
const activeTab = ref('Settings')
</script>Notice how the tabs object maps tab names to component references. This approach is flexible: adding a new tab is just one line. The active tab state is held at the parent level, so switching tabs is instantaneous. The template stays readable because all the logic is declarative.
Multi-step wizards with dynamic components
Wizards take tab switching further. Instead of independent tabs, you have a linear flow where users move through steps sequentially. Each step is a component, and you validate or prepare data before advancing.
<template>
<div class="wizard">
<div class="progress-bar">
<div class="progress" :style="{ width: progressPercent + '%' }"></div>
</div>
<component :is="steps[currentStep]" @next="goToStep(currentStep + 1)" />
<div class="wizard-controls">
<button @click="goToStep(currentStep - 1)" :disabled="currentStep === 0">
Back
</button>
<button @click="goToStep(currentStep + 1)" :disabled="currentStep === steps.length - 1">
Next
</button>
<button v-if="currentStep === steps.length - 1" @click="submitWizard">
Submit
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import StepOne from './wizard/StepOne.vue'
import StepTwo from './wizard/StepTwo.vue'
import StepThree from './wizard/StepThree.vue'
const steps = [StepOne, StepTwo, StepThree]
const currentStep = ref(0)
const progressPercent = computed(() => {
return ((currentStep.value + 1) / steps.length) * 100
})
const goToStep = (stepIndex) => {
if (stepIndex >= 0 && stepIndex < steps.length) {
currentStep.value = stepIndex
}
}
const submitWizard = async () => {
// Validate and submit collected data
console.log('Submitting wizard')
}
</script>Each step component emits a @next event when ready to proceed. The parent controls navigation and can inject validation logic. This pattern scales well for onboarding flows, checkout processes, or any sequential user journey.
KeepAlive: preserving component state
Here's a common gotcha: when you switch away from a component, Vue unmounts it by default. All internal state—form inputs, scroll position, API calls in progress—is destroyed. When you switch back, you get a fresh instance.
This is usually desired, but sometimes you want to preserve a component's state across tab switches. That's where KeepAlive comes in. It caches the component instance in memory, so switching back restores the exact state you left it in.
<template>
<div class="tabs-container">
<div class="tab-buttons">
<button
v-for="(component, tabName) in tabs"
:key="tabName"
@click="activeTab = tabName"
:class="{ active: activeTab === tabName }"
>
{{ tabName }}
</button>
</div>
<KeepAlive :max="3">
<component :is="tabs[activeTab]" :key="activeTab" />
</KeepAlive>
</div>
</template>
<script setup>
import { ref } from 'vue'
import SettingsTab from './tabs/SettingsTab.vue'
import BillingTab from './tabs/BillingTab.vue'
import NotificationsTab from './tabs/NotificationsTab.vue'
const tabs = {
Settings: SettingsTab,
Billing: BillingTab,
Notifications: NotificationsTab,
}
const activeTab = ref('Settings')
</script>The :max="3" prop limits how many component instances KeepAlive holds in memory. The :key="activeTab" is important—it ensures Vue treats each tab as a separate cached instance. Without it, the same instance would be reused across all tabs.
KeepAlive also exposes lifecycle hooks. Your components can tap into onActivated and onDeactivated to refresh data when switching back or clean up resources when leaving:
<script setup>
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
console.log('Tab is now visible')
// Refresh data if needed
})
onDeactivated(() => {
console.log('Tab is hidden')
// Pause animations, stop timers, etc.
})
</script>Mapping component names to imports: the scalable approach
As your app grows, manually importing every dynamic component and maintaining a mapping object becomes tedious and error-prone. A common pattern is to use a centralized registry that imports components dynamically based on a naming convention.
// components/registry.ts
const componentModules = import.meta.glob('./views/*.vue', { eager: true })
const registry = Object.entries(componentModules).reduce(
(acc, [path, module]) => {
const name = path
.split('/')
.pop()
?.replace(/\.vue$/, '')
if (name) {
acc[name] = module.default
}
return acc
},
{} as Record<string, any>
)
export const getComponent = (name: string) => registry[name] || null
export const getAllComponents = () => registryNow your components can resolve dynamically without explicit imports:
<template>
<component :is="getComponent(componentName)" />
</template>
<script setup>
import { ref } from 'vue'
import { getComponent } from '@/components/registry'
const componentName = ref('UserProfile')
</script>This approach uses Vite's import.meta.glob (or Webpack's require.context in older setups) to automatically discover and register components. Adding a new component is as simple as dropping a file in the convention-based directory—no registry updates needed.
Dynamic components in Nuxt
In Nuxt, the <component :is> pattern works identically to Vue. The main difference is that Nuxt's auto-import system means you don't need explicit import statements for registered components.
If you're using Nuxt's pages directory, you might want to dynamically render pages:
<template>
<div>
<NuxtLink to="/home">Home</NuxtLink>
<NuxtLink to="/about">About</NuxtLink>
<component :is="currentPage" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import Home from '~/pages/Home.vue'
import About from '~/pages/About.vue'
const currentPage = ref(Home)
</script>For SSR-safe dynamic imports in Nuxt, use defineAsyncComponent:
<script setup>
import { ref, defineAsyncComponent } from 'vue'
const currentPage = ref(
defineAsyncComponent(() => import('~/pages/Home.vue'))
)
</script>This ensures the component loads on the client side, avoiding hydration mismatches.
Common mistakes
- Forgetting the key on KeepAlive components: Without
:key, different component instances won't stay cached separately, leading to state bleeding between tabs. - Over-caching with KeepAlive: Caching too many components consumes memory. Always set
:maxto a reasonable number, and only use KeepAlive when state preservation is essential. - Not handling lifecycle changes: Components wrapped in KeepAlive don't use
onMountedandonUnmountedreliably. UseonActivatedandonDeactivatedinstead. - Passing props to dynamic components incorrectly: If your dynamic component expects props, bind them normally on the
<component>tag:<component :is="current" :modelValue="data" />. - Circular dependencies in component registries: If your registry auto-imports from a directory, ensure that directory doesn't re-export the registry itself, or you'll create an infinite loop.
Key takeaways
- Use
<component :is="currentComponent">to swap entire components at runtime without cluttering your template with v-if conditionals. - Tabs and wizards are the canonical use cases; organize your component references in an object for clean, maintainable code.
- Wrap dynamic components in
KeepAliveonly when you need to preserve internal state across switches; always set:maxto limit memory usage. - Use
onActivatedandonDeactivatedlifecycle hooks in cached components to refresh data or clean up resources. - For scalable apps, use Vite's
import.meta.globor convention-based registries to avoid manual component imports and maintain consistency. - In Nuxt, dynamic components work the same way; use
defineAsyncComponentfor SSR safety when importing on the client.






Comments
Loading comments…