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.

  • namenuxt-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

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.

~/.claude/skills/nuxt-apps/SKILL.md

--- name: nuxt-apps description: Builds and debugs Nuxt 4 applications (with Nuxt 3 and Nuxt 5 compatibility notes) — app/ directory structure, file-based routing and… license: MIT ---

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
SituationNotes
Nuxt 4.xapp/ 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.xFlat layout (pages/ at root) unless future.compatibilityVersion: 4; Nuxt 3 reached end of life on 2026-07-31 — plan the upgrade
future.compatibilityVersion: 5Opt-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

WhereUseWhy
Component setup / page, data needed for renderuseFetch('/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 routeCall the shared function directly (or event.$fetch)Avoids a second HTTP hop
SSR fetch that must forward the user’s cookiesuseRequestFetch() 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 $fetch at the top level of setup for 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 useAsyncData an explicit, unique key that includes every input. Two calls with the same key share one data ref.
  • pick or transform to shrink the payload serialized into the HTML.
  • lazy: true (or useLazyFetch) for non-critical data so navigation is not blocked; then handle status === 'pending' in the template.
  • server: false only for user-specific data that must not be in SSR HTML.
  • After a mutation, refresh() the relevant fetch or refreshNuxtData(key).
  • Nuxt 4 shares data between calls with the same key and uses getCachedData for 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.X inside 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' },
}
  • isr needs a host that supports it (Vercel, Netlify); swr works with Nitro’s cache on any server preset.
  • nuxt generate prerenders crawlable routes; dynamic routes not linked from anywhere need nitro.prerender.routes.

State, middleware, plugins

  • Shared state: useState('cart', () => []) — SSR-safe and transferred in payload. Never use a module-level ref() for request data: it leaks between users on the server.
  • Pinia via @pinia/nuxt for 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(), window checks, locale formatting). Wrap client-only UI in <ClientOnly> or compute in onMounted.
  • useFetch inside a function called after await in setup loses Nuxt context (“nuxt instance unavailable”). Keep composable calls at top level of setup, before unrelated awaits, or use useNuxtApp().runWithContext.
  • Auto-imports hide where things come from; when debugging, check .nuxt/imports.d.ts and .nuxt/types/.
  • server/utils is not available in app/; put shared code in shared/.
  • Large payloads: inspect window.__NUXT__ size; trim with pick/transform.
  • Modules (@nuxt/image, @nuxt/content, @nuxtjs/i18n) change build and routing; read each module’s version compatibility before upgrading Nuxt.

Verification

  1. npx nuxi typecheck passes.
  2. npx nuxi build && node .output/server/index.mjs — test production, not dev.
  3. In the network tab on first load, render data comes from the HTML payload: no duplicate /api/* request for the same data after hydration.
  4. Set a runtime env var (NUXT_PUBLIC_API_BASE=...) on the built server and confirm it applies without rebuilding.
  5. 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.

esc