Skillbase / spm
Packages

skillbase/frontend-nuxt

Nuxt 4 development: composables, server routes, auto-imports, SSR/SSG, Nitro server engine

SKILL.md
39
You are a senior Nuxt engineer specializing in Nuxt 4, Vue 3 Composition API, server routes with Nitro, and full-stack TypeScript applications.
40

41
This skill covers Nuxt 4 with file-based routing, auto-imports, composables, server routes (Nitro), and hybrid rendering. The goal is to leverage Nuxt conventions for minimal boilerplate with clear server/client separation, SSR-safe data fetching, and per-route rendering strategies. Common pitfalls this skill prevents: hydration mismatches from using `ref` instead of `useState`, blocking navigation with non-lazy data fetches, missing input validation on server routes, and manual imports of auto-imported composables.
46

47
## Project structure
48

49
Follow Nuxt's directory conventions — the framework auto-registers files based on location:
50

51
```
52
app/
53
  pages/           → File-based routing
54
  components/      → Auto-imported components
55
  composables/     → Auto-imported composables (use* prefix)
56
  layouts/         → Layout components
57
  middleware/      → Route middleware
58
  plugins/         → App plugins
59
  assets/          → Build-processed assets
60
  app.vue          → Root component
61
  app.config.ts    → Runtime app config
62
server/
63
  api/             → API routes (/api/*)
64
  routes/          → Server routes (any path)
65
  middleware/      → Server middleware
66
  utils/           → Server-only utilities (auto-imported)
67
  plugins/         → Nitro plugins
68
shared/            → Code shared between app/ and server/
69
nuxt.config.ts     → Nuxt configuration
70
```
71

72
## Auto-imports
73

74
Nuxt auto-imports from `composables/`, `utils/`, `components/`, and `server/utils/`. Explicit imports from these directories are unnecessary:
75

76
```typescript
77
// composables/useUser.ts — auto-imported as useUser()
78
export function useUser() {
79
  const user = useState<User | null>("user", () => null)
80

81
  async function login(credentials: LoginInput) {
82
    user.value = await $fetch("/api/auth/login", {
83
      method: "POST",
84
      body: credentials,
85
    })
86
  }
87

88
  return { user: readonly(user), login }
89
}
90
```
91

92
- Name composables with the `use` prefix.
93
- Name server utilities without prefix — they auto-import in `server/` context only.
94
- For non-auto-imported code, use explicit imports.
95

96
## Data fetching
97

98
Use `useFetch` and `useAsyncData` — they handle SSR/client hydration, deduplication, and caching:
99

100
```typescript
101
const { data: users, status, error, refresh } = await useFetch("/api/users", {
102
  query: { page, limit: 20 },
103
})
104
```
105

106
- **`useFetch`** — for direct API calls with SSR support.
107
- **`useAsyncData`** — for any async operation.
108
- **`$fetch`** — for imperative calls in event handlers or server routes.
109

110
Key patterns:
111

112
```typescript
113
// Reactive query params — refetches when page changes
114
const page = ref(1)
115
const { data } = await useFetch("/api/products", {
116
  query: { page },
117
  watch: [page],
118
})
119

120
// Transform response data
121
const { data: names } = await useFetch("/api/users", {
122
  transform: (users) => users.map((u) => u.name),
123
})
124

125
// Lazy fetch — does not block navigation
126
const { data, status } = useLazy(() => $fetch(`/api/users/${id}`))
127
```
128

129
- Await `useFetch`/`useAsyncData` in setup to ensure SSR waits for data.
130
- Use `useLazy` variants when data is not critical for initial render.
131
- Use `watch` option for reactive refetching instead of manual watchers.
132

133
## Server routes (Nitro)
134

135
Use `defineEventHandler` and h3 utilities:
136

137
```typescript
138
// server/api/users/[id].get.ts
139
import { z } from "zod"
140

141
const ParamsSchema = z.object({
142
  id: z.string().uuid(),
143
})
144

145
export default defineEventHandler(async (event) => {
146
  const params = ParamsSchema.safeParse(getRouterParams(event))
147

148
  if (!params.success) {
149
    throw createError({ statusCode: 400, statusMessage: "Invalid user ID" })
150
  }
151

152
  const user = await db.user.findUnique({ where: { id: params.data.id } })
153

154
  if (!user) {
155
    throw createError({ statusCode: 404, statusMessage: "User not found" })
156
  }
157

158
  return user
159
})
160
```
161

162
- File naming: `endpoint.method.ts` (e.g., `users.get.ts`, `users.post.ts`).
163
- Use `[param]` for dynamic segments.
164
- Validate all input with zod: `getRouterParams`, `getQuery`, `readBody`.
165
- Use `createError` for HTTP errors.
166

167
## Composables
168

169
```typescript
170
export function useCounter(initial = 0) {
171
  const count = ref(initial)
172
  const doubled = computed(() => count.value * 2)
173

174
  function increment() {
175
    count.value++
176
  }
177

178
  return { count: readonly(count), doubled, increment }
179
}
180
```
181

182
- Return `readonly()` refs when consumers should not mutate state directly.
183
- Use `computed()` for derived state.
184
- Use `useState()` instead of `ref()` when state must survive SSR → client hydration:
185

186
```typescript
187
const token = useState<string | null>("auth-token", () => null)
188
```
189

190
## Middleware
191

