diff --git a/docs/skills/nuxt-architecture-review/SKILL.md b/docs/skills/nuxt-architecture-review/SKILL.md
new file mode 100644
index 000000000..abcc30551
--- /dev/null
+++ b/docs/skills/nuxt-architecture-review/SKILL.md
@@ -0,0 +1,423 @@
+---
+name: nuxt-architecture-review
+description: >
+ Review and audit a Nuxt application for architecture quality, code organization, and TypeScript
+ best practices. Use when asked to "review code quality", "audit architecture", "improve project
+ structure", "review TypeScript patterns", "check component design", "audit code organization",
+ or any structural/quality review on a Nuxt 3/4 project. Covers custom modules, shared/ directory,
+ composable organization, TypeScript patterns (discriminated unions, satisfies, schema inference),
+ server/client component split, route aliases, canonical redirects, defineModel, and security
+ patterns.
+---
+
+# Nuxt Architecture & Code Quality Review
+
+Audit a Nuxt application for architecture quality, project structure, TypeScript patterns, and component design. Scan the codebase for each pattern below, explain the concept, and propose fixes.
+
+For full code examples, see the `references/` files linked in each section.
+
+## Review Process
+
+1. Read project structure (`app/`, `server/`, `shared/`, `modules/`)
+2. Check `modules/` for custom Nuxt modules
+3. Scan composables for organization and patterns
+4. Review TypeScript usage across `shared/` and `server/`
+5. Check component architecture in `app/components/`
+6. Review routing patterns and middleware
+7. Report findings with explanations and suggested fixes
+
+---
+
+## 1. Custom Nuxt Modules for Cross-Cutting Concerns
+
+**What:** Nuxt modules are the standard way to encapsulate cross-cutting concerns (cache configuration, build info, environment-specific behavior) that don't belong in `nuxt.config.ts` or in app code. Each module is a self-contained unit with a single responsibility.
+
+**Why:** Putting all configuration in `nuxt.config.ts` leads to a massive, hard-to-maintain config file. Modules keep concerns separated, testable, and conditionally applied based on environment.
+
+**What to look for:** Check `nuxt.config.ts` for complex logic that should be extracted. Look for environment-specific code scattered across the app. Check if a `modules/` directory exists.
+
+**Good:**
+
+```ts
+// modules/cache.ts - single responsibility: cache configuration
+export default defineNuxtModule({
+ meta: { name: 'cache-config' },
+ setup(_, nuxt) {
+ if (provider !== 'vercel') return // Environment guard
+
+ nuxt.hook('nitro:config', nitroConfig => {
+ nitroConfig.storage = nitroConfig.storage || {}
+ nitroConfig.storage.cache = {
+ ...nitroConfig.storage.cache,
+ driver: 'vercel-runtime-cache',
+ }
+ })
+ },
+})
+```
+
+**Bad:**
+
+```ts
+// nuxt.config.ts with 200+ lines of inline logic
+export default defineNuxtConfig({
+ hooks: {
+ 'nitro:config': nitroConfig => {
+ // 50 lines of cache config
+ // 30 lines of build info
+ // 20 lines of ISR fallback
+ },
+ },
+})
+```
+
+**Fix:** Extract cross-cutting concerns into `modules/`. One module per concern. Use `provider` guards from `std-env`. See [references/modules-patterns.md](references/modules-patterns.md).
+
+---
+
+## 2. shared/ Directory for Cross-Boundary Code
+
+**What:** The `shared/` directory in Nuxt 4 contains types, schemas, and utilities shared between `app/` (client + SSR) and `server/` (Nitro). It's accessible via the `#shared` alias.
+
+**Why:** Without a shared directory, types and utilities get duplicated between `app/` and `server/`, or server-only code leaks into the client bundle. The `shared/` directory provides a clean boundary.
+
+**What to look for:** Check for duplicated types or utilities between `app/` and `server/`. Check if `server/` imports from `app/` (bad) or if types are defined inline in API routes.
+
+**Good:**
+
+```
+shared/
+ types/
+ product.ts # Shared between app and server
+ api-responses.ts
+ schemas/
+ product.ts # Valibot schemas used in both
+ utils/
+ formatters.ts # Pure functions used everywhere
+ constants.ts # Shared constants
+```
+
+**Bad:**
+
+```
+app/types/product.ts # Duplicated type definition
+server/types/product.ts # Same type, different file
+```
+
+**Fix:** Move shared types, schemas, and pure utilities to `shared/`. Import via `#shared/types/product` or `#shared/utils/formatters`. See [references/modules-patterns.md](references/modules-patterns.md).
+
+---
+
+## 3. Feature-Grouped Composables
+
+**What:** Organize composables by domain/feature (e.g., `composables/npm/`, `composables/auth/`) rather than by type (e.g., all composables flat in `composables/`).
+
+**Why:** A flat `composables/` directory with 30+ files becomes hard to navigate. Grouping by feature makes related composables discoverable and maintainable. The auto-import `dirs` config can handle nested directories.
+
+**What to look for:** Count composables in `app/composables/`. If there are more than 10, check if they're grouped by feature.
+
+**Good:**
+
+```
+app/composables/
+ npm/
+ useNpmSearch.ts
+ usePackage.ts
+ usePackageDownloads.ts
+ auth/
+ useAuth.ts
+ useSession.ts
+ useColors.ts # Standalone utilities
+ useMarkdown.ts
+```
+
+```ts
+// nuxt.config.ts - auto-import nested composables
+imports: {
+ dirs: ['~/composables', '~/composables/*/*.ts'],
+},
+```
+
+**Bad:**
+
+```
+app/composables/
+ useNpmSearch.ts
+ usePackage.ts
+ usePackageDownloads.ts
+ useAuth.ts
+ useSession.ts
+ useColors.ts
+ useMarkdown.ts
+ useVirtualScroll.ts
+ ... 20 more files
+```
+
+**Fix:** Group related composables into subdirectories. Update `imports.dirs` in `nuxt.config.ts`.
+
+---
+
+## 4. TypeScript Discriminated Unions
+
+**What:** Use a shared `kind` or `type` property to create exhaustive, type-safe status objects. TypeScript narrows the type automatically in `switch` statements and `if` checks.
+
+**Why:** Discriminated unions make impossible states unrepresentable. Without them, you end up with optional properties and runtime checks that can be forgotten.
+
+**What to look for:** Search for status/state objects with multiple optional properties. These often indicate a missing discriminated union.
+
+**Good:**
+
+```ts
+type TypesStatus =
+ | { kind: 'included' }
+ | { kind: '@types'; packageName: string; deprecated?: string }
+ | { kind: 'none' }
+
+function renderTypesStatus(status: TypesStatus) {
+ switch (status.kind) {
+ case 'included':
+ return 'Built-in types'
+ case '@types':
+ return `@types/${status.packageName}` // TS knows packageName exists
+ case 'none':
+ return 'No types'
+ }
+}
+```
+
+**Bad:**
+
+```ts
+interface TypesStatus {
+ hasTypes: boolean
+ typesPackage?: string
+ deprecated?: string
+}
+
+// Easy to forget checks, no exhaustiveness guarantee
+if (status.hasTypes) {
+ /* ... */
+} else if (status.typesPackage) {
+ /* ... */
+} // Could be undefined even when hasTypes is false
+```
+
+**Fix:** Refactor status objects to discriminated unions with a `kind` or `type` discriminant. See [references/typescript-patterns.md](references/typescript-patterns.md).
+
+---
+
+## 5. satisfies Operator
+
+**What:** The `satisfies` operator checks that a value conforms to a type without widening it. Unlike `as`, it preserves the literal types and catches structural errors at compile time.
+
+**Why:** Using `as` to type a return value suppresses errors and widens types. `satisfies` ensures correctness while preserving the exact shape for consumers.
+
+**What to look for:** Search for `as` type assertions on return values and object literals. These are often better expressed with `satisfies`.
+
+**Good:**
+
+```ts
+return {
+ package: packageName,
+ version: pkg.version ?? 'latest',
+ ...analysis,
+} satisfies PackageAnalysisResponse
+
+const defaults = {
+ objects: [],
+ total: 0,
+ time: new Date().toISOString(),
+} satisfies NpmSearchResponse
+```
+
+**Bad:**
+
+```ts
+// 'as' suppresses errors and widens the type
+return {
+ package: packageName,
+ version: pkg.version ?? 'latest',
+ ...analysis,
+} as PackageAnalysisResponse
+```
+
+**Fix:** Replace `as Type` with `satisfies Type` on return values and object literals. See [references/typescript-patterns.md](references/typescript-patterns.md).
+
+---
+
+## 6. Server/Client Component Split
+
+**What:** Nuxt supports `.server.vue` and `.client.vue` suffixes. A `.server.vue` component only renders on the server (SSR HTML, no hydration JS). A `.client.vue` component only renders on the client (after hydration). When both exist with the same name, Nuxt uses the server version during SSR and swaps to the client version after hydration.
+
+**Why:** Components that are only needed on the server (static UI, SEO content) should not ship JavaScript to the client. Components that need browser APIs (localStorage, window) should not run during SSR.
+
+**What to look for:** Check for components with `onMounted` guards wrapping all logic, or components that are purely presentational with no interactivity. These are candidates for server/client split.
+
+**Good:**
+
+```
+app/components/Header/
+ AccountMenu.server.vue # SSR: shows login button placeholder
+ AccountMenu.client.vue # Client: shows actual auth state with dropdown
+```
+
+**Bad:**
+
+```vue
+
+
+
+
+
+
+```
+
+**Fix:** Split into `.server.vue` and `.client.vue` files. See [references/component-patterns.md](references/component-patterns.md).
+
+---
+
+## 7. Route Aliases in definePageMeta
+
+**What:** `definePageMeta` supports an `alias` property that maps multiple URL patterns to the same page component. This avoids duplicating page components for different URL shapes.
+
+**Why:** Without aliases, you either duplicate page files or use complex redirects. Aliases keep one source of truth while supporting multiple URL patterns.
+
+**What to look for:** Check for duplicated page components or complex redirect middleware that could be replaced with route aliases.
+
+**Good:**
+
+```ts
+// app/pages/package-code/[...path].vue
+definePageMeta({
+ name: 'code',
+ path: '/package-code/:path+',
+ alias: ['/package/code/:path+', '/code/:path+'],
+})
+```
+
+**Bad:**
+
+```
+app/pages/
+ package-code/[...path].vue # Original
+ code/[...path].vue # Duplicate component
+ package/code/[...path].vue # Another duplicate
+```
+
+**Fix:** Use `alias` in `definePageMeta` to support multiple URL patterns from a single page component. See [references/component-patterns.md](references/component-patterns.md).
+
+---
+
+## 8. Canonical Redirects Middleware
+
+**What:** A server middleware that redirects legacy, shorthand, or non-canonical URLs to the canonical version with appropriate Cache-Control headers. Uses 301 (permanent) redirects.
+
+**Why:** Multiple URLs pointing to the same content hurt SEO (duplicate content), confuse caching (different cache entries for the same data), and create inconsistent user experience.
+
+**What to look for:** Check if the app has multiple URL patterns for the same content. Check for a canonical redirect middleware in `server/middleware/`.
+
+**Good:**
+
+```ts
+// server/middleware/canonical-redirects.global.ts
+const cacheControl = 's-maxage=3600, stale-while-revalidate=36000'
+
+export default defineEventHandler(async event => {
+ const [path] = event.path.split('?')
+
+ // /vue -> /package/vue (301)
+ const match = path.match(/^\/(?[^/@]+)$/)
+ if (match?.groups) {
+ setHeader(event, 'cache-control', cacheControl)
+ return sendRedirect(event, `/package/${match.groups.name}`, 301)
+ }
+})
+```
+
+**Fix:** Create a `server/middleware/canonical-redirects.global.ts` that maps shorthand URLs to canonical ones. See [references/component-patterns.md](references/component-patterns.md).
+
+---
+
+## 9. defineModel for v-model
+
+**What:** `defineModel()` is the Vue 3.4+ macro for two-way binding. It replaces the `defineProps` + `defineEmits` + `computed` boilerplate for `v-model`.
+
+**Why:** Reduces 10+ lines of boilerplate to a single line. Makes the component API clearer: "this prop supports v-model".
+
+**What to look for:** Search for the old `modelValue` prop + `update:modelValue` emit pattern. Also check for named v-model props using the old pattern.
+
+**Good:**
+
+```ts
+// Child component
+const sortOption = defineModel('sortOption', { required: true })
+const pageSize = defineModel('pageSize', { default: 25 })
+
+// Parent:
+```
+
+**Bad:**
+
+```ts
+const props = defineProps<{ sortOption: string; pageSize: number }>()
+const emit = defineEmits<{
+ 'update:sortOption': [value: string]
+ 'update:pageSize': [value: number]
+}>()
+const localSort = computed({
+ get: () => props.sortOption,
+ set: v => emit('update:sortOption', v),
+})
+```
+
+**Fix:** Replace `defineProps` + `defineEmits` + `computed` patterns with `defineModel()`. See [references/component-patterns.md](references/component-patterns.md).
+
+---
+
+## 10. XSS-Safe Markdown Rendering
+
+**What:** When rendering user-provided markdown (package READMEs, descriptions), sanitize HTML, validate URL protocols, and use ReDoS-safe regex patterns.
+
+**Why:** Rendering raw markdown without sanitization opens XSS vectors. Using unbounded regex on user input enables ReDoS attacks.
+
+**What to look for:** Search for `v-html` usage. Check if the HTML source is sanitized. Check regex patterns for catastrophic backtracking.
+
+**Good:**
+
+```ts
+// Escape HTML entities before parsing markdown
+function sanitize(text: string): string {
+ return text.replace(/&/g, '&').replace(//g, '>')
+}
+
+// Validate link protocols
+html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
+ try {
+ const { protocol } = new URL(url)
+ if (['https:', 'mailto:'].includes(protocol)) {
+ return `${text}`
+ }
+ } catch {}
+ return `${text} (${url})` // Invalid URL: render as plain text
+})
+
+// ReDoS-safe: use bounded quantifiers instead of unbounded *
+text.replace(/\[!\[[^\]]{0,500}\]\([^)]{0,2000}\)\]\([^)]{0,2000}\)?/g, '')
+```
+
+**Bad:**
+
+```ts
+// Raw HTML injection
+v-html="userMarkdown"
+
+// Unbounded regex - vulnerable to ReDoS
+text.replace(/\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)/g, '')
+```
+
+**Fix:** Sanitize HTML before rendering. Validate URL protocols. Use bounded quantifiers in regex. See [references/component-patterns.md](references/component-patterns.md).
diff --git a/docs/skills/nuxt-architecture-review/references/component-patterns.md b/docs/skills/nuxt-architecture-review/references/component-patterns.md
new file mode 100644
index 000000000..29d121222
--- /dev/null
+++ b/docs/skills/nuxt-architecture-review/references/component-patterns.md
@@ -0,0 +1,261 @@
+# Component Patterns Reference
+
+## Table of Contents
+
+- [Server/Client Component Split](#serverclient-component-split)
+- [defineModel Patterns](#definemodel-patterns)
+- [Route Aliases](#route-aliases)
+- [Canonical Redirects Middleware](#canonical-redirects-middleware)
+- [XSS-Safe Markdown Rendering](#xss-safe-markdown-rendering)
+
+---
+
+## Server/Client Component Split
+
+### When to split
+
+| Scenario | Approach |
+| --------------------------------------------------- | ------------------------------------ |
+| Component needs browser APIs (localStorage, window) | `.client.vue` |
+| Component is purely presentational, no JS needed | `.server.vue` |
+| Component has different SSR vs client behavior | Both `.server.vue` and `.client.vue` |
+| Component needs interactivity + SSR | Single `.vue` (default) |
+
+### Example: auth-aware menu
+
+```vue
+
+
+
+ Sign in
+
+```
+
+```vue
+
+
+
+
+
+
+
+ Sign in
+
+```
+
+### How it works
+
+1. During SSR, Nuxt renders `AccountMenu.server.vue` (no JS shipped)
+2. After hydration, Nuxt replaces it with `AccountMenu.client.vue`
+3. The client version has full interactivity and browser API access
+
+---
+
+## defineModel Patterns
+
+### Basic v-model
+
+```vue
+
+
+
+
+
+
+
+
+```
+
+### Named v-model (multiple models)
+
+```vue
+
+
+
+
+
+```
+
+### Replacing the old pattern
+
+Before (Vue 3.3 and earlier):
+
+```ts
+const props = defineProps<{ sortOption: string }>()
+const emit = defineEmits<{ 'update:sortOption': [value: string] }>()
+const localSort = computed({
+ get: () => props.sortOption,
+ set: v => emit('update:sortOption', v),
+})
+```
+
+After (Vue 3.4+):
+
+```ts
+const sortOption = defineModel('sortOption', { required: true })
+```
+
+---
+
+## Route Aliases
+
+### Pattern: multiple URL shapes for one page
+
+```ts
+// app/pages/package-code/[...path].vue
+definePageMeta({
+ name: 'code',
+ path: '/package-code/:path+',
+ alias: [
+ '/package/code/:path+', // Legacy URL pattern
+ '/code/:path+', // Shorthand
+ ],
+})
+```
+
+All three URLs render the same page component:
+
+- `/package-code/vue/v/3.5.0/src/index.ts`
+- `/package/code/vue/v/3.5.0/src/index.ts`
+- `/code/vue/v/3.5.0/src/index.ts`
+
+### When to use aliases vs redirects
+
+| Scenario | Use |
+| -------------------------------------------- | ---------------------------- |
+| Both URLs are valid and should be accessible | Alias |
+| One URL is canonical, others should redirect | Redirect (middleware) |
+| Backwards compatibility with old URL scheme | Alias (or redirect with 301) |
+
+---
+
+## Canonical Redirects Middleware
+
+### Full implementation
+
+```ts
+// server/middleware/canonical-redirects.global.ts
+
+// Pages that should NOT be redirected (they have their own routes)
+const reservedPaths = ['/about', '/search', '/settings', '/api', '/package']
+
+const cacheControl = 's-maxage=3600, stale-while-revalidate=36000'
+
+export default defineEventHandler(async event => {
+ const [path = '/', query] = event.path.split('?')
+
+ // Skip internal paths
+ if (path.startsWith('/~') || path.startsWith('/_')) return
+
+ // Skip known page routes
+ if (reservedPaths.some(p => path === p || path.startsWith(p + '/'))) return
+
+ // /vue -> /package/vue (shorthand package URL)
+ const pkgMatch = path.match(/^\/(?:(?@[^/]+)\/)?(?[^/@]+)$/)
+ if (pkgMatch?.groups) {
+ const parts = [pkgMatch.groups.org, pkgMatch.groups.name].filter(Boolean).join('/')
+ setHeader(event, 'cache-control', cacheControl)
+ return sendRedirect(event, `/package/${parts}${query ? '?' + query : ''}`, 301)
+ }
+
+ // /vue@3.5.0 -> /package/vue/v/3.5.0
+ const versionMatch = path.match(/^\/(?:(?@[^/]+)\/)?(?[^/@]+)@(?[^/]+)$/)
+ if (versionMatch?.groups) {
+ const parts = [versionMatch.groups.org, versionMatch.groups.name].filter(Boolean).join('/')
+ setHeader(event, 'cache-control', cacheControl)
+ return sendRedirect(
+ event,
+ `/package/${parts}/v/${versionMatch.groups.version}${query ? '?' + query : ''}`,
+ 301,
+ )
+ }
+})
+```
+
+### Key design decisions
+
+1. **Early returns** -- Skip known paths first to avoid regex evaluation
+2. **Cache-Control on redirects** -- CDN caches the redirect itself, avoiding a server roundtrip
+3. **301 Permanent** -- Tells search engines to index only the canonical URL
+4. **Query preservation** -- Redirects preserve query parameters
+
+---
+
+## XSS-Safe Markdown Rendering
+
+### Complete inline markdown parser
+
+```ts
+// app/composables/useMarkdown.ts
+
+function stripAndEscapeHtml(text: string): string {
+ // Decode HTML entities first
+ let stripped = decodeHtmlEntities(text)
+
+ // Strip markdown image badges (bounded quantifiers for ReDoS safety)
+ stripped = stripped.replace(/\[!\[[^\]]{0,500}\]\([^)]{0,2000}\)\]\([^)]{0,2000}\)?/g, '')
+ stripped = stripped.replace(/!\[[^\]]{0,500}\]\([^)]{0,2000}\)/g, '')
+
+ // Strip HTML tags (keep text content)
+ stripped = stripped.replace(/<\/?[a-z][^>]*>/gi, '')
+
+ // Escape remaining HTML entities
+ return stripped
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
+function parseMarkdown(text: string): string {
+ if (!text) return ''
+
+ let html = stripAndEscapeHtml(text)
+
+ // Bold
+ html = html.replace(/\*\*(.+?)\*\*/g, '$1')
+
+ // Italic
+ html = html.replace(/(?$1')
+
+ // Inline code
+ html = html.replace(/`([^`]+)`/g, '$1')
+
+ // Strikethrough
+ html = html.replace(/~~(.+?)~~/g, '$1')
+
+ // Links with protocol validation
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
+ try {
+ const { protocol, href } = new URL(url)
+ if (['https:', 'mailto:'].includes(protocol)) {
+ const safeUrl = href.replace(/"/g, '"')
+ return `${text}`
+ }
+ } catch {}
+ return `${text} (${url})`
+ })
+
+ return html
+}
+```
+
+### Security checklist for v-html
+
+1. **Escape HTML entities** before any markdown processing
+2. **Validate URL protocols** -- only allow `https:` and `mailto:`
+3. **Add `rel="nofollow noreferrer noopener"`** to all external links
+4. **Use bounded quantifiers** in regex (e.g., `{0,500}` instead of `*`)
+5. **Strip image badges** that could contain tracking pixels
+6. **Never render raw user input** without sanitization
diff --git a/docs/skills/nuxt-architecture-review/references/modules-patterns.md b/docs/skills/nuxt-architecture-review/references/modules-patterns.md
new file mode 100644
index 000000000..f29fdfa5e
--- /dev/null
+++ b/docs/skills/nuxt-architecture-review/references/modules-patterns.md
@@ -0,0 +1,247 @@
+# Custom Nuxt Modules Reference
+
+## Table of Contents
+
+- [Module Design Principles](#module-design-principles)
+- [Build Info Module](#build-info-module)
+- [Cache Configuration Module](#cache-configuration-module)
+- [ISR Fallback Module](#isr-fallback-module)
+- [Component Extension Module](#component-extension-module)
+- [shared/ Directory Structure](#shared-directory-structure)
+
+---
+
+## Module Design Principles
+
+1. **Single responsibility** -- One module per concern (cache, build info, ISR fallback)
+2. **Environment guards** -- Use `provider` from `std-env` to skip non-relevant environments
+3. **Type augmentation** -- Extend Nuxt's type system via `declare module` for type-safe `appConfig`
+4. **Hook-based** -- Use Nuxt/Nitro hooks instead of modifying config directly
+
+### Module skeleton
+
+```ts
+// modules/my-feature.ts
+import { defineNuxtModule } from 'nuxt/kit'
+import { provider } from 'std-env'
+
+export default defineNuxtModule({
+ meta: { name: 'my-feature' },
+ setup(_, nuxt) {
+ // Environment guard
+ if (provider !== 'vercel') return
+
+ // Use hooks to modify behavior
+ nuxt.hook('nitro:config', nitroConfig => {
+ // Modify nitro config
+ })
+ },
+})
+```
+
+---
+
+## Build Info Module
+
+Inject build metadata (version, commit, branch, environment) into `appConfig` for runtime access:
+
+```ts
+// modules/build-env.ts
+import type { BuildInfo } from '../shared/types'
+import { createResolver, defineNuxtModule } from 'nuxt/kit'
+import { isCI } from 'std-env'
+import { getEnv, version } from '../config/env'
+
+const { resolve } = createResolver(import.meta.url)
+
+export default defineNuxtModule({
+ meta: { name: 'build-env' },
+ async setup(_, nuxt) {
+ const { env, commit, shortCommit, branch } = await getEnv(nuxt.options.dev)
+
+ nuxt.options.appConfig = nuxt.options.appConfig || {}
+ nuxt.options.appConfig.buildInfo = {
+ version,
+ time: +Date.now(),
+ commit,
+ shortCommit,
+ branch,
+ env,
+ } satisfies BuildInfo
+
+ // Environment-specific public assets
+ nuxt.options.nitro.publicAssets = nuxt.options.nitro.publicAssets || []
+ if (env === 'dev') {
+ nuxt.options.nitro.publicAssets.unshift({ dir: resolve('../public-dev') })
+ } else if (env === 'staging') {
+ nuxt.options.nitro.publicAssets.unshift({ dir: resolve('../public-staging') })
+ }
+ },
+})
+
+// Type augmentation for appConfig
+declare module '@nuxt/schema' {
+ interface AppConfig {
+ buildInfo: BuildInfo
+ }
+}
+```
+
+### Usage in app
+
+```ts
+// In any component or composable
+const { buildInfo } = useAppConfig()
+console.log(buildInfo.version, buildInfo.commit)
+```
+
+---
+
+## Cache Configuration Module
+
+Configure Nitro storage backends per deployment provider:
+
+```ts
+// modules/cache.ts
+import process from 'node:process'
+import { defineNuxtModule } from 'nuxt/kit'
+import { provider } from 'std-env'
+
+export default defineNuxtModule({
+ meta: { name: 'cache-config' },
+ setup(_, nuxt) {
+ if (provider !== 'vercel') return
+
+ nuxt.hook('nitro:config', nitroConfig => {
+ nitroConfig.storage = nitroConfig.storage || {}
+
+ // SSR cache -> Vercel runtime cache
+ nitroConfig.storage.cache = {
+ ...nitroConfig.storage.cache,
+ driver: 'vercel-runtime-cache',
+ }
+
+ // Fetch cache -> Vercel runtime cache
+ nitroConfig.storage['fetch-cache'] = {
+ driver: 'vercel-runtime-cache',
+ }
+
+ // Sessions -> KV in production, runtime cache in preview
+ const env = process.env.VERCEL_ENV
+ nitroConfig.storage.sessions = {
+ driver: env === 'production' ? 'vercel-kv' : 'vercel-runtime-cache',
+ }
+ })
+ },
+})
+```
+
+---
+
+## ISR Fallback Module
+
+Generate SPA fallback HTML for ISR routes:
+
+```ts
+// modules/isr-fallback.ts
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { defineNuxtModule } from 'nuxt/kit'
+import { provider } from 'std-env'
+
+export default defineNuxtModule({
+ meta: { name: 'isr-fallback' },
+ setup(_, nuxt) {
+ if (provider !== 'vercel') return
+
+ nuxt.hook('nitro:init', nitro => {
+ nitro.hooks.hook('compiled', () => {
+ const spaTemplate = readFileSync(nitro.options.output.publicDir + '/200.html', 'utf-8')
+ // Copy SPA shell to each dynamic route directory
+ const routes = ['product', 'product/[id]', 'product/[id]/v/[version]']
+ for (const path of routes) {
+ const dir = resolve(nitro.options.output.serverDir, '..', path)
+ mkdirSync(dir, { recursive: true })
+ writeFileSync(resolve(dir, 'spa.prerender-fallback.html'), spaTemplate)
+ }
+ })
+ })
+ },
+})
+```
+
+---
+
+## Component Extension Module
+
+Remove or modify auto-discovered components via hooks:
+
+```ts
+// modules/og-image.ts
+import { defineNuxtModule, useNuxt } from 'nuxt/kit'
+
+export default defineNuxtModule({
+ meta: { name: 'og-image-tweaks' },
+ setup() {
+ const nuxt = useNuxt()
+
+ nuxt.hook('components:extend', components => {
+ // Remove conflicting OG image components
+ for (const component of [...components].toReversed()) {
+ if (component.filePath.includes('og-image')) {
+ components.splice(components.indexOf(component), 1)
+ }
+ }
+ })
+ },
+})
+```
+
+---
+
+## shared/ Directory Structure
+
+### Recommended layout
+
+```
+shared/
+ types/
+ index.ts # Re-exports all types
+ product.ts # Domain types
+ api-responses.ts # API response shapes
+ user.ts # User/session types
+ schemas/
+ product.ts # Valibot schemas + inferred types
+ auth.ts # Auth validation schemas
+ utils/
+ constants.ts # Shared constants (API URLs, cache durations)
+ formatters.ts # Pure formatting functions
+ npm.ts # Domain-specific pure utilities
+ async.ts # Async utilities (mapWithConcurrency)
+ fetch-cache-config.ts # Cache config shared between plugin and composable
+```
+
+### Import examples
+
+```ts
+// From app/ code
+import type { Product } from '#shared/types'
+import { PackageNameSchema } from '#shared/schemas/product'
+import { mapWithConcurrency } from '#shared/utils/async'
+
+// From server/ code
+import type { Product } from '#shared/types'
+import { PackageNameSchema } from '#shared/schemas/product'
+```
+
+### What belongs in shared/
+
+| Content | Belongs in `shared/`? | Why |
+| --------------------------- | --------------------- | ------------------------------------------- |
+| Type definitions | Yes | Used by both app and server |
+| Valibot schemas | Yes | Validation in server, type inference in app |
+| Pure utility functions | Yes | No side effects, works everywhere |
+| Constants (URLs, durations) | Yes | Referenced by both sides |
+| Vue composables | No | Client/SSR only, use `app/composables/` |
+| Server utilities | No | Nitro-only, use `server/utils/` |
+| Database clients | No | Server-only |
diff --git a/docs/skills/nuxt-architecture-review/references/typescript-patterns.md b/docs/skills/nuxt-architecture-review/references/typescript-patterns.md
new file mode 100644
index 000000000..1f23648b7
--- /dev/null
+++ b/docs/skills/nuxt-architecture-review/references/typescript-patterns.md
@@ -0,0 +1,232 @@
+# TypeScript Patterns Reference
+
+## Table of Contents
+
+- [Discriminated Unions](#discriminated-unions)
+- [satisfies Operator](#satisfies-operator)
+- [Schema-Driven Types](#schema-driven-types)
+- [Type Augmentation](#type-augmentation)
+
+---
+
+## Discriminated Unions
+
+### Pattern: status modeling
+
+Instead of an object with optional fields, use a `kind` discriminant:
+
+```ts
+// Bad: optional fields, easy to misuse
+interface LoadingState {
+ isLoading: boolean
+ data?: Product
+ error?: Error
+}
+
+// Good: discriminated union, impossible states are unrepresentable
+type LoadingState =
+ | { status: 'idle' }
+ | { status: 'loading' }
+ | { status: 'success'; data: Product }
+ | { status: 'error'; error: Error }
+```
+
+### Pattern: feature detection
+
+```ts
+type TypesStatus =
+ | { kind: 'included' }
+ | { kind: '@types'; packageName: string; deprecated?: string }
+ | { kind: 'none' }
+
+function detectTypesStatus(pkg: PackageJson): TypesStatus {
+ if (pkg.types || pkg.typings) return { kind: 'included' }
+ const typesPackage = findTypesPackage(pkg.name)
+ if (typesPackage) return { kind: '@types', packageName: typesPackage.name }
+ return { kind: 'none' }
+}
+
+// Usage: TypeScript narrows automatically
+function renderBadge(status: TypesStatus): string {
+ switch (status.kind) {
+ case 'included':
+ return 'TS'
+ case '@types':
+ return `@types/${status.packageName}`
+ case 'none':
+ return 'No types'
+ }
+ // TypeScript error if a case is missing (exhaustiveness check)
+}
+```
+
+### Pattern: API response variants
+
+```ts
+type ApiResponse =
+ | { ok: true; data: T; isStale: boolean }
+ | { ok: false; error: string; statusCode: number }
+
+function handleResponse(response: ApiResponse) {
+ if (response.ok) {
+ // TypeScript knows: response.data exists, response.error doesn't
+ console.log(response.data)
+ } else {
+ // TypeScript knows: response.error exists, response.data doesn't
+ console.error(response.error)
+ }
+}
+```
+
+---
+
+## satisfies Operator
+
+### Pattern: type-checked return values
+
+```ts
+// Bad: 'as' suppresses errors and widens the type
+return {
+ package: name,
+ version: pkg.version,
+} as PackageResponse // Typo in field name? No error!
+
+// Good: 'satisfies' catches errors while preserving literal types
+return {
+ package: name,
+ version: pkg.version,
+} satisfies PackageResponse // Error if shape doesn't match
+```
+
+### Pattern: typed defaults
+
+```ts
+// The type is checked but the literal values are preserved
+const emptyResponse = {
+ objects: [],
+ total: 0,
+ isStale: false,
+ time: new Date().toISOString(),
+} satisfies SearchResponse
+
+// emptyResponse.total is typed as 0 (literal), not number
+```
+
+### Pattern: config objects
+
+```ts
+// Ensures the config matches the expected shape
+const theme = {
+ spacing: { DEFAULT: '4px' },
+ font: {
+ mono: "'Geist Mono', monospace",
+ sans: "'Geist', system-ui, sans-serif",
+ },
+} satisfies ThemeConfig
+```
+
+### When to use satisfies vs as vs annotation
+
+| Approach | Use when |
+| ---------------- | --------------------------------------------------------------- |
+| `satisfies Type` | Checking shape without widening (return values, defaults) |
+| `: Type` | Variable declarations where you want the wider type |
+| `as Type` | Narrowing in assertions you're certain about (avoid on returns) |
+
+---
+
+## Schema-Driven Types
+
+### Pattern: infer types from Valibot schemas
+
+```ts
+import * as v from 'valibot'
+
+// Define the schema once
+export const ProductSchema = v.object({
+ id: v.pipe(v.string(), v.nonEmpty()),
+ name: v.pipe(v.string(), v.minLength(1)),
+ version: v.optional(v.pipe(v.string(), v.regex(/^[\w.+-]+$/))),
+ tags: v.array(v.string()),
+})
+
+// Infer the type from the schema - no manual interface needed
+export type Product = v.InferOutput
+// { id: string; name: string; version?: string; tags: string[] }
+
+// Use in routes
+const product = v.parse(ProductSchema, rawInput)
+// product is fully typed as Product
+```
+
+### Why this is better than manual types
+
+1. **Single source of truth** -- Schema and type are always in sync
+2. **Runtime validation** -- The schema validates at runtime, not just compile time
+3. **Auto-complete** -- IDE knows the shape from the schema
+4. **Refactor-safe** -- Change the schema, the type updates automatically
+
+### Pattern: multiple schemas sharing fields
+
+```ts
+const BaseProductSchema = v.object({
+ name: PackageNameSchema, // Reuse validated fields
+})
+
+const ProductWithVersionSchema = v.object({
+ ...BaseProductSchema.entries,
+ version: VersionSchema,
+})
+
+const ProductWithFileSchema = v.object({
+ ...ProductWithVersionSchema.entries,
+ filePath: FilePathSchema,
+})
+
+// Each infers its own type
+type ProductRoute = v.InferOutput
+type ProductVersionRoute = v.InferOutput
+type ProductFileRoute = v.InferOutput
+```
+
+---
+
+## Type Augmentation
+
+### Pattern: extend Nuxt's AppConfig
+
+```ts
+// modules/build-env.ts
+declare module '@nuxt/schema' {
+ interface AppConfig {
+ env: 'dev' | 'staging' | 'production'
+ buildInfo: BuildInfo
+ }
+}
+```
+
+Now `useAppConfig().buildInfo` is fully typed.
+
+### Pattern: extend H3 event context
+
+```ts
+// server/plugins/fetch-cache.ts
+declare module 'h3' {
+ interface H3EventContext {
+ cachedFetch?: CachedFetchFunction
+ }
+}
+```
+
+Now `event.context.cachedFetch` is fully typed across all server code.
+
+### Pattern: extend component props globally
+
+```ts
+// types/global.d.ts
+declare module 'vue' {
+ interface ComponentCustomProperties {
+ $formatDate: (date: string) => string
+ }
+}
+```
diff --git a/docs/skills/nuxt-perf-review/SKILL.md b/docs/skills/nuxt-perf-review/SKILL.md
new file mode 100644
index 000000000..00ef6b777
--- /dev/null
+++ b/docs/skills/nuxt-perf-review/SKILL.md
@@ -0,0 +1,282 @@
+---
+name: nuxt-perf-review
+description: >
+ Review and audit a Nuxt application for client-side and rendering performance optimizations.
+ Use when asked to "review performance", "optimize my Nuxt app", "audit for speed",
+ "improve TTFB", "reduce bundle size", "optimize data fetching", or any performance-related
+ code review on a Nuxt 3/4 project. Covers ISR, SWR, getCachedData, lazy data fetching,
+ shallowRef, view transitions, conditional fetching, and incremental loading patterns.
+---
+
+# Nuxt Performance Optimization Review
+
+Audit a Nuxt application for performance optimizations. Scan the codebase for each pattern below, explain the concept, and propose fixes when the pattern is missing or misused.
+
+For full code examples, see the `references/` files linked in each section.
+
+## Review Process
+
+1. Read `nuxt.config.ts` for route rules, experimental features, and rendering config
+2. Scan `app/composables/` and `app/pages/` for data fetching patterns
+3. Check for `shallowRef` vs `ref` usage across components and composables
+4. Report findings with explanations and suggested fixes
+
+---
+
+## 1. ISR Route Rules
+
+**What:** Incremental Static Regeneration (ISR) pre-renders pages on first request, then serves the cached version for subsequent requests. After a configurable TTL (e.g., 60 seconds), the next request triggers a background regeneration while still serving the stale page instantly.
+
+**Why:** Without ISR, every request either hits SSR (slow TTFB, high server cost) or serves a fully static page (stale data). ISR gives the best of both: instant responses with fresh-enough data.
+
+**What to look for:** Check `routeRules` in `nuxt.config.ts`. Dynamic pages (e.g., `/product/[id]`) should have `isr` with an expiration. Static pages should use `prerender: true`. Auth/search routes should have `isr: false, cache: false`.
+
+**Good:**
+
+```ts
+// nuxt.config.ts
+routeRules: {
+ '/product/:id': { isr: { expiration: 60 } }, // revalidate every 60s
+ '/docs/**': { isr: true, cache: { maxAge: 31536000 } }, // immutable content
+ '/': { prerender: true }, // fully static
+ '/search': { isr: false, cache: false }, // never cache
+ '/api/auth/**': { isr: false, cache: false }, // never cache auth
+}
+```
+
+**Bad:**
+
+```ts
+// No routeRules at all, or only prerender for everything
+routeRules: {
+ '/': { prerender: true },
+ // Dynamic pages have no ISR config -> full SSR on every request
+}
+```
+
+**Fix:** Add `isr` rules for dynamic pages. Use `prerender: true` for static pages. Disable caching for auth and real-time routes. See [references/isr-patterns.md](references/isr-patterns.md) for advanced patterns (SPA fallback, query-aware ISR).
+
+---
+
+## 2. getCachedData for Navigation Caching
+
+**What:** `getCachedData` is a `useFetch`/`useAsyncData` option that checks Nuxt's payload cache before making a new request. On client-side navigation, data already fetched during SSR or a previous navigation is returned instantly without a network call.
+
+**Why:** Without `getCachedData`, navigating back to a previously visited page triggers a new API call, causing loading spinners and wasted bandwidth. With it, the page renders instantly from cache.
+
+**What to look for:** Search for `useFetch` and `useLazyFetch` calls. If the data rarely changes (avatars, config, metadata), add `getCachedData`.
+
+**Good:**
+
+```ts
+const { data } = useFetch('/api/settings', {
+ getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key] ?? nuxtApp.static.data[key],
+})
+```
+
+**Bad:**
+
+```ts
+// No getCachedData -> refetches on every navigation
+const { data } = useFetch('/api/settings')
+```
+
+**Fix:** Add `getCachedData` to `useFetch`/`useLazyFetch` calls where data does not change frequently. See [references/client-caching-patterns.md](references/client-caching-patterns.md).
+
+---
+
+## 3. Client-Side Stale-While-Revalidate
+
+**What:** When the server returns data from a stale cache (past TTL but still usable), the response includes an `isStale` flag. On the client, detect this flag and trigger a background `refresh()` after mount to get fresh data without blocking the initial render.
+
+**Why:** The user sees content instantly (even if slightly stale), and the page silently updates with fresh data in the background. This eliminates perceived loading time.
+
+**What to look for:** Search for composables that fetch external API data. Check if they propagate staleness metadata and trigger `refresh()` on mount when stale.
+
+**Good:**
+
+```ts
+export function useProduct(id: MaybeRefOrGetter) {
+ const cachedFetch = useCachedFetch()
+
+ const asyncData = useLazyAsyncData(
+ () => `product:${toValue(id)}`,
+ async () => {
+ const { data, isStale } = await cachedFetch(`/api/products/${toValue(id)}`)
+ return { ...data, isStale }
+ },
+ )
+
+ // If SSR returned stale data, silently refresh on mount
+ if (import.meta.client && asyncData.data.value?.isStale) {
+ onMounted(() => asyncData.refresh())
+ }
+
+ return asyncData
+}
+```
+
+**Bad:**
+
+```ts
+// No stale detection -> user either sees old data forever or waits for fresh data
+const { data } = useFetch('/api/products/' + id)
+```
+
+**Fix:** Propagate staleness from server cache, detect it on client, and call `refresh()` on mount. See [references/client-caching-patterns.md](references/client-caching-patterns.md).
+
+---
+
+## 4. useLazyAsyncData / useLazyFetch for Non-Blocking Data
+
+**What:** `useLazyFetch` and `useLazyAsyncData` fetch data without blocking the page render. The page displays immediately with `null` data, which fills in as requests complete. This is ideal for secondary/supplementary data.
+
+**Why:** If a page fetches 5 different APIs with `useFetch`, the page waits for ALL of them before rendering. Using `useLazyFetch` for non-critical data (README, stats, related items) lets the page render with the critical data first.
+
+**What to look for:** Pages with multiple `useFetch` calls. Identify which data is critical (needed for the page to make sense) vs. supplementary (can load after render).
+
+**Good:**
+
+```ts
+// Critical data - blocks render
+const { data: product } = await useFetch(`/api/product/${id}`)
+
+// Supplementary data - loads after render
+const { data: readme } = useLazyFetch(`/api/product/${id}/readme`, {
+ default: () => ({ html: '' }),
+})
+const { data: stats } = useLazyFetch(`/api/product/${id}/stats`, {
+ server: false, // skip SSR entirely
+ immediate: false, // don't fetch until triggered
+})
+onMounted(() => stats.execute())
+```
+
+**Bad:**
+
+```ts
+// All blocking -> page waits for everything
+const { data: product } = await useFetch(`/api/product/${id}`)
+const { data: readme } = await useFetch(`/api/product/${id}/readme`)
+const { data: stats } = await useFetch(`/api/product/${id}/stats`)
+```
+
+**Fix:** Convert supplementary fetches to `useLazyFetch`. Use `server: false` + `immediate: false` for client-only data that can be deferred. See [references/data-fetching-patterns.md](references/data-fetching-patterns.md).
+
+---
+
+## 5. shallowRef Over ref for Non-Deep State
+
+**What:** `shallowRef` creates a ref that only triggers reactivity when the `.value` itself is reassigned, not when nested properties change. For primitive values, Maps, arrays used as caches, or objects that are always replaced (never mutated), `shallowRef` avoids Vue wrapping every nested property in a Proxy.
+
+**Why:** `ref` deeply proxies the entire object tree. For a `Map` with 1000 entries or an array of search results, this creates thousands of Proxy objects. `shallowRef` avoids this overhead entirely.
+
+**What to look for:** Search for `ref(` in composables. Check if the value is: a boolean, a number, a string, a Map/Set, or an object that is always replaced wholesale. If so, it should be `shallowRef`.
+
+**Good:**
+
+```ts
+const isOpen = shallowRef(false)
+const cache = shallowRef(new Map())
+const results = shallowRef([])
+
+// Replace the whole value to trigger reactivity
+results.value = [...results.value, ...newResults]
+```
+
+**Bad:**
+
+```ts
+const isOpen = ref(false) // Deep proxy on a boolean is wasteful
+const cache = ref(new Map()) // Deep proxy on a Map is expensive
+const results = ref([]) // Deep proxy on array of objects
+```
+
+**Fix:** Replace `ref` with `shallowRef` for primitives, Maps, Sets, and objects that are replaced (not mutated). See [references/client-caching-patterns.md](references/client-caching-patterns.md).
+
+---
+
+## 6. View Transitions API
+
+**What:** The View Transitions API provides native browser animations between page navigations in SSR-rendered apps. Nuxt supports it via `experimental.viewTransition`.
+
+**Why:** Gives smooth cross-fade transitions between pages with zero JavaScript animation overhead. Handled entirely by the browser.
+
+**What to look for:** Check `nuxt.config.ts` for `experimental.viewTransition`.
+
+**Good:**
+
+```ts
+// nuxt.config.ts
+experimental: {
+ viewTransition: true,
+}
+```
+
+**Fix:** Add `viewTransition: true` to the experimental config. Works out of the box for page transitions.
+
+---
+
+## 7. Conditional / Deferred Fetching
+
+**What:** Use the `immediate` option on `useLazyFetch` to conditionally skip fetches that aren't needed. For example, only fetch JSR data for scoped packages, or only fetch install size after the component mounts.
+
+**Why:** Avoids unnecessary network requests, reducing server load and improving page load time.
+
+**What to look for:** `useFetch` calls that always execute but only make sense under certain conditions.
+
+**Good:**
+
+```ts
+// Only fetch for scoped packages
+const { data } = useLazyFetch(() => `/api/jsr/${name}`, {
+ immediate: computed(() => name.value.startsWith('@')).value,
+})
+
+// Only fetch on client after mount
+const { data, execute } = useLazyFetch(`/api/heavy-data`, {
+ server: false,
+ immediate: false,
+})
+onMounted(() => execute())
+```
+
+**Bad:**
+
+```ts
+// Fetches for every package, even non-scoped ones where it will 404
+const { data } = useFetch(() => `/api/jsr/${name}`)
+```
+
+**Fix:** Add `immediate: false` or `immediate: computed(...)` to skip unnecessary fetches. Use `server: false` for client-only data. See [references/data-fetching-patterns.md](references/data-fetching-patterns.md).
+
+---
+
+## 8. Incremental Loading / Infinite Scroll
+
+**What:** Instead of fetching all results at once, fetch an initial batch and provide a `fetchMore()` function that appends results incrementally. The composable manages a local cache to merge pages.
+
+**Why:** Fetching 500 search results on initial load is slow and wastes bandwidth. Fetching 25 at a time and loading more on scroll is faster and more responsive.
+
+**What to look for:** Search/list pages that fetch all results in one call. Check if there is infinite scroll or pagination support.
+
+**Good:**
+
+```ts
+const cache = shallowRef([])
+
+async function fetchMore(targetSize: number) {
+ const from = cache.value.length
+ const response = await fetch(`/api/search?from=${from}&size=25`)
+ cache.value = [...cache.value, ...response.results]
+}
+```
+
+**Bad:**
+
+```ts
+// Fetches everything at once
+const { data } = useFetch(`/api/search?size=500`)
+```
+
+**Fix:** Implement incremental loading with a local cache and `fetchMore()` function. See [references/data-fetching-patterns.md](references/data-fetching-patterns.md) for the full pattern.
diff --git a/docs/skills/nuxt-perf-review/references/client-caching-patterns.md b/docs/skills/nuxt-perf-review/references/client-caching-patterns.md
new file mode 100644
index 000000000..bc70c8eee
--- /dev/null
+++ b/docs/skills/nuxt-perf-review/references/client-caching-patterns.md
@@ -0,0 +1,156 @@
+# Client Caching Patterns Reference
+
+## Table of Contents
+
+- [getCachedData Pattern](#getcacheddata-pattern)
+- [Client-Side SWR with isStale](#client-side-swr-with-isstale)
+- [shallowRef Optimization Guide](#shallowref-optimization-guide)
+
+---
+
+## getCachedData Pattern
+
+Prevent refetching on client-side navigation by reading from Nuxt's payload cache.
+
+### How it works
+
+When Nuxt renders a page on the server, all `useFetch` data is serialized into `nuxtApp.payload.data`. On client-side navigation, `getCachedData` checks this cache first. If data exists, no network request is made.
+
+### Basic pattern
+
+```ts
+const { data } = useFetch('/api/config', {
+ getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key] ?? nuxtApp.static.data[key],
+})
+```
+
+### When to use
+
+- Data that rarely changes (user settings, app config, avatar URLs)
+- Data already fetched by another component on the same page
+- Lookups with stable keys (e.g., `/api/user/123`)
+
+### When NOT to use
+
+- Real-time data (chat, live feeds)
+- Data that changes between navigations (search results with different queries)
+- Data gated by auth state (may leak between users in SSR)
+
+### With useLazyFetch
+
+```ts
+const { data: avatarUrl } = useLazyFetch(() => `/api/avatar/${username}`, {
+ transform: res => res.url,
+ getCachedData: (key, nuxtApp) => nuxtApp.static.data[key] ?? nuxtApp.payload.data[key],
+})
+```
+
+---
+
+## Client-Side SWR with isStale
+
+Propagate staleness metadata from server cache to client and trigger background revalidation.
+
+### Architecture
+
+```
+Server cache (SWR) Composable Client
+ | | |
+ |-- data + isStale ------->| |
+ | |-- render stale ----->|
+ | |-- onMounted -------->|
+ | | refresh() |
+ |<---- fresh fetch --------| |
+ | |-- update UI -------->|
+```
+
+### Full pattern
+
+```ts
+export function useProduct(id: MaybeRefOrGetter) {
+ // Get cachedFetch in setup context (before async)
+ const cachedFetch = useCachedFetch()
+
+ const asyncData = useLazyAsyncData(
+ () => `product:${toValue(id)}`,
+ async (_nuxtApp, { signal }) => {
+ const { data, isStale } = await cachedFetch(
+ `https://api.example.com/products/${toValue(id)}`,
+ { signal },
+ )
+ return { ...data, isStale }
+ },
+ )
+
+ // On client: if the SSR data came from stale cache, refresh silently
+ if (import.meta.client && asyncData.data.value?.isStale) {
+ onMounted(() => {
+ asyncData.refresh()
+ })
+ }
+
+ return asyncData
+}
+```
+
+### Key points
+
+1. `useCachedFetch()` must be called in setup context (not inside async handlers)
+2. The `isStale` flag is returned by the server-side SWR cache plugin
+3. `import.meta.client` guard ensures refresh only runs on client
+4. `onMounted` ensures the DOM is ready before triggering a background fetch
+5. The user sees stale content instantly, then it updates seamlessly
+
+---
+
+## shallowRef Optimization Guide
+
+### When to use shallowRef
+
+| Value Type | Use `shallowRef`? | Reason |
+| ---------------------------------- | ----------------- | ---------------------------------------- |
+| `boolean` | Yes | No nested props to track |
+| `number` / `string` | Yes | Primitives don't benefit from deep proxy |
+| `Map` / `Set` | Yes | Vue proxies are expensive on collections |
+| `Array