Claude Code with Nuxt: SSR, Auto-Imports, Server Routes
Why Nuxt needs its own CLAUDE.md
Claude Code knows Vue 3 well. Give it a prompt and it will scaffold a <script setup> component, wire up a Pinia store, extract a composable, and write Vitest tests. For a plain Vue SPA, that is mostly correct.
Nuxt changes the rules. When you add server-side rendering, auto-imports, Nitro server routes, the Nuxt composable layer, and hybrid rendering modes, a large share of Vue SPA conventions either break silently or produce code that compiles in dev but fails in production. Claude does not default to Nuxt conventions without context. It falls back to patterns it has seen most often, which are Vue SPA patterns, and you end up with hydration mismatches, broken server routes, state that does not survive the client transition, and composables that access window on the server.
This guide builds a CLAUDE.md for Nuxt that prevents all of those failure modes. It covers auto-imports, server routes, data fetching strategy, SSR-safe state, environment configuration, and the rendering mode decisions that shape how Claude generates your application. If you are setting up Claude Code for the first time, the Claude Code setup guide covers installation. For the underlying Vue 3 patterns this builds on, Claude Code with Vue 3 is a useful starting point.
The core problem is that Nuxt straddles two runtimes. Your pages/, components/, and composables/ directories run in both the Node.js server environment during SSR and in the browser after hydration. Your server/ directory runs in Nitro, which is its own runtime, separate from the Vue application context entirely. Claude cannot infer these boundaries without being told. The CLAUDE.md is where you draw the lines.
The Nuxt CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Nuxt application it needs to declare: Nuxt version, modules installed, directory conventions (especially if you are using Nuxt 4's app/ directory structure), which composables are available without import, how data fetching works, how environment variables are accessed, and the hard rules that prevent server/client boundary violations.
# Nuxt project rules
## Stack
- Nuxt 4.x (app/ directory enabled, Vue 3, Nitro engine)
- TypeScript strict, @nuxt/eslint
- Modules: @pinia/nuxt, @nuxtjs/tailwindcss, @nuxt/image, nuxt-icon
- Deployment: Vercel (Node.js preset)
## Directory structure (Nuxt 4 app/ layout)
- app/pages/: file-based routing (index.vue, [id].vue, [...slug].vue)
- app/components/: auto-imported, no import needed in SFCs
- app/composables/: auto-imported, use* naming convention
- app/layouts/: default.vue plus named layouts
- app/plugins/: client-only (.client.ts) and universal (.ts)
- app/middleware/: route middleware, universal and client-only
- app/stores/: Pinia stores via defineStore, exported and auto-imported
- server/api/: Nitro route handlers, defineEventHandler, readBody, etc.
- server/middleware/: Nitro server middleware, runs before every request
- server/utils/: server-only utilities, auto-imported in server/ tree
- public/: static assets, not processed by Vite
- app/assets/: processed by Vite (CSS, fonts, local images)
## Auto-imports (NEVER manually import these)
- Vue: ref, computed, watch, watchEffect, onMounted, onUnmounted, nextTick
- Vue Router: useRoute, useRouter
- Nuxt composables: useNuxtApp, useRuntimeConfig, useState, useFetch,
useAsyncData, useLazyFetch, useLazyAsyncData, navigateTo, useError,
defineNuxtRouteMiddleware, defineNuxtPlugin, defineNuxtComponent
- Pinia: defineStore, storeToRefs (via @pinia/nuxt)
- Components in app/components/, deeply nested, PascalCase, including
folder prefix (e.g. components/base/Card.vue → <BaseCard />)
## Environment and config (NEVER use process.env directly in app/ code)
- useRuntimeConfig() returns { public: {}, ...private } in app/ code
- useRuntimeConfig() returns full config (public + private) in server/ code
- Public config (exposed to client): set in nuxt.config.ts runtimeConfig.public
- Private config (server only): set in nuxt.config.ts runtimeConfig (top-level)
- All values are overridable via NUXT_* env vars at runtime
## Data fetching rules
- useFetch: for data needed on first render (SSR-safe, deduplicates requests)
- useAsyncData: for custom fetch logic or non-URL-based async (SSR-safe)
- $fetch: for client-side-only requests (event handlers, actions, form submits)
- useLazyFetch / useLazyAsyncData: when you can render before data arrives
- NEVER use axios or raw fetch() at the top level of a composable or setup()
- NEVER use $fetch for initial page data (it runs twice: once server, once client)
## State rules
- useState<T>(key, init): SSR-safe shared state, serialised and sent to client
- Pinia store: for complex state with actions and cross-component sharing
- ref() / reactive(): component-local state only (NOT shared across requests)
- NEVER use a module-level variable for state (shared across all SSR requests)
## Hard rules
- NEVER access window, document, or localStorage outside onMounted / .client.ts
- NEVER import server/ utilities into app/ code (Nitro runtime, not Vue)
- NEVER use process.env in app/ code, use useRuntimeConfig()
- NEVER put secrets in runtimeConfig.public, public keys are sent to the client
- ALWAYS use navigateTo() for programmatic navigation, not router.push()
- ALWAYS define the key param in useFetch / useAsyncData to avoid cache collisions
- server/api/ handlers are not Vue, no ref, no composables, no useNuxtApp()
This template addresses the three categories of mistakes Claude makes without Nuxt context: boundary violations (accessing browser APIs on the server), wrong data fetching primitives (using $fetch for initial data or raw fetch in setup), and state anti-patterns (module-level variables that bleed across SSR requests).
The most important section is the hard rules list at the bottom. Put it last so it appears close to the top of Claude's working context after the file is summarised.
Auto-imports: what Claude assumes vs. what Nuxt provides
Auto-imports are the feature Nuxt developers either love or find confusing when they first arrive from Vue SPA or React. Nuxt scans your app/components/, app/composables/, app/utils/, and app/stores/ directories and makes everything in them available without an explicit import statement. The same applies to Vue's reactivity primitives, Vue Router hooks, and Nuxt's own composables.
Without the CLAUDE.md rule, Claude adds import statements at the top of every file:
<!-- Claude without context -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useFetch } from '#app'
import { useProductStore } from '@/stores/product'
</script>
This compiles and works. It is also unnecessary noise in a Nuxt project and will trigger your ESLint rule if you have @nuxt/eslint configured with autoImport: true. The correct output is:
<!-- Claude with Nuxt CLAUDE.md -->
<script setup lang="ts">
const route = useRoute()
const { data: product, error } = await useFetch(`/api/products/${route.params.id}`)
const productStore = useProductStore()
</script>
The CLAUDE.md rule "NEVER manually import these" is explicit because Claude's default instinct is to be explicit about imports for clarity. In a Nuxt project that instinct produces incorrect output. You want Claude to skip imports and trust Nuxt's layer.
One nuance worth adding to your CLAUDE.md: component naming with folder prefixes. A component at app/components/base/Card.vue is registered as <BaseCard />, not <Card />. Claude will use <Card /> without the rule, which works until you have two Card.vue files in different subdirectories and Nuxt resolves the wrong one.
Server routes and the Nitro boundary
Nuxt's server/api/ directory is not part of the Vue application. It runs on Nitro, a separate server engine that does not share the Vue context. This means no ref, no useNuxtApp, no composables, no Pinia. It also means every server/api/ file has access to Nitro's own utilities: defineEventHandler, readBody, getQuery, setResponseStatus, createError, $fetch, and H3EventContext.
The most common mistake Claude makes without this distinction is importing Vue composables or Nuxt's useFetch into a server route:
// WRONG, Claude without context
// server/api/products/[id].get.ts
import { useRuntimeConfig } from '#app' // breaks in Nitro
import { useProductStore } from '@/stores/product' // breaks in Nitro
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig() // wrong: only works in Vue context
const store = useProductStore() // wrong: Pinia not available here
...
})
The correct pattern uses Nitro's own config access and direct service calls:
// CORRECT, Nuxt server route
// server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
const { id } = getRouterParams(event)
const config = useRuntimeConfig() // correct: Nitro has its own useRuntimeConfig
const product = await $fetch(`${config.apiBaseUrl}/products/${id}`, {
headers: { Authorization: `Bearer ${config.apiSecret}` }
})
if (!product) {
throw createError({ statusCode: 404, statusMessage: 'Product not found' })
}
return product
})
The file naming convention in server/api/ controls HTTP method routing automatically. products/[id].get.ts handles GET, products/[id].delete.ts handles DELETE. Claude needs this in CLAUDE.md to generate the correct file names:
## Server route file conventions
- server/api/users.get.ts → GET /api/users
- server/api/users.post.ts → POST /api/users
- server/api/users/[id].get.ts → GET /api/users/:id
- server/api/users/[id].put.ts → PUT /api/users/:id
- server/api/users/[id].delete.ts → DELETE /api/users/:id
- server/api/[...].ts → catch-all handler
- server/middleware/auth.ts → runs before every request (no method suffix)
Add a validation pattern to your CLAUDE.md server route section. Claude generates consistent input validation when the pattern is explicit:
## Server route validation pattern
import { z } from 'zod'
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const parsed = CreateUserSchema.safeParse(body)
if (!parsed.success) {
throw createError({ statusCode: 400, data: parsed.error.flatten() })
}
// use parsed.data
})
With this in CLAUDE.md, Claude generates the Zod parse on every POST handler. Without it, Claude frequently skips validation or uses a manual if (!body.email) check that misses edge cases.
useFetch vs $fetch vs useAsyncData
This is the decision Claude gets wrong most often in Nuxt projects, and the consequences range from subtle (double network requests) to broken (hydration mismatches, stale data).
The three primitives have distinct purposes:
useFetch wraps useAsyncData and $fetch together. It runs on the server during SSR, serialises the result into the HTML payload, and rehydrates on the client without making a second network request. Use it for any data your page needs before it renders. The key parameter is how Nuxt deduplicates and caches the result across navigations.
useAsyncData is useFetch without the URL shorthand. Use it when you need custom fetch logic (a database call, a third-party SDK, or anything that is not a plain HTTP URL). The shape is identical to useFetch and the SSR behaviour is the same.
$fetch is a thin wrapper around ofetch. It makes a raw HTTP request with no SSR deduplication, no caching, and no state serialisation. Use it inside event handlers (button clicks, form submits) and inside server route handlers where SSR does not apply. Never use it at the top level of <script setup> for initial data, because it will fire once on the server (adding to page render time) and again on the client (doubling the request), with the two responses potentially differing.
Add this decision tree to your CLAUDE.md:
## Data fetching decision guide
Is this data needed before the page renders?
YES → use useFetch (if fetching a URL) or useAsyncData (custom async logic)
NO → use $fetch inside the event handler
Is the user on a page that changes route params?
YES → add the param to useFetch key: useFetch(`/api/products/${id}`, { key: `product-${id}` })
NO → static key is fine
Does the data need to refresh on every navigation?
YES → add watch: [route.params.id] to useFetch options
NO → default deduplication is correct
Can the page render with a loading state while data arrives?
YES → use useLazyFetch (non-blocking SSR, data arrives after hydration)
NO → use useFetch (blocking SSR, page waits for data before sending HTML)
A worked example showing all three in context:
<!-- app/pages/products/[id].vue -->
<script setup lang="ts">
const route = useRoute()
// Initial product data, SSR-safe, deduplicated, blocking
const { data: product, error } = await useFetch(
() => `/api/products/${route.params.id}`,
{ key: () => `product-${route.params.id}` }
)
// Related products, non-blocking, page renders before this arrives
const { data: related, status } = useLazyFetch(
() => `/api/products/${route.params.id}/related`,
{ key: () => `related-${route.params.id}` }
)
// Add to cart, client action, $fetch is correct here
async function addToCart(productId: string) {
await $fetch('/api/cart/items', {
method: 'POST',
body: { productId, quantity: 1 }
})
}
</script>
The arrow function syntax for URLs (() => \/api/products/${route.params.id}``) is important when the URL contains reactive values. It tells Nuxt to re-evaluate the URL when the route changes. Without it, the URL is computed once at setup time and the page shows stale data after navigating to a different product.
SSR-safe state with useState and Pinia
State management in Nuxt has a constraint that does not exist in Vue SPAs: state that lives in a module-level variable is shared across all incoming requests on the server. A user visits your app, their request triggers SSR, and the module-level ref now holds their data. The next request comes in before the first response is sent, and now both requests see mixed state. This is a classic SSR data bleed.
The two safe patterns are useState and Pinia stores configured through the Nuxt plugin system.
useState is Nuxt's built-in SSR-safe state primitive. It scopes state to the current request on the server and to the current page on the client, serialises it into the hydration payload, and makes it accessible across components without prop drilling.
// app/composables/useCart.ts
export function useCart() {
// Unique key scopes this state per-request on the server
const items = useState<CartItem[]>('cart.items', () => [])
const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))
function addItem(item: CartItem) {
items.value.push(item)
}
function removeItem(id: string) {
items.value = items.value.filter(item => item.id !== id)
}
return { items: readonly(items), total, addItem, removeItem }
}
The key ('cart.items') is how Nuxt identifies the state across server and client. Use a namespaced key that is unique across your application. Claude will sometimes generate a generic key like 'items' when the rule is not explicit; a naming collision between two useState calls produces state that overwrites the wrong piece of data.
Pinia stores in Nuxt work through @pinia/nuxt, which handles the SSR lifecycle automatically. The store definition is identical to plain Pinia, but the module-level bleed problem is handled by the Nuxt plugin:
// app/stores/product.ts
export const useProductStore = defineStore('product', () => {
const featured = ref<Product[]>([])
const isLoading = ref(false)
async function fetchFeatured() {
isLoading.value = true
featured.value = await $fetch('/api/products/featured')
isLoading.value = false
}
return { featured: readonly(featured), isLoading: readonly(isLoading), fetchFeatured }
})
<!-- app/pages/index.vue -->
<script setup lang="ts">
const productStore = useProductStore()
const { featured, isLoading } = storeToRefs(productStore)
// Populate during SSR with useAsyncData wrapping the store action
await useAsyncData('featured-products', () => productStore.fetchFeatured())
</script>
The useAsyncData wrapper around the Pinia action is the correct pattern for SSR. Without it, the store action runs on the server but the result is not serialised into the hydration payload, so the client re-fetches on mount and you get a flash of empty content. With the wrapper, Nuxt serialises the store state and ships it with the HTML.
Add this pattern to your CLAUDE.md:
## Pinia + SSR pattern
- Always populate stores in pages via useAsyncData(key, () => store.action())
- The key must be unique across all useAsyncData calls in your app
- storeToRefs() preserves reactivity when destructuring store state
- NEVER call a store action directly in <script setup> without useAsyncData wrapping
useRuntimeConfig and environment variables
Claude defaults to process.env.MY_VAR when generating environment variable access in Vue/Node.js projects. In Nuxt, this is wrong in all app/ code and partially wrong in server/ code, because Nuxt replaces direct process.env access with a runtime config system that handles public/private separation, type safety, and runtime overrides via NUXT_* env vars.
The configuration lives in nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// Private, server only, NEVER sent to client
databaseUrl: '', // override with NUXT_DATABASE_URL
apiSecret: '', // override with NUXT_API_SECRET
stripeSecretKey: '', // override with NUXT_STRIPE_SECRET_KEY
public: {
// Public, sent to client in the hydration payload
apiBaseUrl: '', // override with NUXT_PUBLIC_API_BASE_URL
stripePublishableKey: '', // override with NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
siteUrl: '', // override with NUXT_PUBLIC_SITE_URL
}
}
})
In app/ code, only public values are accessible:
// app/composables/useStripe.ts
const config = useRuntimeConfig()
// Correct, public key is available on client
const stripe = loadStripe(config.public.stripePublishableKey)
// WRONG, this is undefined in app/ code
const secret = config.stripeSecretKey // server-only, undefined in browser
In server/ code, both public and private values are accessible:
// server/api/payment/intent.post.ts
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event) // pass event for per-request config
const stripe = new Stripe(config.stripeSecretKey) // private, correct in server
const { amount, currency } = await readBody(event)
const intent = await stripe.paymentIntents.create({ amount, currency })
return { clientSecret: intent.client_secret }
})
The NUXT_* env var convention is the runtime override mechanism. NUXT_DATABASE_URL overrides runtimeConfig.databaseUrl, and NUXT_PUBLIC_API_BASE_URL overrides runtimeConfig.public.apiBaseUrl. Claude generates process.env.DATABASE_URL without the CLAUDE.md rule, which works in Node.js but bypasses Nuxt's type-safe config layer and makes the values unavailable in the browser context where public config should be accessible.
Hydration mismatches and how to prevent them
A hydration mismatch happens when the HTML Nuxt renders on the server does not match the Vue application's first render on the client. The browser sees a discrepancy, throws a warning (and in strict mode, re-renders the entire tree), and you get a flash or a layout shift.
Claude generates hydration mismatches most often in four patterns:
Pattern 1: accessing browser APIs during setup. window, document, localStorage, and navigator do not exist in Node.js. Code that accesses them during <script setup> runs on the server and throws.
<!-- WRONG, breaks SSR -->
<script setup lang="ts">
const theme = localStorage.getItem('theme') ?? 'light'
const isMobile = window.innerWidth < 768
</script>
<!-- CORRECT, deferred to client -->
<script setup lang="ts">
const theme = ref('light')
const isMobile = ref(false)
onMounted(() => {
theme.value = localStorage.getItem('theme') ?? 'light'
isMobile.value = window.innerWidth < 768
})
</script>
Pattern 2: date and random values during render. new Date() and Math.random() produce different values on the server and client.
<!-- WRONG, different value server vs client -->
<template>
<p>Fetched at {{ new Date().toLocaleTimeString() }}</p>
</template>
<!-- CORRECT, use useState or onMounted -->
<script setup lang="ts">
const fetchTime = useState('fetch-time', () => '')
onMounted(() => { fetchTime.value = new Date().toLocaleTimeString() })
</script>
Pattern 3: using a client-only plugin inside a universal component. Plugins with the .client.ts suffix only run in the browser. Accessing their return values during SSR returns undefined.
Pattern 4: conditional rendering based on client state. A component that renders different content based on localStorage or navigator.userAgent produces a mismatch because the server has no access to those values.
Add a mismatches section to your CLAUDE.md:
## Hydration mismatch prevention
- NEVER access window/document/localStorage/navigator in <script setup> directly
- Defer to onMounted() for any browser-only access
- For client-only components, use <ClientOnly> wrapper or .client.vue suffix
- For date/random values in templates, initialize with useState and set on mount
- After a hydration warning in dev: check the component for server/client divergence
The <ClientOnly> component is Nuxt's built-in way to skip SSR for a subtree:
<template>
<div>
<p>This renders on server and client.</p>
<ClientOnly>
<!-- This only renders on client, no hydration mismatch -->
<UserDashboard />
<template #fallback>
<DashboardSkeleton />
</template>
</ClientOnly>
</div>
</template>
The #fallback slot renders during SSR in place of the client-only content. Without it, the server sends empty HTML for that section and users see a layout shift. Claude generates the fallback slot when you include the pattern in CLAUDE.md; without it, Claude generates <ClientOnly> with no fallback.
Rendering modes: SSR vs SSG vs ISR
Nuxt 4 supports per-route rendering mode configuration in nuxt.config.ts. Claude defaults to full SSR for every route when the project is set up with ssr: true, which is correct for most dynamic content but unnecessarily expensive for pages that never change.
The three modes:
SSR (Server-Side Rendering): The default. Every request triggers a server render. Correct for personalised pages, authenticated routes, and any page where content changes per user.
SSG (Static Site Generation): Pages are rendered at build time and served as static HTML. Correct for marketing pages, documentation, blog posts, and anything that does not change between deploys. Zero server cost after build.
ISR (Incremental Static Regeneration): Pages are rendered statically but revalidated at a configurable interval. Correct for content that changes infrequently (product listings, public profiles, pricing pages).
Configure in nuxt.config.ts:
export default defineNuxtConfig({
routeRules: {
// Marketing and static content
'/': { prerender: true },
'/about': { prerender: true },
'/pricing': { prerender: true },
'/blog/**': { prerender: true },
// ISR, revalidate every hour
'/products': { isr: 3600 },
'/products/**': { isr: 3600 },
// SSR, always dynamic
'/dashboard/**': { ssr: true },
'/account/**': { ssr: true },
// API routes, no caching by default
'/api/**': { cors: true, headers: { 'cache-control': 's-maxage=0' } }
}
})
Add the decision guide to CLAUDE.md:
## Route rendering mode guide
Is the content personalised per user?
YES → SSR (ssr: true, the default)
Is the content static and defined at build time?
YES → SSG (prerender: true)
Is the content shared but updated periodically?
YES → ISR (isr: <seconds>)
Default: SSR unless specified in routeRules
Claude generates ssr: true globally when this guide is absent, missing opportunities to prerender marketing pages that would load faster and cost less. With the routeRules pattern in CLAUDE.md, Claude adds the appropriate directive when generating page-level configuration.
For the Vercel deployment that most Nuxt projects target, the patterns in Claude Code with Vercel cover the edge config and environment variable setup that composes with these routeRules.
Hard rules and what to review manually
Claude Code produces correct Nuxt code consistently in several areas when the CLAUDE.md is in place: auto-import awareness, server route method routing by filename, useFetch with reactive URLs, useState with scoped keys, useRuntimeConfig for environment access, and <ClientOnly> for browser-only content.
Three areas always warrant a manual review pass.
The first is any code that crosses the server/client boundary. If you see a file in app/ importing from server/, or a composable that calls useRuntimeConfig and then accesses a private key, review it before shipping. The boundary violations compile cleanly in some Nuxt configurations and only fail at runtime.
The second is useFetch key stability. Every useFetch and useAsyncData call needs a stable key that is unique across your entire application. Claude sometimes generates the URL string as the key, which works until two different components fetch the same URL expecting different data (different query parameters, different post-processing). Use explicit, namespaced keys: 'product-detail-${id}', not just the URL.
The third is plugin ordering. Nuxt plugins run in filename order. If plugin B depends on something plugin A sets up (a Pinia store, an HTTP client, an auth token), plugin B must have a higher sort order. Claude generates plugins without ordering awareness. For any plugin that depends on another, add the order to CLAUDE.md:
## Plugin load order
- 01.auth.ts: sets up auth state, runs before everything
- 02.api.ts: configures $fetch baseURL, depends on auth
- 03.analytics.ts: depends on auth (user identity) and api
For the TypeScript patterns that make Nuxt's type-safe config and composable return types work correctly with Claude Code, Claude Code with TypeScript covers the strict mode setup that composes with the Nuxt conventions above.
Building Nuxt applications Claude Code can extend
The Nuxt CLAUDE.md in this guide produces a Claude Code that generates server routes in Nitro's format, data fetching calls with the correct primitive for each context, useState with scoped keys for SSR-safe shared state, useRuntimeConfig for environment access, and <ClientOnly> boundaries for browser-only content. It prevents the most common failure mode: Vue SPA patterns silently applied to a server-first framework.
The underlying principle is that Claude Code performs at the level of context you provide. Without a Nuxt CLAUDE.md, Claude generates code that looks correct in a Vue SPA and breaks in production on Nuxt because the two environments have fundamentally different runtime rules. With the configuration above, Claude generates Nuxt-correct output from the first session.
The most immediate practical step is to drop the CLAUDE.md template from this guide into your project root, remove any sections that do not apply to your module list, and add the specific modules you are using with their version numbers. Claude reads the file at session start and applies the rules immediately. For reference on how CLAUDE.md files are structured and read, CLAUDE.md explained covers the format. For the broader Vue 3 patterns this Nuxt setup builds on, Claude Code with Vue 3 is the companion guide. Claudify includes a Nuxt-specific CLAUDE.md template pre-configured for Nuxt 4, server routes, hybrid rendering, and the SSR-safe state patterns covered here.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify