--- name: nuxt-project-patterns description: patterns for Nuxt 3 projects — data fetching (useAsyncData vs TanStack Query with axios services), server routes with Zod, runtimeConfig, SEO, routeRules, Pinia. Load when building pages, server/api routes, or configuring a Nuxt 3 app. --- # Nuxt project patterns Target **Nuxt 3**. Do not use Nuxt-4-only patterns; Nuxt 4 will be evaluated later. Note the Nuxt roadmap lists Nuxt 3 maintenance (bug fixes, security patches) as ending in July 2026 and Nuxt 4 as the current release, so confirm support status and plan the migration. Avoid, for example: - The `app/` source directory layout (Nuxt 3 uses root-level `pages/`, `components/`, etc.). - Relying on `data`/`error` from `useAsyncData` defaulting to `undefined` or shallow reactivity (Nuxt 3: `null` and deep `ref`). ## Data fetching - Axios is the only standard client for service calls (`api-client-patterns`). Fetch-based clients (`$fetch`, `useFetch`, native `fetch`) are a flagged fallback only. No raw axios calls in components for render data; go through service functions. - In Nitro `server/api` code, upstream API calls use axios. `event.$fetch` is allowed only for calling internal Nitro routes, not as a general client (`api-client-patterns`). - Which to use: - SSR initial page data: `useAsyncData` with an **explicit key**, calling a service function (hydrates without double fetching). - Client-driven, repeated, or mutating data (search, pagination, forms, optimistic updates): TanStack Query. - Pinia vs TanStack split: see the TanStack Query section. ```ts const route = useRoute() const { $api } = useNuxtApp() const { data: post } = await useAsyncData(`post-${route.params.id}`, () => getPost($api, Number(route.params.id)), ) ``` ## TanStack Query (`@tanstack/vue-query`) Pinia vs TanStack split (single source of truth): **Pinia = client/UI state** (session, preferences, cross-page UI). **TanStack Query = server data** that is cached, refetched, or mutated. Never copy query results into Pinia. Form submission with mutations (page owns the mutation, organism owns the form): see `form-handling`. - **Key factories**: one per domain, hierarchical, `as const`. ```ts export const postKeys = { all: ['posts'] as const, lists: () => [...postKeys.all, 'list'] as const, list: (filters: PostFilters) => [...postKeys.lists(), filters] as const, detail: (id: number) => [...postKeys.all, 'detail', id] as const, } ``` - **Wrap in composables** (`usePosts`, `useUpdatePost`); components/pages never call `useQuery`/`useMutation` with inline keys. Pass reactive inputs (refs/getters) into the key so it refetches on change. Call them in pages only (see `atomic-components-design`). ```ts export const usePost = (id: MaybeRefOrGetter) => { const { $api } = useNuxtApp() // per-request axios instance (api-client-patterns) return useQuery({ queryKey: computed(() => postKeys.detail(toValue(id))), queryFn: ({ signal }) => getPost($api, toValue(id), signal), }) } export const useUpdatePost = () => { const queryClient = useQueryClient() const { $api } = useNuxtApp() return useMutation({ mutationFn: (post: Post) => updatePost($api, post), onSuccess: (_data, post) => { queryClient.invalidateQueries({ queryKey: postKeys.lists() }) queryClient.invalidateQueries({ queryKey: postKeys.detail(post.id) }) }, }) } ``` - **Server-side cookies**: axios does not forward the incoming request's cookies/headers, so authenticated calls fail during SSR unless the per-request instance sets them (`useRequestHeaders(['cookie'])` in the plugin, see `api-client-patterns`). Get the instance in the composable's setup scope (as in `usePost`). Never share one auth-bearing instance across requests. - **SSR hydration**: create the `QueryClient` in a Nuxt plugin, dehydrate into `useState` on the server, hydrate on the client. ```ts // plugins/vue-query.ts import type { DehydratedState } from '@tanstack/vue-query' import { QueryClient, VueQueryPlugin, dehydrate, hydrate } from '@tanstack/vue-query' export default defineNuxtPlugin((nuxtApp) => { const state = useState('vue-query') const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } }) nuxtApp.vueApp.use(VueQueryPlugin, { queryClient }) if (import.meta.server) { nuxtApp.hooks.hook('app:rendered', () => { state.value = dehydrate(queryClient) }) } if (import.meta.client) { nuxtApp.hooks.hook('app:created', () => { hydrate(queryClient, state.value) }) } }) ``` To prefetch on the server, `await` the query's `suspense()` in the page's `