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 }, // SSG213
"/dashboard/**": { ssr: true }, // SSR214
"/admin/**": { ssr: false }, // SPA215
"/api/**": { cors: true }, // API config216
"/blog/**": { swr: 3600 }, // ISR217
},
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