bookworm-smart-assistant/skills/vue-expert/references/nuxt.md

670 lines
15 KiB
Markdown
Raw Normal View History

# Nuxt 3
## Project Structure
```
my-nuxt-app/
├── app.vue # Root component (optional)
├── nuxt.config.ts # Nuxt configuration
├── package.json
├── tsconfig.json
├── .output/ # Build output
├── assets/ # Uncompiled assets (CSS, images)
├── public/ # Static files (served at root)
├── components/ # Auto-imported components
│ ├── AppHeader.vue
│ └── base/
│ └── Button.vue # Used as <BaseButton>
├── composables/ # Auto-imported composables
│ └── useAuth.ts
├── layouts/ # Layout components
│ ├── default.vue
│ └── admin.vue
├── middleware/ # Route middleware
│ └── auth.ts
├── pages/ # File-based routing
│ ├── index.vue # /
│ ├── about.vue # /about
│ ├── users/
│ │ ├── index.vue # /users
│ │ └── [id].vue # /users/:id
│ └── [...slug].vue # Catch-all route
├── plugins/ # Plugins
│ └── api.ts
├── server/ # Server API routes
│ ├── api/
│ │ └── users.ts # /api/users
│ └── middleware/
│ └── log.ts
└── stores/ # Pinia stores
└── user.ts
```
## File-based Routing
```vue
<!-- pages/index.vue -->
<script setup lang="ts">
definePageMeta({
title: 'Home',
layout: 'default'
})
</script>
<template>
<div>
<h1>Home Page</h1>
</div>
</template>
<!-- pages/about.vue -->
<template>
<div>About Page</div>
</template>
<!-- pages/users/[id].vue - Dynamic route -->
<script setup lang="ts">
const route = useRoute()
const userId = computed(() => route.params.id)
const { data: user } = await useFetch(`/api/users/${userId.value}`)
</script>
<template>
<div>
<h1>User: {{ user?.name }}</h1>
</div>
</template>
<!-- pages/blog/[...slug].vue - Catch-all route -->
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug // ['2024', '12', 'my-post']
</script>
<template>
<div>Blog post: {{ slug }}</div>
</template>
```
## Layouts
```vue
<!-- layouts/default.vue -->
<template>
<div>
<header>
<nav>Navigation</nav>
</header>
<main>
<slot /> <!-- Page content goes here -->
</main>
<footer>Footer</footer>
</div>
</template>
<!-- layouts/admin.vue -->
<script setup lang="ts">
definePageMeta({
middleware: 'auth' // Protect with middleware
})
</script>
<template>
<div class="admin-layout">
<aside>Admin Sidebar</aside>
<main>
<slot />
</main>
</div>
</template>
<!-- pages/admin/dashboard.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'admin'
})
</script>
<template>
<div>Admin Dashboard</div>
</template>
```
## Data Fetching
```vue
<script setup lang="ts">
interface User {
id: number
name: string
email: string
}
// useFetch - SSR-safe, auto-imports
const { data: users, pending, error, refresh } = await useFetch<User[]>('/api/users')
// With options
const { data } = await useFetch('/api/users', {
method: 'POST',
body: { name: 'John' },
headers: {
'Authorization': 'Bearer token'
},
query: { page: 1, limit: 10 },
// Transform response
transform: (data) => data.map(u => ({ ...u, fullName: u.firstName + ' ' + u.lastName })),
// Pick specific keys
pick: ['id', 'name'],
// Watch for changes
watch: [page, limit]
})
// useAsyncData - More control
const { data: user } = await useAsyncData(
'user-123', // Unique key for caching
async () => {
const response = await fetch('/api/users/123')
return response.json()
},
{
server: true, // Fetch on server
lazy: false, // Don't block navigation
default: () => null // Default value while loading
}
)
// useLazyFetch - Non-blocking
const { data: posts } = await useLazyFetch('/api/posts')
// useLazyAsyncData - Non-blocking with custom fetcher
const { data: comments } = await useLazyAsyncData('comments', () =>
$fetch('/api/comments')
)
// Manual refresh
function handleRefresh() {
refresh() // Re-fetch data
}
</script>
<template>
<div>
<div v-if="pending">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<div v-else>
<div v-for="user in users" :key="user.id">
{{ user.name }}
</div>
<button @click="handleRefresh">Refresh</button>
</div>
</div>
</template>
```
## Server API Routes
```typescript
// server/api/users.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Number(query.page) || 1
const limit = Number(query.limit) || 10
// Fetch from database
const users = await prisma.user.findMany({
skip: (page - 1) * limit,
take: limit
})
return users
})
// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const user = await prisma.user.findUnique({
where: { id: Number(id) }
})
if (!user) {
throw createError({
statusCode: 404,
message: 'User not found'
})
}
return user
})
// server/api/users.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Validate
if (!body.email || !body.name) {
throw createError({
statusCode: 400,
message: 'Email and name are required'
})
}
const user = await prisma.user.create({
data: {
email: body.email,
name: body.name
}
})
return user
})
// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
const { email, password } = await readBody(event)
// Verify credentials
const user = await verifyCredentials(email, password)
if (!user) {
throw createError({
statusCode: 401,
message: 'Invalid credentials'
})
}
// Set session cookie
setCookie(event, 'session', user.sessionToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7 // 7 days
})
return { success: true, user }
})
```
## Middleware
```typescript
// middleware/auth.ts - Route middleware
export default defineNuxtRouteMiddleware((to, from) => {
const { isLoggedIn } = useAuthStore()
if (!isLoggedIn) {
return navigateTo('/login')
}
})
// middleware/logger.global.ts - Global middleware
export default defineNuxtRouteMiddleware((to, from) => {
console.log(`Navigating from ${from.path} to ${to.path}`)
})
// server/middleware/log.ts - Server middleware
export default defineEventHandler((event) => {
console.log(`[${event.method}] ${event.path}`)
})
```
## Composables
```typescript
// composables/useAuth.ts - Auto-imported
export const useAuth = () => {
const user = useState<User | null>('user', () => null)
const isLoggedIn = computed(() => user.value !== null)
async function login(email: string, password: string) {
const { data, error } = await useFetch('/api/auth/login', {
method: 'POST',
body: { email, password }
})
if (data.value) {
user.value = data.value.user
}
return { data, error }
}
async function logout() {
await useFetch('/api/auth/logout', { method: 'POST' })
user.value = null
navigateTo('/login')
}
async function fetchUser() {
const { data } = await useFetch('/api/auth/me')
user.value = data.value
}
return {
user,
isLoggedIn,
login,
logout,
fetchUser
}
}
// Usage in component (auto-imported)
<script setup lang="ts">
const { user, isLoggedIn, login, logout } = useAuth()
</script>
```
## Plugins
```typescript
// plugins/api.ts
export default defineNuxtPlugin((nuxtApp) => {
const api = $fetch.create({
baseURL: '/api',
onRequest({ options }) {
// Add auth token
const token = useCookie('token')
if (token.value) {
options.headers = options.headers || {}
options.headers.Authorization = `Bearer ${token.value}`
}
},
onResponseError({ response }) {
if (response.status === 401) {
navigateTo('/login')
}
}
})
return {
provide: {
api
}
}
})
// Usage in component
<script setup lang="ts">
const { $api } = useNuxtApp()
const users = await $api('/users')
</script>
```
## Configuration
```typescript
// nuxt.config.ts
export default defineNuxtConfig({
devtools: { enabled: true },
modules: [
'@pinia/nuxt',
'@nuxtjs/tailwindcss',
'@vueuse/nuxt'
],
runtimeConfig: {
// Server-only (never exposed to client)
apiSecret: process.env.API_SECRET,
// Exposed to client
public: {
apiBase: process.env.API_BASE || '/api'
}
},
app: {
head: {
title: 'My App',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: 'My amazing site' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
}
},
css: ['~/assets/css/main.css'],
typescript: {
strict: true,
typeCheck: true
},
// Vite is the default bundler in Nuxt 3
// Note: webpack is deprecated - use Vite for all new projects
vite: {
optimizeDeps: {
include: ['vue', 'vue-router', 'pinia']
},
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor': ['vue', 'pinia']
}
}
}
}
},
nitro: {
preset: 'vercel' // or 'node-server', 'cloudflare', 'bun', etc.
}
})
```
## SEO and Meta Tags
```vue
<script setup lang="ts">
const route = useRoute()
const title = computed(() => `User ${route.params.id}`)
useHead({
title,
meta: [
{ name: 'description', content: 'User profile page' },
{ property: 'og:title', content: title },
{ property: 'og:description', content: 'User profile' }
]
})
// Or use useSeoMeta
useSeoMeta({
title: 'My Page',
ogTitle: 'My Page',
description: 'Page description',
ogDescription: 'Page description',
ogImage: 'https://example.com/image.png'
})
</script>
```
## Custom SSR with Fastify (Non-Nuxt)
For custom Vue 3 SSR without Nuxt, using Fastify as the server:
```typescript
// server.ts
import Fastify from 'fastify'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import App from './App.vue'
const fastify = Fastify({ logger: true })
fastify.get('*', async (request, reply) => {
const app = createSSRApp(App)
// Server-side data fetching
const initialState = await fetchInitialData(request.url)
const html = await renderToString(app)
reply.type('text/html').send(`
<!DOCTYPE html>
<html>
<head>
<title>Vue SSR</title>
<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>
</head>
<body>
<div id="app">${html}</div>
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>
`)
})
fastify.listen({ port: 3000 })
```
```typescript
// entry-client.ts
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// Hydrate with server state
if (window.__INITIAL_STATE__) {
app.provide('initialState', window.__INITIAL_STATE__)
}
app.mount('#app')
```
```typescript
// vite.config.ts for SSR
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
ssr: true,
rollupOptions: {
input: {
server: './server.ts',
client: './src/entry-client.ts'
}
}
}
})
```
## Hydration Patterns
### Lazy Hydration with ClientOnly
```vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
// Heavy component loaded only on client
const HeavyChart = defineAsyncComponent(() =>
import('./components/HeavyChart.vue')
)
</script>
<template>
<ClientOnly>
<HeavyChart />
<template #fallback>
<div class="chart-skeleton">Loading chart...</div>
</template>
</ClientOnly>
</template>
```
### Hydration Mismatch Prevention
```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
// Avoid hydration mismatch for client-only values
const currentTime = ref<string | null>(null)
const windowWidth = ref<number | null>(null)
onMounted(() => {
// These values differ between server and client
currentTime.value = new Date().toLocaleTimeString()
windowWidth.value = window.innerWidth
})
</script>
<template>
<div>
<!-- Use v-if to prevent mismatch -->
<span v-if="currentTime">{{ currentTime }}</span>
<span v-else>--:--:--</span>
<!-- Or use ClientOnly -->
<ClientOnly>
<span>Width: {{ windowWidth }}px</span>
</ClientOnly>
</div>
</template>
```
### Progressive Hydration
```vue
<script setup lang="ts">
// Use nuxt-delay-hydration for non-critical content
definePageMeta({
// Delay hydration until visible or idle
hydration: 'when-visible' // or 'on-idle'
})
</script>
<template>
<div>
<!-- Critical content hydrates immediately -->
<header>Navigation</header>
<!-- Non-critical content can wait -->
<LazyBelowFoldContent />
</div>
</template>
```
```typescript
// nuxt.config.ts - Configure delay hydration
export default defineNuxtConfig({
modules: ['nuxt-delay-hydration'],
delayHydration: {
mode: 'init', // or 'mount'
debug: process.env.NODE_ENV === 'development'
}
})
```
## Quick Reference
| Pattern | Use Case |
|---------|----------|
| `useFetch()` | Fetch data (SSR-safe) |
| `useAsyncData()` | Custom async operations |
| `useLazyFetch()` | Non-blocking fetch |
| `useState()` | Shared state across components |
| `useRoute()` | Access route params/query |
| `useRouter()` | Navigate programmatically |
| `navigateTo()` | Navigate to route |
| `definePageMeta()` | Page-level metadata |
| `useHead()` | Dynamic meta tags |
| Server routes | `/server/api/*.ts` |
| Auto-imports | Components, composables, utils |
| `<ClientOnly>` | Client-only rendering, prevent hydration mismatch |
| `renderToString()` | Custom SSR with Fastify/Express |
| `vite: {}` | Vite configuration in nuxt.config.ts |
| `nuxt-delay-hydration` | Progressive hydration for performance |