Nuxt Apps skill for Claude Code
Fetch once on the server, render per route. Claude picks the right fetch for each context in a Nuxt app: useFetch or useAsyncData with explicit keys for render data, $fetch for events, direct calls on the server. It validates every Nitro route input, keeps secrets in server runtime config overridable by NUXT_ env vars, sets rendering per route with routeRules, and checks the production build for double fetches.
- name
nuxt-apps - size195 lines · ≈2,150 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds and debugs Nuxt 4 applications (with Nuxt 3 and Nuxt 5 compatibility notes) — app/ directory structure, file-based routing and layouts, useFetch, useAsyncData and $fetch used in the right place, Nitro server routes with validated input, routeRules for hybrid SSR/SSG/ISR/SWR rendering, runtimeConfig and env overrides, useState for SSR-safe state, middleware, plugins, and modules. Use when creating pages or server/api endpoints, data fetches double on hydration, env vars do not apply in production, or when upgrading Nuxt 3 to 4. Try asking
- Our Nuxt page fetches the same API twice on load — fix it
- Add a PATCH /api/users/:id server route with validation and an ownership check
- Upgrade this Nuxt 3 app to Nuxt 4 and make the blog section ISR
Use it when
- Adding pages, layouts, middleware or plugins to a Nuxt app
- Writing server/api routes on Nitro
- Data fetches duplicate, go stale or mismatch on hydration
- Env vars or runtimeConfig behave differently in production
Skip it when
- The question is plain Vue component or composable design — use vue-composition
- You are deciding rendering modes across a whole product — start with rendering-strategies
How to check the result
Run the production build with node .output/server/index.mjs, confirm render data arrives in the payload with no duplicate API call after hydration, and that NUXT_ env vars apply without a rebuild.
The common mistake it prevents
Calling $fetch at the top of setup for render data, which runs on the server and again in the browser and can cause hydration mismatches.
A good Nuxt app fetches each piece of data once on the server and reuses it on the
client, keeps secrets in server-only runtime config, validates every server route
input, and declares rendering per route with routeRules instead of one global mode.
Step 0 — version and layout
npm ls nuxt nitropack vue
grep -n "compatibilityVersion\|future\|srcDir\|routeRules\|ssr:" nuxt.config.ts
ls app server shared 2>/dev/null
| Situation | Notes |
|---|---|
| Nuxt 4.x | app/ is the source dir (pages, components, composables, layouts inside it); server/ and shared/ at root; useAsyncData data is a shallowRef and defaults to undefined |
| Nuxt 3.x | Flat layout (pages/ at root) unless future.compatibilityVersion: 4; Nuxt 3 reached end of life on 2026-07-31 — plan the upgrade |
future.compatibilityVersion: 5 | Opt-in preview of Nuxt 5 behavior (Nitro v3); do not enable on production apps without testing every server route |
Upgrade 3 → 4: npx nuxt upgrade --dedupe, then run npx codemod@latest nuxt/4/migration-recipe
and review the diff (it moves files into app/ and updates data-fetching defaults).
Structure
app/
app.vue # root; <NuxtLayout><NuxtPage /></NuxtLayout>
pages/users/[id].vue # route /users/:id
layouts/default.vue
middleware/auth.ts # route middleware (client + server navigation)
plugins/api.ts # runs on app creation; .client.ts / .server.ts suffixes
composables/ # auto-imported
components/ # auto-imported; Lazy prefix for lazy loading
server/
api/users/[id].get.ts # /api/users/:id GET, method suffix in filename
middleware/ # runs on every server request (no return = continue)
utils/ # auto-imported on the server only
shared/ # code importable from both app and server (types, validators)
nuxt.config.ts
Data fetching — choose by context
| Where | Use | Why |
|---|---|---|
| Component setup / page, data needed for render | useFetch('/api/x') or useAsyncData(key, fn) | Runs on server, payload transferred, no refetch on hydration |
| Event handlers, form submits, after mount | $fetch('/api/x', { method: 'POST', body }) | Plain request; no payload dedupe |
| Server route calling another internal route | Call the shared function directly (or event.$fetch) | Avoids a second HTTP hop |
| SSR fetch that must forward the user’s cookies | useRequestFetch() or useRequestHeaders(['cookie']) | $fetch during SSR does not forward request headers by itself |
<script setup lang="ts">
const route = useRoute();
const { data: user, status, error, refresh } = await useFetch(
() => `/api/users/${route.params.id}`,
{ key: `user-${route.params.id}`, pick: ['id', 'name', 'email'] },
);
</script>
<template>
<p v-if="status === 'pending'">Loading…</p>
<p v-else-if="error">Could not load user ({{ error.statusCode }})</p>
<UserCard v-else-if="user" :user="user" />
</template>
Rules:
- Never call
$fetchat the top level ofsetupfor render data — it runs on the server and again on the client (double request, possible hydration mismatch). - Use a function or reactive URL so the fetch re-runs when params change; or pass
watch: [source]. - Give
useAsyncDataan explicit, unique key that includes every input. Two calls with the same key share one data ref. pickortransformto shrink the payload serialized into the HTML.lazy: true(oruseLazyFetch) for non-critical data so navigation is not blocked; then handlestatus === 'pending'in the template.server: falseonly for user-specific data that must not be in SSR HTML.- After a mutation,
refresh()the relevant fetch orrefreshNuxtData(key). - Nuxt 4 shares data between calls with the same key and uses
getCachedDatafor client-side cache; override it to control when navigation reuses payload data.
Server routes (Nitro)
// server/api/users/[id].patch.ts
import { z } from 'zod';
const Params = z.object({ id: z.string().uuid() });
const Body = z.object({ name: z.string().min(1).max(100) });
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event); // your auth util (e.g. nuxt-auth-utils)
const { id } = await getValidatedRouterParams(event, Params.parse);
const body = await readValidatedBody(event, Body.parse);
if (session.user.id !== id) throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
return db.user.update({ where: { id }, data: body });
});
getValidatedQuery,readValidatedBody,getValidatedRouterParams— validate every input; a thrown Zod error becomes a 400.throw createError({ statusCode, statusMessage })for errors; never return{ error }with a 200.- Return plain objects; Nitro serializes JSON. Dates arrive as strings on the client — type the response accordingly or use a serializer.
- Secrets only in server code via
useRuntimeConfig(event).
Runtime config and env
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
stripeSecret: '', // server only; set by NUXT_STRIPE_SECRET
public: { apiBase: '/api' }, // exposed to client; NUXT_PUBLIC_API_BASE
},
});
- At runtime only
NUXT_-prefixed env vars override runtimeConfig keys (camelCase → SCREAMING_SNAKE).process.env.Xinside nuxt.config is read at build time only. - Declare every key in nuxt.config with a default; undeclared keys are not overridable.
- Never put secrets under
public.
Rendering per route
routeRules: {
'/': { prerender: true },
'/blog/**': { isr: 3600 }, // regenerate at most hourly (platform support needed)
'/products/**': { swr: 600 },
'/dashboard/**': { ssr: false }, // SPA section
'/api/public/**': { cors: true, headers: { 'cache-control': 's-maxage=60' } },
'/old-path': { redirect: '/new-path' },
}
isrneeds a host that supports it (Vercel, Netlify);swrworks with Nitro’s cache on any server preset.nuxt generateprerenders crawlable routes; dynamic routes not linked from anywhere neednitro.prerender.routes.
State, middleware, plugins
- Shared state:
useState('cart', () => [])— SSR-safe and transferred in payload. Never use a module-levelref()for request data: it leaks between users on the server. - Pinia via
@pinia/nuxtfor larger stores; same rule applies. callOnce(async () => ...)for setup work that must run once per request, not per component.- Route middleware:
defineNuxtRouteMiddleware((to) => { if (!loggedIn.value) return navigateTo('/login') }). It is a UX gate; enforce authorization again in server routes. - Plugins provide helpers:
return { provide: { api } }→useNuxtApp().$api.
Gotchas
- Hydration mismatch: values that differ between server and client (
Date.now(),Math.random(),windowchecks, locale formatting). Wrap client-only UI in<ClientOnly>or compute inonMounted. useFetchinside a function called afterawaitin setup loses Nuxt context (“nuxt instance unavailable”). Keep composable calls at top level of setup, before unrelated awaits, or useuseNuxtApp().runWithContext.- Auto-imports hide where things come from; when debugging, check
.nuxt/imports.d.tsand.nuxt/types/. server/utilsis not available inapp/; put shared code inshared/.- Large payloads: inspect
window.__NUXT__size; trim withpick/transform. - Modules (
@nuxt/image,@nuxt/content,@nuxtjs/i18n) change build and routing; read each module’s version compatibility before upgrading Nuxt.
Verification
npx nuxi typecheckpasses.npx nuxi build && node .output/server/index.mjs— test production, not dev.- In the network tab on first load, render data comes from the HTML payload: no
duplicate
/api/*request for the same data after hydration. - Set a runtime env var (
NUXT_PUBLIC_API_BASE=...) on the built server and confirm it applies without rebuilding. - Hit each new server route with invalid input and expect 400, without auth expect 401/403.
Report format
**Nuxt:** 4.x, preset node-server
**Routes:** /users/[id] (SSR, useFetch key user-:id), /blog/** (isr 3600)
**Server API:** PATCH /api/users/:id (zod params + body, owner check)
**Config:** NUXT_STRIPE_SECRET (server), NUXT_PUBLIC_API_BASE (public)
**Verified:** typecheck, prod build, no duplicate fetch on hydration, 400/403 paths About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.