# Nuxt + shadcn-vue

You are working in a Nuxt 4 project with Vue 3.5, TypeScript, shadcn-vue components built on Reka UI, and Tailwind CSS v4. Write code for the versions in `package.json`, not the ones in your training data: Nuxt 4 moved the app into `app/`, Vue 3.5 changed how props and refs are written, and Tailwind v4 has no JavaScript config.

## Before you write code

- Read `package.json` for `nuxt`, `vue`, `reka-ui` (or `@ark-ui/vue`) and `tailwindcss`, and `nuxt.config.ts` for `modules` (`shadcn-nuxt`, `@nuxtjs/color-mode`), `runtimeConfig`, `css` and `vite.plugins`.
- Read `components.json`: its `aliases` say where UI components, `cn()` and composables live, and `tailwind.css` names the stylesheet that holds the tokens.
- Look in the UI folder before adding a component: it may exist, with local changes to keep.
- Auto-imports are generated into `.nuxt/`. After adding a composable, component or server route, run `npx nuxi prepare` (or keep `nuxi dev` running) so types resolve. Never edit `.nuxt/`.

## Project shape

- App code lives in `app/`: `app.vue`, `error.vue`, `pages/`, `layouts/`, `components/`, `composables/`, `middleware/`, `plugins/`, `utils/`, `assets/`. Server code lives in `server/` at the root, code used by both sides in `shared/` (`shared/utils` and `shared/types` are auto-imported on both), static files in `public/`.
- `~` and `@` resolve to `app/`, `~~` to the project root. Never import from `server/` in app code.
- Vue APIs, Nuxt composables and everything in `components/`, `composables/` and `utils/` are auto-imported; don't add imports for them. Import packages (`reka-ui`, `zod`) explicitly.
- Use `import.meta.client` and `import.meta.server` to branch by environment, `<ClientOnly>` or `onMounted` for browser-only APIs.

## Vue 3.5 components

- Write every component as `<script setup lang="ts">`. No Options API, no `this`, no `defineComponent({ props })` in single-file components.
- Destructure props with defaults; they stay reactive. Watch a destructured prop through a getter.

```vue
<script setup lang="ts">
const { size = "md", disabled = false } = defineProps<{
  size?: "sm" | "md" | "lg";
  disabled?: boolean;
}>();
const emit = defineEmits<{ select: [id: string] }>();
const open = defineModel<boolean>("open", { default: false });
watch(() => size, (next) => console.log(next));
</script>
```

- Two-way binding is `defineModel`, never a hand-written prop plus `update:` emit pair. Type emits with named tuples as above.
- Template refs: `const input = useTemplateRef<HTMLInputElement>("input")` with `ref="input"`. Ids that must match between server and client (label `for`, `aria-describedby`): `useId()`, never `Math.random()`.
- Type slots with `defineSlots<{ default(): any; row(props: { item: Item }): any }>()`.
- Cancel stale async work in a watcher with `onWatcherCleanup(() => controller.abort())`.
- `computed` for derived values, never a watcher that writes another ref. `shallowRef` for large objects you replace whole.
- Defer hydration of heavy below-the-fold components: prefix the name with `Lazy` and add `hydrate-on-visible` or `hydrate-on-idle`.

## Data and state

- Fetch page data with `useFetch` or `useAsyncData` at the top level of `<script setup>` (or in a composable called there). It runs on the server, is serialized into the payload and is not fetched again on hydration.
- In event handlers (submit, click), call `$fetch`, then `refresh()` or `refreshNuxtData(key)`. Never call `useFetch` inside a handler, and never use bare `$fetch` in setup for SSR data: it runs twice.
- Give `useAsyncData` an explicit key unique to the data: `` useAsyncData(`post-${id}`, () => $fetch(`/api/posts/${id}`)) ``. Calls with the same key share `data`, `error` and `status` across components, so one key must always mean the same data and options.
- Pass refs or getters in `query` (or a computed URL) so `useFetch` refetches when they change.
- In Nuxt 4, `data` is a shallow ref that starts as `undefined`, and `error` too. Branch on `status` (`"idle" | "pending" | "success" | "error"`), replace `data.value` rather than mutating deep fields, and use `pick` or `transform` so the payload carries only what the page renders.
- During SSR, `useFetch` forwards the user's cookies to your API; any other server-side call that needs them goes through `useRequestFetch()`.
- Shared state is `useState("cart", () => [])`. Never declare a `ref` at module scope in a composable: on the server it is shared by every request. Use Pinia only if the project already does.

## Server routes

- One file per method: `server/api/posts.get.ts`, `server/api/posts.post.ts`, `server/api/posts/[id].patch.ts`. Helpers in `server/utils/` are auto-imported in server code.
- Validate every body, query and param with a schema; h3 turns a thrown parse error into a 400.

```ts
// server/api/posts.post.ts
import { z } from "zod";

const CreatePost = z.object({
  title: z.string().trim().min(1).max(200),
  body: z.string().max(20_000),
});

export default defineEventHandler(async (event) => {
  const user = await requireUser(event); // your helper in server/utils
  const input = await readValidatedBody(event, CreatePost.parse);
  return createPost(user.id, input);
});
```

- Use `getValidatedQuery(event, Schema.parse)` and `getValidatedRouterParams(event, Schema.parse)` for the rest. Put shared schemas in `shared/` so forms and handlers use the same one.
- Throw `createError({ statusCode: 404, statusMessage: "Post not found" })` for expected failures; never return a 200 with an error field.
- Every handler that reads or writes private data checks the session itself. `server/middleware/` can attach the user to `event.context` on every request; route middleware in `app/middleware/` only guards navigation.
- Secrets come from `useRuntimeConfig(event)`: declare `runtimeConfig: { stripeSecret: "", public: { siteUrl: "" } }` in `nuxt.config.ts` and set them at runtime with `NUXT_STRIPE_SECRET` and `NUXT_PUBLIC_SITE_URL`. Only `public` reaches the browser. Don't read `process.env` in app code.

