Most sites have two or three frames. The public pages share a header, a navigation bar and a footer. The login and sign-up pages want none of that, just a centred card. An admin area wants a sidebar. Copying the header into every page works until you change it, and then you change it twelve times.
Nuxt calls these frames layouts. You write each one once, and a page that needs a different frame picks it with definePageMeta, the same macro that sets its middleware, its transition and whether it stays in memory. What the macro cannot do is read data such as the user's role, so a layout that depends on data is chosen with setPageLayout in route middleware.
Add a default layout for every page
A layout is a component in the layouts/ folder with a <slot /> where the page goes. The file named default.vue is used by every page that does not ask for another one.
<script setup lang="ts">
const links = [
{ to: '/', label: 'Home' },
{ to: '/courses', label: 'Courses' },
{ to: '/account', label: 'Account' },
]
</script>
<template>
<div class="site">
<header>
<nav aria-label="Main">
<NuxtLink v-for="link in links" :key="link.to" :to="link.to">{{ link.label }}</NuxtLink>
</nav>
</header>
<main>
<slot />
</main>
<footer>© vuejs.school</footer>
</div>
</template>
Two things about this file matter. First, it has one root element, the <div class="site">, and that root is not the slot itself: Nuxt needs a single root to animate between layouts, and the docs say the root cannot be a <slot />. Second, the <NuxtLink> components are the same ones you would use in a page: a layout is an ordinary component with no special powers, only a special folder.
Layouts do nothing until app.vue asks for them. <NuxtLayout> wraps <NuxtPage>, and the page renders into the slot of whichever layout is active:
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
With that in place every page in pages/ gets the header and footer. Nuxt imports layouts asynchronously, so each one is built as a separate chunk rather than into the main bundle that every page loads.
Give a page a different layout with definePageMeta
The login page wants a plain frame. Add a second file to the folder:
<template>
<div class="auth">
<NuxtLink to="/">Back to the site</NuxtLink>
<main>
<slot />
</main>
</div>
</template>
Then tell the login page to use it. That is the job of definePageMeta, a compiler macro you call at the top of a page's <script setup>:
<script setup lang="ts">
definePageMeta({
layout: 'auth',
})
const email = ref('')
</script>
<template>
<form @submit.prevent>
<label for="email">Email</label>
<input id="email" v-model="email" type="email" autocomplete="email">
<button type="submit">Send me a link</button>
</form>
</template>
The layout name is the file name without .vue, in kebab-case. A layout in a nested folder gets a name built from the path with repeated segments removed, so layouts/desktop/default.vue is desktop-default and layouts/desktop/index.vue is simply desktop.
The layout key is typed: Nuxt generates a LayoutKey union from the files in the folder, so a typo like 'atuh' is a TypeScript error. Without that check the mistake is easy to miss, because Nuxt only logs an "Invalid layout" warning in development and renders the page with no layout at all.
Keep runtime data out of definePageMeta
"Compiler macro" means Nuxt pulls the object out of the page at build time. It copies the object, and any local declaration it refers to, into a separate module. That module runs once, when the app's code loads: before any page renders, outside any component, with no current route and no logged-in user. This is how the router can read every page's metadata without mounting the page.
So the docs limit what the object may refer to: imported values and locally defined pure functions, never reactive data or calls with side effects. This page breaks that rule, and it looks reasonable:
<script setup lang="ts">
const user = useState<{ role: string } | null>('user')
const layoutName = user.value?.role === 'admin' ? 'admin' : 'default'
definePageMeta({
layout: layoutName,
})
</script>
Nuxt copies both declarations into the macro module, so the useState call runs before Nuxt has created the app. In development it throws "A composable that requires access to the Nuxt instance was called outside of a plugin, Nuxt hook, Nuxt middleware, or Vue setup function". Even with an app, layoutName would be computed once and never again, so the layout could not follow a login.
The extraction is strict in two more ways. A variable the macro refers to cannot get its value from await: the build fails with Await expressions are not supported in definePageMeta. And a second definePageMeta call in the same file is an error too.
Choose the layout at runtime with setPageLayout
When the layout depends on data, decide it in route middleware with setPageLayout. The docs say a layout chosen on the server must be set before Vue renders the layout, to avoid a hydration mismatch (HTML from the server that differs from what the browser renders). Route middleware runs at that moment, before the page and its layout render. This one switches admins to a third layout, layouts/admin.vue with the sidebar, which you add to the folder next to the other two:
export default defineNuxtRouteMiddleware(() => {
const user = useState<{ role: string } | null>('user')
if (user.value?.role === 'admin') {
setPageLayout('admin')
}
})
setPageLayout takes a layout name, typed like the layout key, and applies it to the route you are navigating to. The page names the middleware with definePageMeta({ middleware: 'choose-layout' }) and keeps no layout logic of its own. A string there refers to a file in middleware/; the key also takes an inline function, which runs for this page only, or an array, which runs several in order.
setPageLayout needs the Nuxt context. Middleware and plugins have it, and so does a page's event handler in the browser. A click handler suits a "switch to compact view" button, but it runs after the page has loaded, so the first render still shows the page's own layout.
A page that wants full control has one more option: set layout: false and render <NuxtLayout name="auth"> inside the page's own template, which lets you fill the layout's named slots from the page. The docs warn that <NuxtLayout> should not be the page's root element in that case unless transitions are off, so wrap it in a <div>.
Animate page and layout changes
Transitions are a two-step setup. First switch them on in nuxt.config.ts, with a name and a mode; out-in waits for the old page to leave before the new one enters, which avoids both being on screen at once:
export default defineNuxtConfig({
app: {
pageTransition: { name: 'page', mode: 'out-in' },
layoutTransition: { name: 'layout', mode: 'out-in' },
},
})
Then write the classes for both names, once, in app.vue:
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<style>
.page-enter-active,
.page-leave-active,
.layout-enter-active,
.layout-leave-active {
transition: opacity 0.2s;
}
.page-enter-from,
.page-leave-to,
.layout-enter-from,
.layout-leave-to {
opacity: 0;
}
</style>
The page transition runs when the page changes inside the same layout. When the layout changes too, the layout transition runs instead, which is why the config and the styles cover both. A single page can override or opt out with pageTransition: { name: 'slide' } (with matching .slide-* classes) or pageTransition: false in its own definePageMeta.
Every page and layout must keep a single root element for any of this to work. A page that renders two sibling elements gets no transition and, in development, a warning in the console.
Keep a page's state between visits
By default Nuxt unmounts a page when you leave it and mounts a fresh one when you come back, so a half-filled search form is gone. Vue's <KeepAlive> keeps the component instance in memory instead, and Nuxt exposes it through the same macro:
<script setup lang="ts">
definePageMeta({
keepalive: true,
})
const query = ref('')
const { data: courses } = await useFetch('/api/courses', { query: { q: query } })
</script>
Navigate to a course and back, and query and the list are still there. keepalive accepts the same options as Vue's component, so keepalive: { max: 3 } caps the cache and include and exclude filter by component name.
You can also turn it on for every page with app.keepalive: true in nuxt.config.ts. Do that only when most pages benefit: a cached page holds its memory and keeps its watchers running while it is hidden.