diff --git a/docs/content/3.api/1.composables/set-layout.md b/docs/content/3.api/1.composables/set-layout.md
new file mode 100644
index 00000000000..17973577d28
--- /dev/null
+++ b/docs/content/3.api/1.composables/set-layout.md
@@ -0,0 +1,16 @@
+# `setLayout`
+
+`setLayout` allows you to dynamically change the layout of a page.
+
+`setLayout` relies on access to the Nuxt context and can only be called within component setup functions, plugins, and route middleware.
+
+```js
+export default defineNuxtRouteMiddleware(to => {
+ // Set the layout on the route you are navigating _to_
+ setLayout('other')
+})
+```
+
+::alert{icon=👉}
+If you choose to set the layout dynamically on the server-side, you _must_ do so before the layout is rendered by Vue. (In other words, within a plugin or route middleware.) Otherwise there will be a hydration mismatch.
+::
diff --git a/examples/routing/layouts/middleware/other.ts b/examples/routing/layouts/middleware/other.ts
new file mode 100644
index 00000000000..279f8eb920a
--- /dev/null
+++ b/examples/routing/layouts/middleware/other.ts
@@ -0,0 +1,4 @@
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export default defineNuxtRouteMiddleware(() => {
+ setLayout('other')
+})
diff --git a/examples/routing/layouts/pages/index.vue b/examples/routing/layouts/pages/index.vue
index e66905fb951..fac4b038a02 100644
--- a/examples/routing/layouts/pages/index.vue
+++ b/examples/routing/layouts/pages/index.vue
@@ -11,6 +11,18 @@
Dynamic layout
+
+ Other layout
+
+
+ Change to default layout
+
+
+ Change to custom layout
+
+
+ Change to other layout
+
diff --git a/examples/routing/layouts/pages/other.vue b/examples/routing/layouts/pages/other.vue
new file mode 100644
index 00000000000..00b14af93a6
--- /dev/null
+++ b/examples/routing/layouts/pages/other.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Back to home
+
+
+
diff --git a/packages/nuxt/src/app/composables/index.ts b/packages/nuxt/src/app/composables/index.ts
index b3a31a9f43d..0b4b3f16115 100644
--- a/packages/nuxt/src/app/composables/index.ts
+++ b/packages/nuxt/src/app/composables/index.ts
@@ -10,6 +10,6 @@ export type { FetchResult, UseFetchOptions } from './fetch'
export { useCookie } from './cookie'
export type { CookieOptions, CookieRef } from './cookie'
export { useRequestHeaders, useRequestEvent, setResponseStatus } from './ssr'
-export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, navigateTo, useRoute, useActiveRoute, useRouter } from './router'
+export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, setLayout, navigateTo, useRoute, useActiveRoute, useRouter } from './router'
export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router'
export { preloadComponents, prefetchComponents } from './preload'
diff --git a/packages/nuxt/src/app/composables/router.ts b/packages/nuxt/src/app/composables/router.ts
index 188d8c0bca9..542eb8aab7b 100644
--- a/packages/nuxt/src/app/composables/router.ts
+++ b/packages/nuxt/src/app/composables/router.ts
@@ -2,7 +2,7 @@ import { getCurrentInstance, inject } from 'vue'
import type { Router, RouteLocationNormalizedLoaded, NavigationGuard, RouteLocationNormalized, RouteLocationRaw, NavigationFailure, RouteLocationPathRaw } from 'vue-router'
import { sendRedirect } from 'h3'
import { hasProtocol, joinURL, parseURL } from 'ufo'
-import { useNuxtApp, useRuntimeConfig } from '#app'
+import { useNuxtApp, useRuntimeConfig, useState } from '#app'
export const useRouter = () => {
return useNuxtApp()?.$router as Router
@@ -114,3 +114,17 @@ export const abortNavigation = (err?: Error | string) => {
}
return false
}
+
+export const setLayout = (layout: string) => {
+ if (process.server) {
+ useState('_layout').value = layout
+ }
+ if (isProcessingMiddleware()) {
+ const unsubscribe = useRouter().beforeResolve((to) => {
+ to.meta.layout = layout
+ unsubscribe()
+ })
+ } else {
+ useRoute().meta.layout = layout
+ }
+}
diff --git a/packages/nuxt/src/app/plugins/router.ts b/packages/nuxt/src/app/plugins/router.ts
index 18954954549..64bf5d50a6c 100644
--- a/packages/nuxt/src/app/plugins/router.ts
+++ b/packages/nuxt/src/app/plugins/router.ts
@@ -1,7 +1,7 @@
import { reactive, h } from 'vue'
import { parseURL, stringifyParsedURL, parseQuery, stringifyQuery, withoutBase, isEqual, joinURL } from 'ufo'
import { createError } from 'h3'
-import { defineNuxtPlugin, clearError, navigateTo, showError, useRuntimeConfig } from '..'
+import { defineNuxtPlugin, clearError, navigateTo, showError, useRuntimeConfig, useState } from '..'
import { callWithNuxt } from '../nuxt'
// @ts-ignore
import { globalMiddleware } from '#build/middleware'
@@ -218,9 +218,13 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>((nuxtApp) => {
named: {}
}
+ const initialLayout = useState('_layout')
nuxtApp.hooks.hookOnce('app:created', async () => {
router.beforeEach(async (to, from) => {
to.meta = reactive(to.meta || {})
+ if (nuxtApp.isHydrating) {
+ to.meta.layout = initialLayout.value ?? to.meta.layout
+ }
nuxtApp._processingMiddleware = true
const middlewareEntries = new Set([...globalMiddleware, ...nuxtApp._middleware.global])
diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts
index 6985af5b27e..f2838776f3c 100644
--- a/packages/nuxt/src/imports/presets.ts
+++ b/packages/nuxt/src/imports/presets.ts
@@ -25,6 +25,7 @@ const appPreset = defineUnimportPreset({
'useAsyncData',
'useLazyAsyncData',
'refreshNuxtData',
+ 'setLayout',
'defineNuxtComponent',
'useNuxtApp',
'defineNuxtPlugin',
diff --git a/packages/nuxt/src/pages/runtime/router.ts b/packages/nuxt/src/pages/runtime/router.ts
index cb12db1f574..32163890039 100644
--- a/packages/nuxt/src/pages/runtime/router.ts
+++ b/packages/nuxt/src/pages/runtime/router.ts
@@ -9,7 +9,7 @@ import {
import { createError } from 'h3'
import { withoutBase, isEqual } from 'ufo'
import NuxtPage from './page'
-import { callWithNuxt, defineNuxtPlugin, useRuntimeConfig, showError, clearError, navigateTo, useError } from '#app'
+import { callWithNuxt, defineNuxtPlugin, useRuntimeConfig, showError, clearError, navigateTo, useError, useState } from '#app'
// @ts-ignore
import routes from '#build/routes'
// @ts-ignore
@@ -114,8 +114,12 @@ export default defineNuxtPlugin(async (nuxtApp) => {
callWithNuxt(nuxtApp, showError, [error])
}
+ const initialLayout = useState('_layout')
router.beforeEach(async (to, from) => {
to.meta = reactive(to.meta)
+ if (nuxtApp.isHydrating) {
+ to.meta.layout = initialLayout.value ?? to.meta.layout
+ }
nuxtApp._processingMiddleware = true
type MiddlewareDef = string | NavigationGuard
diff --git a/test/basic.test.ts b/test/basic.test.ts
index 893f40ef7ce..497aa9c6425 100644
--- a/test/basic.test.ts
+++ b/test/basic.test.ts
@@ -273,6 +273,16 @@ describe('layouts', () => {
expect(html).toContain('with-layout.vue')
expect(html).toContain('Custom Layout:')
})
+ it('should work with a dynamically set layout', async () => {
+ const html = await $fetch('/with-dynamic-layout')
+
+ // Snapshot
+ // expect(html).toMatchInlineSnapshot()
+
+ expect(html).toContain('with-dynamic-layout')
+ expect(html).toContain('Custom Layout:')
+ await expectNoClientErrors('/with-dynamic-layout')
+ })
})
describe('reactivity transform', () => {
diff --git a/test/fixtures/basic/middleware/sets-layout.ts b/test/fixtures/basic/middleware/sets-layout.ts
new file mode 100644
index 00000000000..52c52f21f74
--- /dev/null
+++ b/test/fixtures/basic/middleware/sets-layout.ts
@@ -0,0 +1,4 @@
+export default defineNuxtRouteMiddleware(async () => {
+ await new Promise(resolve => setTimeout(resolve, 10))
+ setLayout('custom')
+})
diff --git a/test/fixtures/basic/pages/with-dynamic-layout.vue b/test/fixtures/basic/pages/with-dynamic-layout.vue
new file mode 100644
index 00000000000..6e2d5895489
--- /dev/null
+++ b/test/fixtures/basic/pages/with-dynamic-layout.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
with-dynamic-layout.vue
+
+