From nuxt-v4
Optimizes Nuxt 4 production: hydration mismatch debugging, Core Web Vitals performance, Vitest testing, Cloudflare/Vercel/Netlify deployment, v3 to v4 migration.
npx claudepluginhub secondsky/claude-skills --plugin nuxt-v4This skill uses the workspace's default tool permissions.
Hydration, performance, testing, deployment, and migration patterns.
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
Hydration, performance, testing, deployment, and migration patterns.
1. Abort Control for Data Fetching
const controller = ref<AbortController>()
const { data } = await useAsyncData(
'users',
() => $fetch('/api/users', { signal: controller.value?.signal })
)
const abortRequest = () => {
controller.value?.abort()
controller.value = new AbortController()
}
2. Async Data Handler Extraction
3. Enhanced Error Handling
1. Enhanced Chunk Stability
2. Lazy Hydration
<script setup>
const LazyComponent = defineLazyHydrationComponent(() =>
import('./HeavyComponent.vue')
)
</script>
| Change | v3 | v4 |
|---|---|---|
| Source directory | Root | app/ |
| Data reactivity | Deep | Shallow (default) |
| Default values | null | undefined |
| Route middleware | Client | Server |
| App manifest | Opt-in | Default |
Load references/hydration.md when:
Load references/performance.md when:
Load references/testing-vitest.md when:
Load references/deployment-cloudflare.md when:
| Cause | Example | Fix |
|---|---|---|
| Non-deterministic values | Math.random() | Use useState |
| Browser APIs on server | window.innerWidth | Use onMounted |
| Date/time on server | new Date() | Use useState or ClientOnly |
| Third-party scripts | Analytics | Use ClientOnly |
Non-deterministic Values:
<!-- WRONG -->
<script setup>
const id = Math.random()
</script>
<!-- CORRECT -->
<script setup>
const id = useState('random-id', () => Math.random())
</script>
Browser APIs:
<!-- WRONG -->
<script setup>
const width = window.innerWidth // Crashes on server!
</script>
<!-- CORRECT -->
<script setup>
const width = ref(0)
onMounted(() => {
width.value = window.innerWidth
})
</script>
ClientOnly Component:
<template>
<!-- Wrap client-only content -->
<ClientOnly>
<MyMapComponent />
<template #fallback>
<div class="skeleton">Loading map...</div>
</template>
</ClientOnly>
</template>
Conditional Rendering:
<script setup>
const showWidget = ref(false)
onMounted(() => {
// Only show after hydration
showWidget.value = true
})
</script>
<template>
<AnalyticsWidget v-if="showWidget" />
</template>
<script setup>
// Lazy load heavy components
const HeavyChart = defineAsyncComponent(() =>
import('~/components/HeavyChart.vue')
)
// With loading/error states
const HeavyChart = defineAsyncComponent({
loader: () => import('~/components/HeavyChart.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorFallback,
delay: 200,
timeout: 10000
})
</script>
<template>
<Suspense>
<HeavyChart :data="chartData" />
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
<script setup>
// Hydrate when visible in viewport
const LazyComponent = defineLazyHydrationComponent(
() => import('./HeavyComponent.vue'),
{ hydrate: 'visible' }
)
// Hydrate on user interaction
const InteractiveComponent = defineLazyHydrationComponent(
() => import('./InteractiveComponent.vue'),
{ hydrate: 'interaction' }
)
// Hydrate when browser is idle
const IdleComponent = defineLazyHydrationComponent(
() => import('./IdleComponent.vue'),
{ hydrate: 'idle' }
)
</script>
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// Static pages (prerendered at build)
'/': { prerender: true },
'/about': { prerender: true },
// SWR caching (1 hour)
'/blog/**': { swr: 3600 },
// ISR (regenerate every hour)
'/products/**': { isr: 3600 },
// SPA mode (no SSR)
'/dashboard/**': { ssr: false },
// Static with CDN caching
'/static/**': {
headers: { 'Cache-Control': 'public, max-age=31536000' }
}
}
})
<template>
<!-- Automatic optimization with NuxtImg -->
<NuxtImg
src="/images/hero.jpg"
alt="Hero image"
width="800"
height="400"
loading="lazy"
placeholder
format="webp"
/>
<!-- Responsive images -->
<NuxtPicture
src="/images/product.jpg"
alt="Product"
sizes="sm:100vw md:50vw lg:400px"
:modifiers="{ quality: 80 }"
/>
</template>
bun add -d @nuxt/test-utils vitest @vue/test-utils happy-dom
// vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
environmentOptions: {
nuxt: {
domEnvironment: 'happy-dom'
}
}
}
})
// tests/components/UserCard.test.ts
import { describe, it, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import UserCard from '~/components/UserCard.vue'
describe('UserCard', () => {
it('renders user name', async () => {
const wrapper = await mountSuspended(UserCard, {
props: {
user: { id: 1, name: 'John Doe', email: 'john@example.com' }
}
})
expect(wrapper.text()).toContain('John Doe')
expect(wrapper.text()).toContain('john@example.com')
})
it('emits delete event', async () => {
const wrapper = await mountSuspended(UserCard, {
props: { user: { id: 1, name: 'John' } }
})
await wrapper.find('[data-test="delete-btn"]').trigger('click')
expect(wrapper.emitted('delete')).toHaveLength(1)
expect(wrapper.emitted('delete')[0]).toEqual([1])
})
})
// tests/components/Dashboard.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime'
import Dashboard from '~/pages/dashboard.vue'
// Mock useFetch
mockNuxtImport('useFetch', () => {
return () => ({
data: ref({ users: [{ id: 1, name: 'John' }] }),
pending: ref(false),
error: ref(null)
})
})
describe('Dashboard', () => {
it('displays users from API', async () => {
const wrapper = await mountSuspended(Dashboard)
expect(wrapper.text()).toContain('John')
})
})
// tests/api/users.test.ts
import { describe, it, expect } from 'vitest'
import { $fetch, setup } from '@nuxt/test-utils/e2e'
describe('API: /api/users', async () => {
await setup({ server: true })
it('returns users list', async () => {
const users = await $fetch('/api/users')
expect(users).toHaveProperty('users')
expect(Array.isArray(users.users)).toBe(true)
})
it('creates a new user', async () => {
const result = await $fetch('/api/users', {
method: 'POST',
body: { name: 'Jane', email: 'jane@example.com' }
})
expect(result.user.name).toBe('Jane')
})
})
# Build and deploy
bun run build
bunx wrangler pages deploy .output/public
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: 'cloudflare-pages'
}
})
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: 'cloudflare-module'
}
})
# wrangler.toml
name = "my-nuxt-app"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "xxx-xxx-xxx"
[[kv_namespaces]]
binding = "KV"
id = "xxx-xxx-xxx"
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: 'vercel'
}
})
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: 'netlify'
}
})
bun add @nuxthub/core
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxthub/core'],
hub: {
database: true, // D1
kv: true, // KV
blob: true, // R2
cache: true // Cache API
}
})
// Usage in server routes
export default defineEventHandler(async (event) => {
const db = hubDatabase()
const kv = hubKV()
const blob = hubBlob()
// Use like regular Cloudflare bindings
const users = await db.prepare('SELECT * FROM users').all()
})
# .env (development)
API_SECRET=dev-secret
DATABASE_URL=http://localhost:8787
# Production (Cloudflare)
wrangler secret put API_SECRET
wrangler secret put DATABASE_URL
# Production (Vercel/Netlify)
# Set in dashboard or CLI
{
"devDependencies": {
"nuxt": "^4.0.0"
}
}
// nuxt.config.ts
export default defineNuxtConfig({
future: {
compatibilityVersion: 4
}
})
# Create app directory
mkdir app
# Move files
mv components app/
mv composables app/
mv pages app/
mv layouts app/
mv middleware app/
mv plugins app/
mv assets app/
mv app.vue app/
mv error.vue app/
// If mutating data.value properties:
const { data } = await useFetch('/api/user', {
deep: true // Enable deep reactivity
})
// Or replace entire value
data.value = { ...data.value, name: 'New Name' }
// v3: data.value is null
// v4: data.value is undefined
// Update null checks
if (data.value === null) // v3
if (!data.value) // v4 (works for both)
// WRONG
const width = window.innerWidth
// CORRECT
if (import.meta.client) {
const width = window.innerWidth
}
// Or use onMounted
onMounted(() => {
const width = window.innerWidth
})
// WRONG - Different on server vs client
const id = Math.random()
const time = Date.now()
// CORRECT - Use useState for consistency
const id = useState('id', () => Math.random())
const time = useState('time', () => Date.now())
<!-- WRONG -->
<AsyncComponent />
<!-- CORRECT -->
<Suspense>
<AsyncComponent />
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
Hydration Mismatch:
window, document, localStorage usageClientOnly or use onMountedMath.random(), Date.now(), crypto.randomUUID()Build Errors:
rm -rf .nuxt .output node_modules/.vite && bun install
Deployment Fails:
nitro.preset matches targetTests Failing:
@nuxt/test-utils is installedenvironment: 'nuxt'mountSuspended for async componentsVersion: 4.0.0 | Last Updated: 2025-12-28 | License: MIT