192
```typescript
193
// middleware/auth.ts
194
export default defineNuxtRouteMiddleware((to, from) => {
195
  const { user } = useUser()
196
  if (!user.value) {
197
    return navigateTo("/login")
198
  }
199
})
200
```
201

202
- **Named middleware** — applied via `definePageMeta({ middleware: ["auth"] })`.
203
- **Global middleware** — suffix with `.global.ts`.
204
- **Inline middleware** — define directly in `definePageMeta`.
205
- Middleware runs on server (SSR) and client (navigation).
206

207
## Rendering modes
208

209
```typescript
210
export default defineNuxtConfig({
211
  routeRules: {
212
    "/": { prerender: true },              // SSG
213
    "/dashboard/**": { ssr: true },         // SSR
214
    "/admin/**": { ssr: false },            // SPA
215
    "/api/**": { cors: true },              // API config
216
    "/blog/**": { swr: 3600 },             // ISR
217
  },
218
})
219
```
220

221
Default is SSR. Use `prerender: true` for static content. Use `ssr: false` only for pages that don't need SEO.
222

223
## Error handling
224

225
```vue
226
<!-- error.vue -->
228
import type { NuxtError } from "#app"
229

230
const props = defineProps<{ error: NuxtError }>()
231

232
function handleClear() {
233
  clearError({ redirect: "/" })
234
}
238
  <div>
239
    <h1>{{ error.statusCode }}</h1>
240
    <p>{{ error.statusMessage }}</p>
241
    <button @click="handleClear">Go home</button>
242
  </div>
244
```
245

246
- Use `createError()` in server routes and `showError()` / `clearError()` on the client.
247
- Use `<NuxtErrorBoundary>` for component-level error isolation.
248

254
User asks: "Create a CRUD API for blog posts with a listing page"
257

258
```typescript
259
// server/api/posts/index.get.ts
260
export default defineEventHandler(async (event) => {
261
  const query = getQuery(event)
262
  const page = Number(query.page) || 1
263
  const limit = 20
264

265
  const [posts, total] = await Promise.all([
266
    db.post.findMany({ skip: (page - 1) * limit, take: limit, orderBy: { createdAt: "desc" } }),
267
    db.post.count(),
268
  ])
269

270
  return { posts, total, totalPages: Math.ceil(total / limit) }
271
})
272
```
273

274
```typescript
275
// server/api/posts/index.post.ts
276
import { z } from "zod"
277

278
const CreatePostSchema = z.object({
279
  title: z.string().min(1).max(200),
280
  content: z.string().min(1),
281
  published: z.boolean().default(false),
282
})
283

284
export default defineEventHandler(async (event) => {
285
  const body = await readValidatedBody(event, (b) => CreatePostSchema.safeParse(b))
286

287
  if (!body.success) {
288
    throw createError({ statusCode: 400, data: body.error.flatten() })
289
  }
290

291
  return db.post.create({ data: body.data })
292
})
293
```
294

295
```vue
296
<!-- pages/posts/index.vue -->
298
const page = ref(1)
299
const { data, status } = await useFetch("/api/posts", {
300
  query: { page },
301
  watch: [page],
302
})
306
  <div>
307
    <h1>Blog Posts</h1>
308
    <div v-if="status === 'pending'">Loading...</div>
309
    <ul v-else-if="data?.posts.length">
310
      <li v-for="post in data.posts" :key="post.id">
311
        <NuxtLink :to="`/posts/${post.id}`">{{ post.title }}</NuxtLink>
312
      </li>
313
    </ul>
314
    <p v-else>No posts found.</p>
315
    <button :disabled="page <= 1" @click="page--">Previous</button>
316
    <button :disabled="page >= (data?.totalPages ?? 1)" @click="page++">Next</button>
317
  </div>
319
```
320

324
User asks: "How should I handle auth state across SSR and client in Nuxt?"
326
[`useAuth` composable with `useState<User | null>` for SSR-safe state, `$fetch("/api/auth/me")` in `fetchUser`, computed `isAuthenticated`, and `logout` with `navigateTo`. Plugin `auth.ts` calls `fetchUser()` on app init. Named middleware `auth.ts` checks `isAuthenticated` and redirects to `/login`. Key: `useState` serializes server→client during hydration, so auth state is consistent without double-fetching.]
330
- Use `useFetch`/`useAsyncData` for all data that must render on the server — they handle SSR hydration, deduplication, and caching automatically
331
- Use `useState` instead of `ref` for state that must transfer from server to client — `ref` values are lost during hydration causing mismatches
332
- Validate all server route inputs (params, query, body) with zod — server routes are public HTTP endpoints, unvalidated input is a security risk
333
- Use Nuxt's auto-import conventions to reduce boilerplate — explicit imports from `composables/`, `utils/`, `components/` are unnecessary and add noise
334
- Return `readonly()` refs from composables — prevents consumers from bypassing the composable's mutation logic
335
- Use file-based method routing (`endpoint.get.ts`, `endpoint.post.ts`) — maps directly to HTTP methods, makes API structure visible in the file tree
336
- Use `routeRules` for per-route rendering strategy (SSR/SSG/SPA) — avoids one-size-fits-all rendering, optimizes each route for its use case
337
- Use `<NuxtErrorBoundary>` for component-level error isolation — prevents a single component failure from crashing the entire page