Writing
Dynamic Layouts in a Vue Project

There is more than one way to run multiple layouts in a Vue project, and I have tried all of them in real projects. In this post I share the four approaches I keep coming back to, from the simplest to the one I recommend most. This tutorial assumes you already have a Vue project installed - if not, start with my guide on creating a Vue project.
The example case: an app with three pages (home, admin dashboard, admin settings) and two layouts. The home page uses a default layout with a top navigation, while the admin pages use an admin layout with a sidebar.
1. Conditional render
The simplest approach is a conditional v-if in App.vue. This works well for small variations, like hiding the footer on a login page:
<template>
<div class="app">
<nav v-if="visibleNav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</nav>
<router-view />
<footer v-if="visibleFooter">© This is the footer</footer>
</div>
</template>
<script setup>
import { ref } from 'vue'
const visibleNav = ref(true)
const visibleFooter = ref(true)
</script>
That was easy - but the disadvantage shows up fast. As the app grows, App.vue accumulates condition after condition, and the file becomes hard to read and maintain. If your layouts differ by more than one or two elements, skip this approach.
2. Layout components with slots
The second approach wraps each layout in its own component and uses Vue's slot feature to project the page content in. If you are not familiar with slots yet, the official slot guide is a good starting point.
layouts/LayoutDefault.vue - header, content, footer:
<template>
<div>
<nav>
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</nav>
<slot />
<footer>This is the footer</footer>
</div>
</template>
layouts/LayoutAdmin.vue - the same, plus a sidebar:
<template>
<div class="row">
<aside class="sidebar">
<Sidebar />
</aside>
<div class="column">
<nav>
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</nav>
<slot />
<footer>This is the footer</footer>
</div>
</div>
</template>
<script setup>
import Sidebar from '@/components/base/Sidebar.vue'
</script>
Each page then imports the layout it wants and nests its content inside the slot:
<template>
<LayoutDefault>
<div>This is the page body</div>
</LayoutDefault>
</template>
<script setup>
import LayoutDefault from '@/layouts/LayoutDefault.vue'
</script>
This is a big step up in maintainability and readability. The trade-off: every page has to import its layout, the layout component re-mounts on every navigation (because each page owns its own copy), and forgetting the import on one page gives you a page without a layout.
3. Dynamic component wrapper
The third approach uses Vue's dynamic component feature - the special is attribute - so the layout is chosen in one place and swapped at runtime:
<component :is="layout" />
Here layout can be the name of a registered component or the component object itself. Combined with the router, the whole thing lives in App.vue:
<template>
<component :is="layout">
<router-view v-model:layout="layout" />
</component>
</template>
<script setup>
import { shallowRef } from 'vue'
import LayoutDefault from '@/layouts/LayoutDefault.vue'
const layout = shallowRef(LayoutDefault)
</script>
Each page tells the parent which layout it needs by emitting the update event - in Vue 3, the old .sync modifier became the v-model:layout binding you see above on <router-view>:
<template>
<div class="home">
<h1>Home</h1>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { markRaw } from 'vue'
import LayoutAdmin from '@/layouts/LayoutAdmin.vue'
const emit = defineEmits(['update:layout'])
onMounted(() => {
emit('update:layout', markRaw(LayoutAdmin))
})
</script>
markRaw (and shallowRef on the parent) stops Vue from wrapping the layout component in a reactivity proxy, which it does not need and which only costs performance. Compared to the slot approach, the layout here does not re-render when you navigate between two pages that share the same layout - only an actual change of layout value swaps it.
4. Layouts through nested routes
My favorite approach: let the router own the layout. Layouts become parent routes and pages become their children, so the URL alone decides which layout renders - no imports, no events, no conditionals.
layouts/LayoutDefault.vue:
<template>
<div id="default">
<AppHeader />
<main class="body">
<router-view />
</main>
<AppFooter />
</div>
</template>
<script setup>
import AppHeader from '@/components/base/AppHeader.vue'
import AppFooter from '@/components/base/AppFooter.vue'
</script>
layouts/LayoutAdmin.vue:
<template>
<div class="row">
<aside class="sidebar">
<Sidebar />
</aside>
<div class="column">
<main>
<router-view />
</main>
</div>
</div>
</template>
<script setup>
import Sidebar from '@/components/base/Sidebar.vue'
</script>
The layout's <router-view /> is the slot where the child page renders. Now the router file in Vue Router 4:
import { createRouter, createWebHistory } from 'vue-router'
const LayoutDefault = () => import('@/layouts/LayoutDefault.vue')
const LayoutAdmin = () => import('@/layouts/LayoutAdmin.vue')
const HomePage = () => import('@/pages/HomePage.vue')
const AdminDashboardPage = () => import('@/pages/AdminDashboardPage.vue')
const AdminSettingPage = () => import('@/pages/AdminSettingPage.vue')
const routes = [
{
path: '/',
component: LayoutDefault,
children: [
{ path: '', name: 'home', component: HomePage },
],
},
{
path: '/admin',
component: LayoutAdmin,
children: [
{ path: '', name: 'admin-dashboard', component: AdminDashboardPage },
{ path: 'setting', name: 'admin-setting', component: AdminSettingPage },
],
},
]
export default createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
Reading the routes: the / route renders LayoutDefault and shows HomePage inside it. Everything under /admin renders LayoutAdmin, so /admin shows the dashboard and /admin/setting shows the settings page - both inside the admin layout with the sidebar. One small thing to watch: child paths should not start with /, otherwise Vue Router treats them as absolute paths instead of nesting them.
A nice variation is route meta: keep one parent route and set meta: { layout: 'admin' } per route, then resolve the layout component in App.vue from route.meta. Same result, different place to look - I prefer the nested-routes version because the structure is visible right in the route table.
Conclusion
Every approach has its place:
- Conditional render - fine for one or two small variations; painful beyond that.
- Slots - great when a page genuinely composes its own shell, but repeated imports and re-mounts are the cost.
- Dynamic component - one shared shell that swaps at runtime; good middle ground when you cannot restructure routes.
- Nested routes - my default. The layout is decided by the URL, code splitting comes free from the lazy imports, and the route table documents the whole app at a glance.
Thanks for reading - and stay healthy!