| name | nuxt-4-skill |
| description | This skill ensures AI agents write correct, modern Nuxt 4 code and avoid common mistakes from outdated Nuxt 3 patterns. Always read this before writing any Nuxt code. |
Nuxt 4 Development Skill
Purpose: This skill ensures AI agents write correct, modern Nuxt 4 code and avoid common mistakes from outdated Nuxt 3 patterns. Always read this before writing any Nuxt code.
Last verified: February 2025 | Nuxt 4.x stable (released July 2025)
1. Critical Context: Nuxt 4 vs Nuxt 3
Nuxt 4 was officially released in July 2025. It is a stability-focused major release that builds on Nuxt 3 with breaking changes in project structure, data fetching, TypeScript, and tooling. Nuxt 3 reaches end-of-maintenance in January 2026.
If you have training data from Nuxt 3, many patterns are now outdated. Always prefer the patterns documented in this skill.
Key Differences at a Glance
| Area | Nuxt 3 (Old) | Nuxt 4 (Current) |
|---|
| Directory structure | Flat root (pages/, components/ at root) | app/ directory (source code lives in app/) |
| Data fetching reactivity | Deep reactive refs by default | shallowRef by default for better performance |
| Data fetching keys | Independent per call | Singleton: shared refs for same key |
| Route middleware | defineNuxtRouteMiddleware (some guides) | defineNuxtRouteMiddleware with async support |
<head> management | Unhead v1 | Unhead v2 (removed vmid, hid, children) |
| Component names | Vue default naming | Normalized to match Nuxt auto-import pattern |
| Module loading in layers | Project modules loaded first (incorrect) | Layer modules first, project modules last (correct) |
| Route metadata | Available on both route.name and route.meta.name | Only on route.name (deduplicated) |
| Page meta scanning | Before pages:extend hook | After pages:extend hook |
| Node.js requirement | 16+ | 18.20+ (LTS 20+ recommended) |
| Generate config | generate.exclude, generate.routes | Removed. Use nitro.prerender instead |
| Inline styles | All CSS inlined | Only Vue component CSS inlined; global CSS as separate file |
2. Project Structure (CRITICAL CHANGE)
Nuxt 4 Default Structure
my-nuxt-app/
├── app/ # ← ALL application source code goes here
│ ├── assets/
│ ├── components/
│ ├── composables/
│ ├── layouts/
│ ├── middleware/ # Route middleware (client-side)
│ ├── pages/
│ ├── plugins/
│ ├── utils/
│ ├── app.vue
│ ├── app.config.ts
│ ├── error.vue
│ ├── router.options.ts
│ └── spa-loading-template.html
├── content/ # Nuxt Content (at root, NOT inside app/)
├── layers/ # Nuxt Layers (at root)
├── modules/ # Local modules (at root)
├── public/ # Static assets (at root)
├── shared/ # Shared code between app and server
├── server/ # Server code (at root, NOT inside app/)
│ ├── api/
│ ├── middleware/ # Server middleware (Nitro/h3)
│ ├── plugins/
│ ├── routes/
│ └── utils/
├── nuxt.config.ts
├── package.json
└── tsconfig.json
Rules
- The
~ alias now points to app/ by default (your srcDir).
~/components resolves to app/components/, ~/pages to app/pages/, etc.
server/, content/, layers/, modules/, and public/ stay at the project root, NOT inside app/.
- If the project still uses the flat (Nuxt 3) structure, Nuxt auto-detects it — but always prefer the new structure for new projects.
Why This Matters
- Performance: Prevents
.git/ and node_modules/ from being scanned by file watchers.
- IDE Type Safety: Separates app context from server context for accurate auto-completion.
Reverting to Nuxt 3 Structure (if needed)
export default defineNuxtConfig({
srcDir: '.',
dir: {
app: 'app',
},
})
3. Data Fetching (SIGNIFICANT CHANGES)
The Three Methods
| Method | Use Case |
|---|
useFetch | Simple data fetching in components (wrapper around useAsyncData + $fetch) |
useAsyncData | Complex async operations, custom fetch logic |
$fetch | Direct HTTP calls (event handlers, non-component code, client-only actions) |
Critical Nuxt 4 Changes
3.1 Shallow Reactivity by Default
const { data } = await useFetch('/api/users')
const { data } = await useFetch('/api/users', { deep: true })
Do NOT set deep: true globally unless absolutely necessary. ShallowRef provides massive performance improvements on nested data structures.
3.2 Singleton Data Fetching (Shared Refs)
All calls to useAsyncData/useFetch with the same key now share the same data, error, and status refs.
const { data: product } = await useFetch(`/api/products/${id}`, {
key: `product-${id}`
})
const { data: product } = await useFetch(`/api/products/${id}`, {
key: `product-${id}`
})
Important Rules:
- All calls with the same explicit key must have identical
deep, transform, pick, getCachedData, and default options.
- Extract shared data fetching into composables to avoid inconsistencies:
export function useProduct(productId: string) {
return useAsyncData(
`product-${productId}`,
() => $fetch(`/api/products/${productId}`),
{
deep: true,
transform: (product) => ({
...product,
formattedPrice: `${product.price.toFixed(2)} kr`,
}),
}
)
}
3.3 getCachedData Now Receives Context
useAsyncData('key', fetchFunction, {
getCachedData: (key, nuxtApp, ctx) => {
if (ctx.cause === 'refresh:manual') return undefined
return nuxtApp.payload.data[key] || nuxtApp.static.data[key]
}
})
3.4 Reactive Keys
const id = computed(() => route.params.id)
const { data: post } = await useFetch(() => `/api/posts/${id.value}`)
3.5 Automatic Data Cleanup
When the last component using a useAsyncData key unmounts, Nuxt automatically removes the cached data to prevent memory leaks.
3.6 Common Data Fetching Mistakes to Avoid
const data = ref(null)
data.value = await $fetch('/api/users')
const { data } = await useFetch('/api/users')
const { data: a } = useAsyncData('users', () => $fetch('/api/users'), { deep: false })
const { data: b } = useAsyncData('users', () => $fetch('/api/users'), { deep: true })
export default defineEventHandler(async () => {
const [users, posts] = await Promise.all([
$fetch('/api/users'),
$fetch('/api/posts'),
])
return { users, posts }
})
4. Server API Routes
Server routes live in server/ at the project root (NOT inside app/).
File-Based Routing
server/
├── api/
│ ├── hello.ts → GET/POST/etc. /api/hello
│ ├── hello.get.ts → GET /api/hello
│ ├── hello.post.ts → POST /api/hello
│ ├── users/
│ │ ├── index.get.ts → GET /api/users
│ │ ├── index.post.ts → POST /api/users
│ │ └── [id].get.ts → GET /api/users/:id
├── routes/
│ └── health.ts → /health (no /api prefix)
├── middleware/
│ └── log.ts → Runs on every request
├── plugins/
│ └── startup.ts → Nitro server plugins
└── utils/
└── auth.ts → Auto-imported server utilities
Writing Event Handlers
export default defineEventHandler(async (event) => {
const query = getQuery(event)
return { users: ['Alice', 'Bob'] }
})
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, z.object({
name: z.string().min(1),
email: z.string().email(),
}).parse)
return { user: body }
})
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
return { id, name: 'Alice' }
})
Error Handling in Server Routes
export default defineEventHandler(async (event) => {
const user = await findUser(event)
if (!user) {
throw createError({
statusCode: 404,
message: 'User not found',
})
}
return user
})
export default defineEventHandler(async (event) => {
const user = await findUser(event)
if (!user) {
return { code: 404, message: 'User not found' }
}
return user
})
Server Middleware vs Server Utils (Important Pattern)
Prefer server utils over global server middleware for route-specific logic:
export async function requireAuth(event: H3Event) {
const session = await getUserSession(event)
if (!session) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
return session
}
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
return { secret: 'data' }
})
Only use global server middleware for truly global concerns (logging, CORS headers):
export default defineEventHandler((event) => {
console.log('Request:', getRequestURL(event))
})
Event Handler with Guards Pattern (Nuxt 4 / h3)
export default defineEventHandler({
onRequest: [
(event) => requireAuth(event),
(event) => requireRole(event, 'admin'),
],
handler: async (event) => {
return { dashboard: 'data' }
},
})
5. Route Middleware (Client-Side)
Route middleware lives in app/middleware/ and runs in the Vue/browser context (not server).
Types of Middleware
- Named:
app/middleware/auth.ts → applied via definePageMeta
- Global:
app/middleware/tracking.global.ts → runs on every route change
- Inline: Defined directly in
definePageMeta
export default defineNuxtRouteMiddleware((to, from) => {
const { isLoggedIn } = useAuth()
if (!isLoggedIn) {
return navigateTo('/login')
}
})
definePageMeta({
middleware: ['auth'],
})
Nuxt 4: Async Middleware Support
export default defineNuxtRouteMiddleware(async (to, from) => {
const { data } = await useFetch('/api/auth/check')
if (!data.value?.authenticated) {
return navigateTo('/login')
}
})
Return Values
undefined or nothing → continue navigation
navigateTo('/path') → redirect (302 on server)
navigateTo('/path', { redirectCode: 301 }) → permanent redirect
abortNavigation() → cancel navigation
abortNavigation(error) → cancel with error
6. nuxt.config.ts — Nuxt 4 Patterns
export default defineNuxtConfig({
runtimeConfig: {
apiSecret: '',
public: {
apiBase: '',
},
},
modules: [
'@nuxtjs/supabase',
'@nuxt/ui',
],
nitro: {
prerender: {
ignore: ['/admin', '/private'],
routes: ['/sitemap.xml'],
},
},
routeRules: {
'/': { prerender: true },
'/api/**': { cache: { maxAge: 60 * 60 } },
'/old-page': { redirect: { to: '/new-page', statusCode: 302 } },
},
})
7. <head> Management (Unhead v2)
Removed Props
useHead({
meta: [{ name: 'description', content: '...', vmid: 'desc', hid: 'desc' }]
})
useHead({
meta: [{ name: 'description', content: '...' }]
})
Import from #imports Instead of @unhead/vue
import { useHead, useSeoMeta } from '#imports'
import { useHead } from '@unhead/vue'
useSeoMeta — Preferred for SEO
useSeoMeta({
title: 'About Us',
description: 'Learn about our company.',
ogTitle: 'About Us',
ogDescription: 'Learn about our company.',
ogImage: '/images/og-about.png',
})
8. Composables & Auto-Imports
Where to Place Composables
app/composables/ — auto-imported in app context
app/utils/ — auto-imported in app context
server/utils/ — auto-imported in server context
shared/ — shared between app and server (new in Nuxt 4)
Writing Composables
export function useCounter(initial = 0) {
const count = useState('counter', () => initial)
function increment() {
count.value++
}
return { count, increment }
}
const { count, increment } = useCounter()
useState for Shared State
const user = useState('user', () => null)
const user = ref(null)
9. TypeScript in Nuxt 4
Nuxt 4 generates a tsconfig.json with project references that separate:
app/ context (Vue, browser APIs, auto-imports)
server/ context (h3, Nitro, Node APIs)
shared/ context (code shared between both)
- Configuration files
This means:
- Server utils are NOT available in
app/ code (and vice versa)
- Use
shared/ for types and functions needed in both contexts
- IDE auto-complete is context-aware
Type-Safe Fetch
const { data } = await useFetch('/api/users')
10. Common Mistakes & Anti-Patterns
❌ Mistake 1: Placing server/ Inside app/
# WRONG
app/
server/ ← Server code must be at project root!
pages/
# CORRECT
app/
pages/
server/ ← At root level
❌ Mistake 2: Using generate Config
export default defineNuxtConfig({
generate: {
routes: ['/about', '/contact']
}
})
export default defineNuxtConfig({
nitro: {
prerender: {
routes: ['/about', '/contact']
}
}
})
❌ Mistake 3: Using $fetch in Component Setup
const users = ref([])
users.value = await $fetch('/api/users')
const { data: users } = await useFetch('/api/users')
❌ Mistake 4: Using vmid or hid in useHead
useHead({ meta: [{ name: 'desc', content: '...', hid: 'desc' }] })
useHead({ meta: [{ name: 'description', content: '...' }] })
❌ Mistake 5: Returning Error Objects from Server Routes
return { statusCode: 404, message: 'Not found' }
throw createError({ statusCode: 404, message: 'Not found' })
❌ Mistake 6: Accessing Route Meta Duplicates
const name = route.meta.name
const name = route.name
❌ Mistake 7: Deep Reactivity Assumptions
const { data } = await useFetch('/api/nested-object')
data.value.nested.property = 'new value'
data.value = { ...data.value, nested: { ...data.value.nested, property: 'new value' } }
const { data } = await useFetch('/api/nested-object', { deep: true })
❌ Mistake 8: Using pages:extend to Override Page Meta
hooks: {
'pages:extend'(pages) {
pages[0].meta.layout = 'custom'
}
}
hooks: {
'pages:resolved'(pages) {
pages[0].meta.layout = 'custom'
}
}
11. SSR-Safe Cookie and Session Handling
When calling protected API routes during SSR, the cookie is not automatically forwarded:
const { data } = await useFetch('/api/protected')
const { data } = await useFetch('/api/protected', {
headers: useRequestHeaders(['cookie']),
})
For auth patterns, consider using nuxt-auth-utils which handles this automatically with useUserSession().
12. Environment Variables & Runtime Config
export default defineNuxtConfig({
runtimeConfig: {
supabaseServiceKey: '',
public: {
supabaseUrl: '',
supabaseAnonKey: '',
},
},
})
const config = useRuntimeConfig()
config.public.supabaseUrl
const config = useRuntimeConfig()
config.supabaseServiceKey
13. Migration Checklist (Nuxt 3 → 4)
- Update Node.js to 18.20+ (LTS 20+ recommended)
- Install Nuxt 4:
npm install nuxt@^4.0.0
- Run codemods:
npx codemod@0.18.7 nuxt/4/migration-recipe
- Move source files into
app/ directory
- Keep
server/, content/, public/ at project root
- Replace
generate config with nitro.prerender
- Remove
vmid/hid from useHead calls
- Review
getCachedData implementations (new context parameter)
- Check
useAsyncData keys for conflicting options
- Update
route.meta.name to route.name
- Update
pages:extend meta overrides to pages:resolved
- Test thoroughly — especially data fetching, middleware, and SSR
14. Recommended Module Ecosystem
| Module | Purpose |
|---|
@nuxt/ui | UI component library (v4 for Nuxt 4) |
@nuxtjs/supabase | Supabase integration |
nuxt-auth-utils | Session/auth management |
@nuxt/content | Content management (v3 for Nuxt 4) |
@nuxtjs/tailwindcss | Tailwind CSS integration |
@nuxt/image | Image optimization |
@nuxt/eslint | ESLint configuration |
@pinia/nuxt | State management |
Always check module compatibility with Nuxt 4 before installing. Many popular modules have released Nuxt 4-compatible versions.
15. Official Documentation Links
Agent Instructions
When writing Nuxt code:
- Always use the
app/ directory structure for new projects.
- Never use
$fetch in component <script setup> — use useFetch or useAsyncData.
- Remember
shallowRef is the default — don't assume deep reactivity on fetched data.
- Use
createError() for server route errors, never return error objects.
- Forward cookies with
useRequestHeaders(['cookie']) when calling protected APIs during SSR.
- Prefer server utils over global server middleware for route-specific auth/validation.
- Use
#imports instead of importing from @unhead/vue or other Nuxt-provided packages directly.
- Check the Nuxt 4 docs at https://nuxt.com/docs/4.x for anything not covered here.
- Nuxt auto-imports are available — don't manually import
defineEventHandler, useFetch, useHead, etc. unless needed for type clarity.
- Use
shared/ directory for code that needs to run in both app and server contexts.