## Routing, SEO and errors

- Pages map to files: `app/pages/posts/[id].vue` reads `useRoute().params.id`. Set page options with `definePageMeta({ layout: "dashboard", middleware: ["auth"] })`; it is a compiler macro, so values must be static.
- Route middleware (`app/middleware/auth.ts`, `*.global.ts` for every route) is `defineNuxtRouteMiddleware((to) => { … })` that returns `navigateTo("/login")` or `abortNavigation()`. Never call `router.push` inside it.
- Link with `<NuxtLink to="/posts">`; navigate in code with `await navigateTo(…)`.
- Set titles and social tags with `useSeoMeta({ title, description, ogTitle, ogImage })`, links and scripts with `useHead`.
- Throw `createError({ statusCode: 404, statusMessage: "Not found", fatal: true })` from a page to show `app/error.vue` (on the client, only `fatal` errors take over the page). `error.vue` receives an `error` prop of type `NuxtError` and leaves with `clearError({ redirect: "/" })`. Wrap a risky widget in `<NuxtErrorBoundary>` instead of failing the page.

## shadcn-vue components

- Add components with the CLI, `npx shadcn-vue@latest add dialog`, never from memory. It writes source files into the UI folder (`ui/dialog/` with an `index.ts` and one `.vue` per part) that you own and edit.
- With the `shadcn-nuxt` module the UI components are auto-imported under their names (`<Button>`, `<DialogContent>`), with the prefix set in its `shadcn.prefix` option. Without it, import from the folder: `import { Button } from "@/components/ui/button"`.
- Variants live in the folder's `index.ts` as `cva` definitions (`buttonVariants`). Add a variant there instead of repeating long class lists. Merge classes with `cn()` from the utils alias (clsx plus tailwind-merge), passing the caller's `class` last.
- Reka UI renders the parts. `as-child` makes a part render its single child element instead of its own:

```vue
<Dialog v-model:open="open">
  <DialogTrigger as-child>
    <Button variant="outline">Edit profile</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Edit profile</DialogTitle>
      <DialogDescription>Changes save when you press Save.</DialogDescription>
    </DialogHeader>
  </DialogContent>
</Dialog>
```

- Control state with `v-model` on the root (`v-model:open`, `v-model` on Select). Every dialog has a `DialogTitle`, every input a label wired with `useId()`.
- When you wrap a Reka part yourself, copy the pattern of the generated files: forward props and emits with `useForwardPropsEmits`, and drop `class` before forwarding so `cn()` can merge it.
- Style open, checked and disabled states with the data attributes the parts set (`data-[state=open]:`, `data-disabled:`), not with extra refs.

## Tailwind CSS v4 and theming

- Tailwind runs through `@tailwindcss/vite`: `vite: { plugins: [tailwindcss()] }` in `nuxt.config.ts`, and the stylesheet (`@import "tailwindcss";`) listed in `css: ["~/assets/css/main.css"]`. No `tailwind.config.js`, no `@tailwind` directives, no `@nuxtjs/tailwindcss` module next to it.
- Colors are CSS variables in OKLCH: `:root { --background: oklch(1 0 0); }`, overridden in `.dark { … }`, mapped to utilities in `@theme inline { --color-background: var(--background); }`. Components use `bg-background`, `text-muted-foreground`, `border-input`; never raw palette colors or `hsl(var(--x))`.
- Dark mode is a class: `@custom-variant dark (&:is(.dark *));` in the stylesheet, and `@nuxtjs/color-mode` with `colorMode: { classSuffix: "" }` so it sets `.dark`. Switch with `useColorMode().preference = "dark"`. The resolved mode is unknown on the server, so render mode-dependent UI inside `<ClientOnly>`.
- An SFC `<style>` that uses `@apply` or theme variables needs `@reference` with the relative path to the main stylesheet at its top (`@reference "../assets/css/main.css";`). Prefer utilities in the template.

## If the project uses Ark UI

When `package.json` has `@ark-ui/vue` instead of `reka-ui`, the components wrap Ark parts: import them per component (`import { Dialog } from "@ark-ui/vue/dialog"`), compose `Dialog.Root`, `Dialog.Trigger`, `Dialog.Content`, use `as-child` the same way, bind `v-model:open` or `v-model` on the root, and read change details from the event payload (`@value-change="(details) => …"`). Never mix Reka and Ark parts in one component.

## Do not

- Do not write Nuxt 2 or early Nuxt 3 code: `asyncData` or `fetch()` hooks, `@nuxtjs/axios`, `process.client`, a root-level `pages/` folder in a Nuxt 4 layout.
- Do not import from `radix-vue` (renamed to `reka-ui`) or wrap destructured props in `withDefaults`.
- Do not keep request data in module-level refs, call `useFetch` from handlers, or trust route middleware to protect an API route.
- Do not add `tailwind.config.js`, `hsl(var(--x))` tokens or hard-coded colors, or install a component package instead of running the CLI.

## Before you finish

- Run `npx nuxi typecheck` (or the project's typecheck script) and the linter, and fix what they report.
- Load every page you touched with a hard refresh: data is in the server HTML, and the console shows no hydration mismatch.
- Call each new server route without a session and with bad input: expect 401 or 400, never a 500 or private data.
- Check the UI in light and dark mode, and that dialogs and menus trap focus and close with Escape.
