Vue 3 + Nuxt 3
Project context
This is a Nuxt 3 project using Vue 3 with the Composition API and <script setup>. We rely on Nuxt's auto-imports for ref, computed, useFetch, etc. State management with Pinia.
Stack
- Nuxt 3+
- Vue 3 (Composition API +
<script setup>only — no Options API) - TypeScript strict mode
- Tailwind CSS 4 via
@nuxtjs/tailwindcss - Pinia for global state
- VueUse for utility composables
- Vitest + Vue Test Utils
- pnpm
Folder structure (Nuxt conventions)
.
├── app.vue — root component (replaces _app.tsx)
├── nuxt.config.ts
├── pages/ — file-based routes
│ ├── index.vue
│ ├── blog/[slug].vue
├── components/ — auto-imported when used in templates
├── composables/ — auto-imported (useFoo)
├── server/
│ ├── api/
│ │ ├── posts.get.ts — GET /api/posts
│ │ └── posts.post.ts — POST /api/posts
│ └── routes/ — non-/api routes
├── stores/ — Pinia stores, auto-imported (defineStore)
├── middleware/ — route middleware
├── layouts/
│ └── default.vue
├── plugins/ — runtime plugins
├── public/ — served as-is
├── assets/ — processed by Vite
└── types/
Auto-imports (don't fight them)
Nuxt auto-imports ref, computed, watch, useFetch, useAsyncData, useRoute, useRouter, useState, useNuxtApp, all components in components/, all composables in composables/, all stores in stores/. Never manually import these.
<script setup> everywhere
<script setup lang="ts">
const props = defineProps<{ title: string; count?: number }>()
const emit = defineEmits<{ change: [value: number] }>()
const local = ref(props.count ?? 0)
const doubled = computed(() => local.value * 2)
</script>
<template>
<div>
<h2>{{ title }}</h2>
<button @click="local++">Count: {{ local }} (×2 = {{ doubled }})</button>
</div>
</template>
- Always
lang="ts" - Type props with
defineProps<{...}>()— not the runtime declaration - Type emits with
defineEmits<{...}>() - Refs:
ref()for primitives,reactive()for plain objects (and.valueon refs in script, no.valuein template)
Data fetching
useFetch(url)— for HTTP fetches (Nuxt-aware caching, dedupes between server/client)useAsyncData('key', () => fetchSomething())— for arbitrary async logic$fetch(url)— imperative fetch (use inside event handlers, not in setup)- Never raw
fetch()—useFetchand$fetchintegrate with Nuxt's payload hydration
<script setup lang="ts">
const { data: posts, pending, error } = await useFetch('/api/posts')
</script>
awaituseFetchonly inside<script setup>(not insideif/ loops); for conditionals useuseLazyFetchoruseAsyncData
Server routes (server/api/)
- File-based:
server/api/posts.get.ts→GET /api/posts - Use
defineEventHandlerandgetQuery,readBody,createErrorfrom h3 (auto-imported) - Validate inputs with Zod / valibot at the boundary
- Use
setResponseStatus(event, 201)for non-200 success
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, schema.parse)
const post = await db.posts.create({ data: body })
setResponseStatus(event, 201)
return post
})
Pinia stores
// stores/posts.ts
export const usePostsStore = defineStore('posts', () => {
const items = ref<Post[]>([])
const fetched = ref(false)
async function fetchAll() {
if (fetched.value) return
items.value = await $fetch('/api/posts')
fetched.value = true
}
return { items, fetched, fetchAll }
})
- Setup-style stores (composition API) — not options-style
- One store per resource / domain
- Don't put one-off ephemeral state in stores; use
useState(Nuxt's SSR-aware shared state)
Patterns to follow
- Use Nuxt layouts (
layouts/default.vue) — don't roll your own - Use
definePageMeta({ middleware: 'auth' })for route guards - Use
useHead({ title, meta })for SEO instead of manual<head>manipulation - For SSR-bound shared state,
useState('key', () => initial)— survives hydration
Patterns to avoid
- Options API — composition only
- Manual
import { ref } from 'vue'— Nuxt auto-imports fetch()directly — useuseFetch/$fetch- Mutation in computed —
computedis read-only by default - Calling
$fetchinsetup()top-level for SSR data — useuseFetch - Vuex — use Pinia
Testing
- Vitest +
@vue/test-utils - Use
mountfor components; assert on rendered DOM - For composables, test in isolation — they're plain functions
Tooling
pnpm dev— dev server with HMRpnpm build— production buildpnpm preview— preview built outputpnpm test— Vitestpnpm typecheck—nuxt typecheck
AI behavioral rules
- Use
<script setup lang="ts">only — never Options API, never plain<script> - Type props/emits via the type-only
defineProps<{...}>()/defineEmits<{...}>() - For data fetching in pages, use
useFetchoruseAsyncData— never rawfetch - Don't manually import auto-imported APIs (
ref,computed,useFetch) - Server routes go under
server/api/with file-name HTTP method suffix - Use Pinia setup-stores; never Vuex
- Run
nuxt typecheckandvitestbefore declaring a task done