Nuxt layouts and middleware: what developers should know
As your application grows, managing shared UI structure and route protection becomes critical to maintainability and security. Nuxt layouts and middleware are the backbone of this architecture, letting you define consistent page templates and control access before a component ever renders. In this article, we'll explore how to structure pages effectively and protect routes so your team can build scalable applications confidently.
Understanding the layouts/ directory
Layouts in Nuxt provide a way to wrap pages with consistent UI scaffolding—think header, sidebar, footer, or navigation menus. Rather than repeating this structure in every page component, you define it once and apply it across multiple routes. This becomes especially valuable in large applications where navigation logic, user preferences, or theme switching need to persist across all pages.
Create your layout file inside layouts/default.vue:
<template>
<div class="app-container">
<header>
<nav>
<NuxtLink to="/">Home</NuxtLink>
<NuxtLink to="/about">About</NuxtLink>
<UserMenu v-if="user" :user="user" />
</nav>
</header>
<aside class="sidebar">
<SidebarNav />
</aside>
<main>
<slot />
</main>
<footer>
<p>© 2024 Our App</p>
</footer>
</div>
</template>
<script setup lang="ts">
const { user } = useAuth()
const { isDarkMode } = useTheme()
// Shared logic for all pages using this layout
watch(() => isDarkMode.value, (newValue) => {
document.documentElement.classList.toggle('dark', newValue)
})
</script>
<style scoped>
.app-container {
display: grid;
grid-template-columns: 250px 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
}
header {
grid-column: 1 / -1;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
aside {
padding: 1rem;
background-color: #f9fafb;
}
main {
padding: 2rem;
overflow-y: auto;
}
footer {
grid-column: 1 / -1;
padding: 1rem;
border-top: 1px solid #e5e7eb;
background-color: #f9fafb;
}
</style>Every page that uses this layout will render inside the <slot />. This approach keeps your templates DRY and makes global UI changes simple—update the layout once and all pages inherit the change. In a SaaS application, this means you can update the navigation bar or add a notification banner without touching dozens of page files.
You can also create specialized layouts for different sections of your application. A layouts/admin.vue might have different navigation and permissions than layouts/customer.vue. A layouts/blank.vue for full-screen experiences like login forms eliminates all chrome entirely:
<template>
<div class="blank-layout">
<slot />
</div>
</template>
<style scoped>
.blank-layout {
width: 100%;
height: 100vh;
}
</style>In e-commerce, you might have a layouts/checkout.vue that removes distracting sidebars and keeps focus on completing the purchase. Nuxt automatically makes all these layouts available to your pages without any registration step.
Assigning layouts with definePageMeta
By default, Nuxt applies the default layout to every page. To use a different layout—or no layout at all—use the definePageMeta composable at the top of your page component. This is a compile-time directive that tells Nuxt which layout to apply before rendering.
Here's a page that opts into the admin layout:
<template>
<div>
<h1>Dashboard</h1>
<DashboardStats />
<RecentActivity />
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin'
})
const stats = await $fetch('/api/admin/stats')
</script>For a login or onboarding page that shouldn't inherit any layout structure, use the blank layout:
<template>
<div class="login-container">
<LoginForm @success="handleLoginSuccess" />
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'blank'
})
const router = useRouter()
const handleLoginSuccess = async (credentials) => {
await navigateTo('/dashboard')
}
</script>
<style scoped>
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>This is particularly valuable in e-commerce and SaaS applications where checkout flows or authentication pages need visual isolation from the main application chrome. A checkout page might use a minimal layout with only a logo and progress indicator, while a confirmation page could have a different layout entirely to prevent users from accidentally navigating away.
Protecting routes with middleware
Route middleware in Nuxt runs before a page renders, making it ideal for authentication checks, permission validation, and redirects. Unlike layouts which are purely structural, middleware is functional—it can prevent a page from rendering entirely. Define middleware in the middleware/ directory and reference it in your pages.
Create a simple auth middleware:
// middleware/auth.ts
export default defineRouteMiddleware((to, from) => {
const { user, isAuthenticated } = useAuth()
if (!isAuthenticated.value) {
// Store the intended destination so we can redirect back after login
return navigateTo(`/login?redirect=${to.path}`)
}
})
Then apply it to a protected page:
<template>
<div>
<h1>My Orders</h1>
<OrdersList :orders="orders" />
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
const { orders, fetchOrders } = useOrders()
onMounted(async () => {
await fetchOrders()
})
</script>The middleware runs before onMounted hooks, so you're guaranteed the user is authenticated when your component logic executes. This prevents race conditions where you might fetch user data before confirming authentication.
You can also apply middleware as an array to combine multiple guards:
definePageMeta({
middleware: ['auth', 'admin']
})
Middleware executes server-side during prerendering and client-side on navigation, so you get protection in both environments. This is especially important for CRM and trading applications where unauthorized access could expose sensitive data or allow unintended transactions.
For more complex permission checks, consider a middleware that validates role-based access. In a real-world scenario, you might need to check multiple conditions:
// middleware/admin.ts
export default defineRouteMiddleware((to, from) => {
const { user, isAuthenticated } = useAuth()
if (!isAuthenticated.value) {
return navigateTo('/login')
}
const hasAdminRole = user.value?.roles?.includes('admin')
const hasModeratorRole = user.value?.roles?.includes('moderator')
if (!hasAdminRole && !hasModeratorRole) {
return navigateTo('/unauthorized')
}
// Log admin access for audit trails
if (process.server) {
console.log(`Admin access: ${user.value.id} accessing ${to.path}`)
}
})
In ride-hailing applications, you might need middleware that checks if a driver has completed their background check and vehicle verification:
// middleware/driver-verified.ts
export default defineRouteMiddleware(async (to, from) => {
const { user, isAuthenticated } = useAuth()
if (!isAuthenticated.value) {
return navigateTo('/login')
}
if (user.value?.type !== 'driver') {
return navigateTo('/')
}
const { isVerified, isPendingVerification } = await useDriverVerification()
if (!isVerified) {
if (isPendingVerification) {
return navigateTo('/driver/verification-pending')
}
return navigateTo('/driver/complete-verification')
}
})
Global middleware with the .global suffix
Sometimes you need middleware to run on every route—think logging, analytics, or checking session expiry. Nuxt treats any middleware file ending in .global as global middleware that applies automatically without any page-level configuration.
Create a global middleware for session management:
// middleware/session.global.ts
export default defineRouteMiddleware((to, from) => {
const session = useSession()
// Skip checks on login/logout pages to avoid redirect loops
if (to.path === '/login' || to.path === '/logout') {
return
}
// Check if session is still valid on each navigation
if (session.isExpired()) {
session.clear()
return navigateTo('/login?reason=session-expired')
}
// Refresh session timeout on each navigation
session.extend()
})
This runs before every page transition, keeping your session alive and checking for expiry transparently. In a ride-hailing or travel application, this ensures users stay authenticated throughout their journey without interruption. A user booking a ride shouldn't have to log back in midway through.
You can also use global middleware for analytics or telemetry. This is particularly useful in SaaS where tracking user behavior informs product decisions:
// middleware/analytics.global.ts
export default defineRouteMiddleware((to, from) => {
if (process.client) {
const analytics = useAnalytics()
analytics.trackPageView({
path: to.path,
name: to.name,
from: from.path,
timestamp: Date.now()
})
// Track specific user journeys
if (to.name === 'checkout') {
analytics.trackEvent('checkout_started', {
from_page: from.path
})
}
}
})
Global middleware runs in the order files are discovered, so name them with numeric prefixes if order matters. Session validation should happen before analytics so you don't track unauthenticated users:
// middleware/01-session.global.ts
// middleware/02-analytics.global.ts
You can also add error recovery middleware that automatically handles common failure scenarios:
// middleware/error-recovery.global.ts
export default defineRouteMiddleware((to, from) => {
// If we're coming from an error page, clear the error state
if (from.name === 'error') {
const errorStore = useErrorStore()
errorStore.clear()
}
// Check for stale data and refresh if needed
const dataStore = useDataStore()
if (dataStore.isStale()) {
dataStore.refresh()
}
})
Middleware execution order and data flow
Understanding when middleware runs is critical for avoiding subtle bugs. Global middleware runs first in filename order, then page-specific middleware in the order declared. This happens before the page component's setup script, but after route parameters are available.
If you need to pass data from middleware to your page component, use a composable that holds reactive state:
// composables/usePageData.ts
export const usePageData = () => {
const data = ref(null)
const loading = ref(false)
return {
data,
loading,
setData: (value) => { data.value = value }
}
}
// middleware/load-data.ts
export default defineRouteMiddleware(async (to) => {
const { setData } = usePageData()
try {
const result = await $fetch(`/api/data/${to.params.id}`)
setData(result)
} catch (error) {
console.error('Failed to load data:', error)
}
})
// pages/item/[id].vue
<template>
<div>
<div v-if="!data">Loading...</div>
<div v-else>
<h1>{{ data.title }}</h1>
</div>
</div>
</template>
<script setup>
const { data } = usePageData()
</script>Common mistakes
- Forgetting to wrap global logic in process checks: Global middleware runs on every navigation in both server and client environments. If it performs browser-only operations like DOM manipulation without checking
process.client, your server-side rendering will fail. Always guard client-specific code and add early returns for unnecessary processing on routes that don't need it. - Creating redirect loops with middleware: If your auth middleware redirects to
/loginbut the login page also has auth middleware, you've created a loop. Exclude auth pages from auth middleware by checkingto.pathat the start of the middleware function. - Mixing layout concerns with middleware: Layouts define UI structure; middleware controls access. Don't put authentication checks in a layout—use middleware instead. It's cleaner, runs before rendering (preventing layout flashes), and separates concerns properly.
- Nesting middleware logic too deeply: If you're applying the same middleware to many pages, make it global or consider a plugin for initialization. Repeating
definePageMetaacross dozens of files becomes hard to maintain and easy to miss. - Blocking middleware without feedback or context: When middleware redirects a user away, provide context about why. If they're sent to
/login, store the intended destination so you can redirect back after authentication. If they're sent to/unauthorized, show a clear message explaining what permissions they need. - Not handling async operations properly: Middleware can return promises, but if you're fetching user data asynchronously, ensure your auth composable has cached or pre-loaded that data. Otherwise, middleware delays page rendering while waiting for network requests, harming user experience.
- Applying too much middleware: Each page paying attention to 5+ middleware functions becomes hard to reason about. Keep middleware focused on a single responsibility and compose them logically. A page might need auth and role-check, but not analytics and theme-loading.
- Not testing middleware in both environments: Middleware runs server-side during prerendering and client-side on navigation. Test both paths—SSR middleware and client navigation separately—to catch environment-specific bugs.
Key takeaways
- Layouts live in
layouts/and wrap pages with consistent UI structure using<slot />. Create multiple layouts for different sections of your app (admin, checkout, blank, etc.). - Use
definePageMeta({ layout: 'name' })to assign a layout to a specific page or override the default layout. - Route middleware runs before navigation and is perfect for auth checks, data fetching, and route guards. Define it in
middleware/and reference it in pages by name. - Global middleware (files ending in
.global.ts) runs on every route and is ideal for session checks, analytics, error recovery, and cross-cutting concerns. Name files with numeric prefixes to control execution order. - Middleware executes server-side during prerendering and client-side on navigation, giving you protection in both environments. Guard client-specific code with
process.clientchecks. - Combine middleware arrays for complex permission checks and keep each middleware focused on a single responsibility to maintain clarity.
- Use composables to pass data from middleware to page components, maintaining a clean reactive data flow through your application.
- Prevent redirect loops by checking the current route path and exempting auth pages from auth middleware.






Comments
Loading comments…