Initial implementation of the project structure and basic functionality.
This commit is contained in:
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx:*)",
|
||||
"mcp__nuxt-remote__get-documentation-page",
|
||||
"Bash(python3 -c \"import sys,json; data=json.load\\(sys.stdin\\); text=data[0][''''text'''']; lines=text.split\\(''''\\\\n''''\\); [print\\(l\\) for l in lines[:200]]\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
5
.claude/skills/nuxt/GENERATION.md
Normal file
5
.claude/skills/nuxt/GENERATION.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Generation Info
|
||||
|
||||
- **Source:** `sources/nuxt`
|
||||
- **Git SHA:** `c9fed804b9bef362276033b03ca43730c6efa7dc`
|
||||
- **Generated:** 2026-01-28
|
||||
55
.claude/skills/nuxt/SKILL.md
Normal file
55
.claude/skills/nuxt/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: nuxt
|
||||
description: Nuxt 4 full-stack Vue framework with SSR, auto-imports, and file-based routing. Use when working with Nuxt apps, server routes, useFetch, middleware, or hybrid rendering.
|
||||
metadata:
|
||||
author: Anthony Fu
|
||||
version: "2026.1.28"
|
||||
source: Generated from https://github.com/nuxt/nuxt, scripts located at https://github.com/antfu/skills
|
||||
---
|
||||
|
||||
Nuxt is a full-stack Vue framework that provides server-side rendering, file-based routing, auto-imports, and a powerful module system. It uses Nitro as its server engine for universal deployment across Node.js, serverless, and edge platforms.
|
||||
|
||||
> The skill is based on Nuxt 4.x, updated at 2026-03-26.
|
||||
|
||||
## Core
|
||||
|
||||
| Topic | Description | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| Directory Structure | Project folder structure, conventions, file organization | [core-directory-structure](references/core-directory-structure.md) |
|
||||
| Configuration | nuxt.config.ts, app.config.ts, runtime config, environment variables | [core-config](references/core-config.md) |
|
||||
| CLI Commands | Dev server, build, generate, preview, and utility commands | [core-cli](references/core-cli.md) |
|
||||
| Routing | File-based routing, dynamic routes, navigation, middleware, layouts | [core-routing](references/core-routing.md) |
|
||||
| Data Fetching | useFetch, useAsyncData, $fetch, caching, refresh | [core-data-fetching](references/core-data-fetching.md) |
|
||||
| Modules | Creating and using Nuxt modules, Nuxt Kit utilities | [core-modules](references/core-modules.md) |
|
||||
| Deployment | Platform-agnostic deployment with Nitro, Vercel, Netlify, Cloudflare | [core-deployment](references/core-deployment.md) |
|
||||
|
||||
## Features
|
||||
|
||||
| Topic | Description | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| Composables Auto-imports | Vue APIs, Nuxt composables, custom composables, utilities | [features-composables](references/features-composables.md) |
|
||||
| Components Auto-imports | Component naming, lazy loading, hydration strategies | [features-components-autoimport](references/features-components-autoimport.md) |
|
||||
| Built-in Components | NuxtLink, NuxtPage, NuxtLayout, ClientOnly, and more | [features-components](references/features-components.md) |
|
||||
| State Management | useState composable, SSR-friendly state, Pinia integration | [features-state](references/features-state.md) |
|
||||
| Server Routes | API routes, server middleware, Nitro server engine | [features-server](references/features-server.md) |
|
||||
|
||||
## Rendering
|
||||
|
||||
| Topic | Description | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| Rendering Modes | Universal (SSR), client-side (SPA), hybrid rendering, route rules | [rendering-modes](references/rendering-modes.md) |
|
||||
|
||||
## Best Practices
|
||||
|
||||
| Topic | Description | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| Data Fetching Patterns | Efficient fetching, caching, parallel requests, error handling | [best-practices-data-fetching](references/best-practices-data-fetching.md) |
|
||||
| SSR & Hydration | Avoiding context leaks, hydration mismatches, composable patterns | [best-practices-ssr](references/best-practices-ssr.md) |
|
||||
|
||||
## Advanced
|
||||
|
||||
| Topic | Description | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| Layers | Extending applications with reusable layers | [advanced-layers](references/advanced-layers.md) |
|
||||
| Lifecycle Hooks | Build-time, runtime, and server hooks | [advanced-hooks](references/advanced-hooks.md) |
|
||||
| Module Authoring | Creating publishable Nuxt modules with Nuxt Kit | [advanced-module-authoring](references/advanced-module-authoring.md) |
|
||||
289
.claude/skills/nuxt/references/advanced-hooks.md
Normal file
289
.claude/skills/nuxt/references/advanced-hooks.md
Normal file
@@ -0,0 +1,289 @@
|
||||
---
|
||||
name: lifecycle-hooks
|
||||
description: Nuxt and Nitro hooks for extending build-time and runtime behavior
|
||||
---
|
||||
|
||||
# Lifecycle Hooks
|
||||
|
||||
Nuxt provides hooks to tap into the build process, application lifecycle, and server runtime.
|
||||
|
||||
## Build-time Hooks (Nuxt)
|
||||
|
||||
Used in `nuxt.config.ts` or modules:
|
||||
|
||||
### In nuxt.config.ts
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
hooks: {
|
||||
'build:before': () => {
|
||||
console.log('Build starting...')
|
||||
},
|
||||
'pages:extend': (pages) => {
|
||||
// Add custom pages
|
||||
pages.push({
|
||||
name: 'custom',
|
||||
path: '/custom',
|
||||
file: '~/pages/custom.vue',
|
||||
})
|
||||
},
|
||||
'components:dirs': (dirs) => {
|
||||
// Add component directories
|
||||
dirs.push({ path: '~/extra-components' })
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### In Modules
|
||||
|
||||
```ts
|
||||
// modules/my-module.ts
|
||||
export default defineNuxtModule({
|
||||
setup(options, nuxt) {
|
||||
nuxt.hook('ready', async (nuxt) => {
|
||||
console.log('Nuxt is ready')
|
||||
})
|
||||
|
||||
nuxt.hook('close', async (nuxt) => {
|
||||
console.log('Nuxt is closing')
|
||||
})
|
||||
|
||||
nuxt.hook('modules:done', () => {
|
||||
console.log('All modules loaded')
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Common Build Hooks
|
||||
|
||||
| Hook | When |
|
||||
|------|------|
|
||||
| `ready` | Nuxt initialization complete |
|
||||
| `close` | Nuxt is closing |
|
||||
| `modules:done` | All modules installed |
|
||||
| `build:before` | Before build starts |
|
||||
| `build:done` | Build complete |
|
||||
| `pages:extend` | Pages routes resolved |
|
||||
| `components:dirs` | Component dirs being resolved |
|
||||
| `imports:extend` | Auto-imports being resolved |
|
||||
| `nitro:config` | Before Nitro config finalized |
|
||||
| `vite:extend` | Vite context created |
|
||||
| `vite:extendConfig` | Before Vite config finalized |
|
||||
|
||||
## App Hooks (Runtime)
|
||||
|
||||
Used in plugins and composables:
|
||||
|
||||
### In Plugins
|
||||
|
||||
```ts
|
||||
// plugins/lifecycle.ts
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.hook('app:created', (vueApp) => {
|
||||
console.log('Vue app created')
|
||||
})
|
||||
|
||||
nuxtApp.hook('app:mounted', (vueApp) => {
|
||||
console.log('App mounted')
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:start', () => {
|
||||
console.log('Page navigation starting')
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
console.log('Page navigation finished')
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:loading:start', () => {
|
||||
console.log('Page loading started')
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:loading:end', () => {
|
||||
console.log('Page loading ended')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Common App Hooks
|
||||
|
||||
| Hook | When |
|
||||
|------|------|
|
||||
| `app:created` | Vue app created |
|
||||
| `app:mounted` | Vue app mounted (client only) |
|
||||
| `app:error` | Fatal error occurred |
|
||||
| `page:start` | Page navigation starting |
|
||||
| `page:finish` | Page navigation finished |
|
||||
| `page:loading:start` | Loading indicator should show |
|
||||
| `page:loading:end` | Loading indicator should hide |
|
||||
| `link:prefetch` | Link is being prefetched |
|
||||
|
||||
### Using Runtime Hooks
|
||||
|
||||
```ts
|
||||
// composables/usePageTracking.ts
|
||||
export function usePageTracking() {
|
||||
const nuxtApp = useNuxtApp()
|
||||
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
trackPageView(useRoute().path)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Server Hooks (Nitro)
|
||||
|
||||
Used in server plugins:
|
||||
|
||||
```ts
|
||||
// server/plugins/hooks.ts
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
// Modify HTML before sending
|
||||
nitroApp.hooks.hook('render:html', (html, { event }) => {
|
||||
html.head.push('<meta name="custom" content="value">')
|
||||
html.bodyAppend.push('<script>console.log("injected")</script>')
|
||||
})
|
||||
|
||||
// Modify response
|
||||
nitroApp.hooks.hook('render:response', (response, { event }) => {
|
||||
console.log('Sending response:', response.statusCode)
|
||||
})
|
||||
|
||||
// Before request
|
||||
nitroApp.hooks.hook('request', (event) => {
|
||||
console.log('Request:', event.path)
|
||||
})
|
||||
|
||||
// After response
|
||||
nitroApp.hooks.hook('afterResponse', (event) => {
|
||||
console.log('Response sent')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Common Nitro Hooks
|
||||
|
||||
| Hook | When |
|
||||
|------|------|
|
||||
| `request` | Request received |
|
||||
| `beforeResponse` | Before sending response |
|
||||
| `afterResponse` | After response sent |
|
||||
| `render:html` | Before HTML is sent |
|
||||
| `render:response` | Before response is finalized |
|
||||
| `error` | Error occurred |
|
||||
|
||||
## Custom Hooks
|
||||
|
||||
### Define Custom Hook Types
|
||||
|
||||
```ts
|
||||
// types/hooks.d.ts
|
||||
import type { HookResult } from '@nuxt/schema'
|
||||
|
||||
declare module '#app' {
|
||||
interface RuntimeNuxtHooks {
|
||||
'my-app:event': (data: MyEventData) => HookResult
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@nuxt/schema' {
|
||||
interface NuxtHooks {
|
||||
'my-module:init': () => HookResult
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'nitropack/types' {
|
||||
interface NitroRuntimeHooks {
|
||||
'my-server:event': (data: any) => void
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Call Custom Hooks
|
||||
|
||||
```ts
|
||||
// In a plugin
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
// Call custom hook
|
||||
nuxtApp.callHook('my-app:event', { type: 'custom' })
|
||||
})
|
||||
|
||||
// In a module
|
||||
export default defineNuxtModule({
|
||||
setup(options, nuxt) {
|
||||
nuxt.callHook('my-module:init')
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## useRuntimeHook
|
||||
|
||||
Call hooks at runtime from components:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Register a callback for a runtime hook
|
||||
useRuntimeHook('app:error', (error) => {
|
||||
console.error('App error:', error)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Hook Examples
|
||||
|
||||
### Page View Tracking
|
||||
|
||||
```ts
|
||||
// plugins/analytics.client.ts
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
const route = useRoute()
|
||||
analytics.track('pageview', {
|
||||
path: route.path,
|
||||
title: document.title,
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Performance Monitoring
|
||||
|
||||
```ts
|
||||
// plugins/performance.client.ts
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
let navigationStart: number
|
||||
|
||||
nuxtApp.hook('page:start', () => {
|
||||
navigationStart = performance.now()
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
const duration = performance.now() - navigationStart
|
||||
console.log(`Navigation took ${duration}ms`)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Inject HTML
|
||||
|
||||
```ts
|
||||
// server/plugins/inject.ts
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('render:html', (html) => {
|
||||
html.head.push(`
|
||||
<script>
|
||||
window.APP_CONFIG = ${JSON.stringify(config)}
|
||||
</script>
|
||||
`)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/guide/going-further/hooks
|
||||
- https://nuxt.com/docs/api/advanced/hooks
|
||||
-->
|
||||
299
.claude/skills/nuxt/references/advanced-layers.md
Normal file
299
.claude/skills/nuxt/references/advanced-layers.md
Normal file
@@ -0,0 +1,299 @@
|
||||
---
|
||||
name: nuxt-layers
|
||||
description: Extending Nuxt applications with layers for code sharing and reusability
|
||||
---
|
||||
|
||||
# Nuxt Layers
|
||||
|
||||
Layers allow sharing and reusing partial Nuxt applications across projects. They can include components, composables, pages, layouts, and configuration.
|
||||
|
||||
## Using Layers
|
||||
|
||||
### From npm Package
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
extends: [
|
||||
'@my-org/base-layer',
|
||||
'@nuxtjs/ui-layer',
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### From Git Repository
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
extends: [
|
||||
'github:username/repo',
|
||||
'github:username/repo/base', // Subdirectory
|
||||
'github:username/repo#v1.0', // Specific tag
|
||||
'github:username/repo#dev', // Branch
|
||||
'gitlab:username/repo',
|
||||
'bitbucket:username/repo',
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### From Local Directory
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
extends: [
|
||||
'../base-layer',
|
||||
'./layers/shared',
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Auto-scanned Layers
|
||||
|
||||
Place in `layers/` directory for automatic discovery:
|
||||
|
||||
```
|
||||
my-app/
|
||||
├── layers/
|
||||
│ ├── base/
|
||||
│ │ └── nuxt.config.ts
|
||||
│ └── ui/
|
||||
│ └── nuxt.config.ts
|
||||
└── nuxt.config.ts
|
||||
```
|
||||
|
||||
## Creating a Layer
|
||||
|
||||
Minimal layer structure:
|
||||
|
||||
```
|
||||
my-layer/
|
||||
├── nuxt.config.ts # Required
|
||||
├── app/
|
||||
│ ├── components/ # Auto-merged
|
||||
│ ├── composables/ # Auto-merged
|
||||
│ ├── layouts/ # Auto-merged
|
||||
│ ├── middleware/ # Auto-merged
|
||||
│ ├── pages/ # Auto-merged
|
||||
│ ├── plugins/ # Auto-merged
|
||||
│ └── app.config.ts # Merged
|
||||
├── server/ # Auto-merged
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### Layer nuxt.config.ts
|
||||
|
||||
```ts
|
||||
// my-layer/nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
// Layer configuration
|
||||
app: {
|
||||
head: {
|
||||
title: 'My Layer App',
|
||||
},
|
||||
},
|
||||
// Shared modules
|
||||
modules: ['@nuxt/ui'],
|
||||
})
|
||||
```
|
||||
|
||||
### Layer Components
|
||||
|
||||
```vue
|
||||
<!-- my-layer/app/components/BaseButton.vue -->
|
||||
<template>
|
||||
<button class="base-btn">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
Use in consuming project:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BaseButton>Click me</BaseButton>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Layer Composables
|
||||
|
||||
```ts
|
||||
// my-layer/app/composables/useTheme.ts
|
||||
export function useTheme() {
|
||||
const isDark = useState('theme-dark', () => false)
|
||||
const toggle = () => isDark.value = !isDark.value
|
||||
return { isDark, toggle }
|
||||
}
|
||||
```
|
||||
|
||||
## Layer Priority
|
||||
|
||||
Override order (highest to lowest):
|
||||
1. Your project files
|
||||
2. Auto-scanned layers (alphabetically, Z > A)
|
||||
3. `extends` array (first > last)
|
||||
|
||||
Control order with prefixes:
|
||||
|
||||
```
|
||||
layers/
|
||||
├── 1.base/ # Lower priority
|
||||
└── 2.theme/ # Higher priority
|
||||
```
|
||||
|
||||
## Layer Aliases
|
||||
|
||||
Access layer files:
|
||||
|
||||
```ts
|
||||
// Auto-scanned layers get aliases
|
||||
import Component from '#layers/base/components/Component.vue'
|
||||
```
|
||||
|
||||
Named aliases:
|
||||
|
||||
```ts
|
||||
// my-layer/nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
$meta: {
|
||||
name: 'my-layer',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
// In consuming project
|
||||
import { something } from '#layers/my-layer/utils'
|
||||
```
|
||||
|
||||
## Publishing Layers
|
||||
|
||||
### As npm Package
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-nuxt-layer",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nuxt": "^3.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Private Layers
|
||||
|
||||
For private git repos:
|
||||
|
||||
```bash
|
||||
export GIGET_AUTH=<github-token>
|
||||
```
|
||||
|
||||
## Layer Best Practices
|
||||
|
||||
### Use Resolved Paths
|
||||
|
||||
```ts
|
||||
// my-layer/nuxt.config.ts
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineNuxtConfig({
|
||||
css: [
|
||||
join(currentDir, './assets/main.css'),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
extends: [
|
||||
['github:user/layer', { install: true }],
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Disable Layer Modules
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
extends: ['./base-layer'],
|
||||
// Disable modules from layer
|
||||
image: false, // Disables @nuxt/image
|
||||
pinia: false, // Disables @pinia/nuxt
|
||||
})
|
||||
```
|
||||
|
||||
## Starter Template
|
||||
|
||||
Create a new layer:
|
||||
|
||||
```bash
|
||||
npx nuxi init --template layer my-layer
|
||||
```
|
||||
|
||||
## Example: Theme Layer
|
||||
|
||||
```
|
||||
theme-layer/
|
||||
├── nuxt.config.ts
|
||||
├── app/
|
||||
│ ├── app.config.ts
|
||||
│ ├── components/
|
||||
│ │ ├── ThemeButton.vue
|
||||
│ │ └── ThemeCard.vue
|
||||
│ ├── composables/
|
||||
│ │ └── useTheme.ts
|
||||
│ └── assets/
|
||||
│ └── theme.css
|
||||
└── package.json
|
||||
```
|
||||
|
||||
```ts
|
||||
// theme-layer/nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
css: ['~/assets/theme.css'],
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
// theme-layer/app/app.config.ts
|
||||
export default defineAppConfig({
|
||||
theme: {
|
||||
primaryColor: '#00dc82',
|
||||
darkMode: false,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
// consuming-app/nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
extends: ['theme-layer'],
|
||||
})
|
||||
|
||||
// consuming-app/app/app.config.ts
|
||||
export default defineAppConfig({
|
||||
theme: {
|
||||
primaryColor: '#ff0000', // Override
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/getting-started/layers
|
||||
- https://nuxt.com/docs/guide/going-further/layers
|
||||
-->
|
||||
554
.claude/skills/nuxt/references/advanced-module-authoring.md
Normal file
554
.claude/skills/nuxt/references/advanced-module-authoring.md
Normal file
@@ -0,0 +1,554 @@
|
||||
---
|
||||
name: module-authoring
|
||||
description: Complete guide to creating publishable Nuxt modules with best practices
|
||||
---
|
||||
|
||||
# Module Authoring
|
||||
|
||||
This guide covers creating publishable Nuxt modules with proper structure, type safety, and best practices.
|
||||
|
||||
## Module Structure
|
||||
|
||||
Recommended structure for a publishable module:
|
||||
|
||||
```
|
||||
my-nuxt-module/
|
||||
├── src/
|
||||
│ ├── module.ts # Module entry
|
||||
│ └── runtime/
|
||||
│ ├── components/ # Vue components
|
||||
│ ├── composables/ # Composables
|
||||
│ ├── plugins/ # Nuxt plugins
|
||||
│ └── server/ # Server handlers
|
||||
├── playground/ # Development app
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Module Definition
|
||||
|
||||
### Basic Module with Type-safe Options
|
||||
|
||||
```ts
|
||||
// src/module.ts
|
||||
import { defineNuxtModule, createResolver, addPlugin, addComponent, addImports } from '@nuxt/kit'
|
||||
|
||||
export interface ModuleOptions {
|
||||
prefix?: string
|
||||
apiKey: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export default defineNuxtModule<ModuleOptions>({
|
||||
meta: {
|
||||
name: 'my-module',
|
||||
configKey: 'myModule',
|
||||
compatibility: {
|
||||
nuxt: '>=3.0.0',
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
prefix: 'My',
|
||||
enabled: true,
|
||||
},
|
||||
setup(options, nuxt) {
|
||||
if (!options.enabled) return
|
||||
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
// Module setup logic here
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Using `.with()` for Strict Type Inference
|
||||
|
||||
When you need TypeScript to infer that default values are always present:
|
||||
|
||||
```ts
|
||||
import { defineNuxtModule } from '@nuxt/kit'
|
||||
|
||||
interface ModuleOptions {
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export default defineNuxtModule<ModuleOptions>().with({
|
||||
meta: {
|
||||
name: '@nuxtjs/my-api',
|
||||
configKey: 'myApi',
|
||||
},
|
||||
defaults: {
|
||||
baseURL: 'https://api.example.com',
|
||||
timeout: 5000,
|
||||
},
|
||||
setup(resolvedOptions, nuxt) {
|
||||
// resolvedOptions.baseURL is guaranteed to be string (not undefined)
|
||||
// resolvedOptions.timeout is guaranteed to be number (not undefined)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Adding Runtime Assets
|
||||
|
||||
### Components
|
||||
|
||||
```ts
|
||||
import { addComponent, addComponentsDir, createResolver } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup() {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
// Single component
|
||||
addComponent({
|
||||
name: 'MyButton',
|
||||
filePath: resolve('./runtime/components/MyButton.vue'),
|
||||
})
|
||||
|
||||
// Component directory with prefix
|
||||
addComponentsDir({
|
||||
path: resolve('./runtime/components'),
|
||||
prefix: 'My',
|
||||
pathPrefix: false,
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Composables and Auto-imports
|
||||
|
||||
```ts
|
||||
import { addImports, addImportsDir, createResolver } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup() {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
// Single import
|
||||
addImports({
|
||||
name: 'useMyUtil',
|
||||
from: resolve('./runtime/composables/useMyUtil'),
|
||||
})
|
||||
|
||||
// Directory of composables
|
||||
addImportsDir(resolve('./runtime/composables'))
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Plugins
|
||||
|
||||
```ts
|
||||
import { addPlugin, addPluginTemplate, createResolver } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup(options) {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
// Static plugin file
|
||||
addPlugin({
|
||||
src: resolve('./runtime/plugins/myPlugin'),
|
||||
mode: 'client', // 'client', 'server', or 'all'
|
||||
})
|
||||
|
||||
// Dynamic plugin with generated code
|
||||
addPluginTemplate({
|
||||
filename: 'my-module-plugin.mjs',
|
||||
getContents: () => `
|
||||
import { defineNuxtPlugin } from '#app/nuxt'
|
||||
|
||||
export default defineNuxtPlugin({
|
||||
name: 'my-module',
|
||||
setup() {
|
||||
const config = ${JSON.stringify(options)}
|
||||
// Plugin logic
|
||||
}
|
||||
})`,
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Server Extensions
|
||||
|
||||
### Server Handlers
|
||||
|
||||
```ts
|
||||
import { addServerHandler, addServerScanDir, createResolver } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup() {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
// Single handler
|
||||
addServerHandler({
|
||||
route: '/api/my-endpoint',
|
||||
handler: resolve('./runtime/server/api/my-endpoint'),
|
||||
})
|
||||
|
||||
// Scan entire server directory (api/, routes/, middleware/, utils/)
|
||||
addServerScanDir(resolve('./runtime/server'))
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Server Composables
|
||||
|
||||
```ts
|
||||
import { addServerImports, addServerImportsDir, createResolver } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup() {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
// Single server import
|
||||
addServerImports({
|
||||
name: 'useServerUtil',
|
||||
from: resolve('./runtime/server/utils/useServerUtil'),
|
||||
})
|
||||
|
||||
// Server composables directory
|
||||
addServerImportsDir(resolve('./runtime/server/composables'))
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Nitro Plugin
|
||||
|
||||
```ts
|
||||
import { addServerPlugin, createResolver } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup() {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
addServerPlugin(resolve('./runtime/server/plugin'))
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
// runtime/server/plugin.ts
|
||||
import { defineNitroPlugin } from 'nitropack/runtime'
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('request', (event) => {
|
||||
console.log('Request:', event.path)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Templates and Virtual Files
|
||||
|
||||
### Generate Virtual Files
|
||||
|
||||
```ts
|
||||
import { addTemplate, addTypeTemplate, addServerTemplate, createResolver } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup(options, nuxt) {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
// Client/build virtual file (accessible via #build/my-config.mjs)
|
||||
addTemplate({
|
||||
filename: 'my-config.mjs',
|
||||
getContents: () => `export default ${JSON.stringify(options)}`,
|
||||
})
|
||||
|
||||
// Type declarations
|
||||
addTypeTemplate({
|
||||
filename: 'types/my-module.d.ts',
|
||||
getContents: () => `
|
||||
declare module '#my-module' {
|
||||
export interface Config {
|
||||
apiKey: string
|
||||
}
|
||||
}`,
|
||||
})
|
||||
|
||||
// Nitro virtual file (accessible in server routes)
|
||||
addServerTemplate({
|
||||
filename: '#my-module/config.mjs',
|
||||
getContents: () => `export const config = ${JSON.stringify(options)}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Access Virtual Files
|
||||
|
||||
```ts
|
||||
// In runtime plugin
|
||||
// @ts-expect-error - virtual file
|
||||
import config from '#build/my-config.mjs'
|
||||
|
||||
// In server routes
|
||||
import { config } from '#my-module/config.js'
|
||||
```
|
||||
|
||||
## Extending Pages and Routes
|
||||
|
||||
```ts
|
||||
import { extendPages, extendRouteRules, addRouteMiddleware, createResolver } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup() {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
// Add pages
|
||||
extendPages((pages) => {
|
||||
pages.push({
|
||||
name: 'my-page',
|
||||
path: '/my-route',
|
||||
file: resolve('./runtime/pages/MyPage.vue'),
|
||||
})
|
||||
})
|
||||
|
||||
// Add route rules (caching, redirects, etc.)
|
||||
extendRouteRules('/api/**', {
|
||||
cache: { maxAge: 60 },
|
||||
})
|
||||
|
||||
// Add middleware
|
||||
addRouteMiddleware({
|
||||
name: 'my-middleware',
|
||||
path: resolve('./runtime/middleware/myMiddleware'),
|
||||
global: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Module Dependencies
|
||||
|
||||
Declare dependencies on other modules with version constraints:
|
||||
|
||||
```ts
|
||||
export default defineNuxtModule({
|
||||
meta: {
|
||||
name: 'my-module',
|
||||
},
|
||||
moduleDependencies: {
|
||||
'@nuxtjs/tailwindcss': {
|
||||
version: '>=6.0.0',
|
||||
// Set defaults (user can override)
|
||||
defaults: {
|
||||
exposeConfig: true,
|
||||
},
|
||||
// Force specific options
|
||||
overrides: {
|
||||
viewer: false,
|
||||
},
|
||||
},
|
||||
'@nuxtjs/i18n': {
|
||||
optional: true, // Won't fail if not installed
|
||||
defaults: {
|
||||
defaultLocale: 'en',
|
||||
},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
// Dependencies are guaranteed to be set up before this runs
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Dynamic Dependencies
|
||||
|
||||
```ts
|
||||
moduleDependencies(nuxt) {
|
||||
const deps: Record<string, any> = {
|
||||
'@nuxtjs/tailwindcss': { version: '>=6.0.0' },
|
||||
}
|
||||
|
||||
if (nuxt.options.ssr) {
|
||||
deps['@nuxtjs/html-validator'] = { optional: true }
|
||||
}
|
||||
|
||||
return deps
|
||||
}
|
||||
```
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
Requires `meta.name` and `meta.version`:
|
||||
|
||||
```ts
|
||||
export default defineNuxtModule({
|
||||
meta: {
|
||||
name: 'my-module',
|
||||
version: '1.2.0',
|
||||
},
|
||||
onInstall(nuxt) {
|
||||
// First-time setup
|
||||
console.log('Module installed for the first time')
|
||||
},
|
||||
onUpgrade(nuxt, options, previousVersion) {
|
||||
// Version upgrade migrations
|
||||
console.log(`Upgrading from ${previousVersion}`)
|
||||
},
|
||||
setup(options, nuxt) {
|
||||
// Regular setup runs every build
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Extending Configuration
|
||||
|
||||
```ts
|
||||
export default defineNuxtModule({
|
||||
setup(options, nuxt) {
|
||||
// Add CSS
|
||||
nuxt.options.css.push('my-module/styles.css')
|
||||
|
||||
// Add runtime config
|
||||
nuxt.options.runtimeConfig.public.myModule = {
|
||||
apiUrl: options.apiUrl,
|
||||
}
|
||||
|
||||
// Extend Vite config
|
||||
nuxt.options.vite.optimizeDeps ||= {}
|
||||
nuxt.options.vite.optimizeDeps.include ||= []
|
||||
nuxt.options.vite.optimizeDeps.include.push('some-package')
|
||||
|
||||
// Add build transpile
|
||||
nuxt.options.build.transpile.push('my-package')
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Using Hooks
|
||||
|
||||
```ts
|
||||
export default defineNuxtModule({
|
||||
// Declarative hooks
|
||||
hooks: {
|
||||
'components:dirs': (dirs) => {
|
||||
dirs.push({ path: '~/extra' })
|
||||
},
|
||||
},
|
||||
|
||||
setup(options, nuxt) {
|
||||
// Programmatic hooks
|
||||
nuxt.hook('pages:extend', (pages) => {
|
||||
// Modify pages
|
||||
})
|
||||
|
||||
nuxt.hook('imports:extend', (imports) => {
|
||||
imports.push({ name: 'myHelper', from: 'my-package' })
|
||||
})
|
||||
|
||||
nuxt.hook('nitro:config', (config) => {
|
||||
// Modify Nitro config
|
||||
})
|
||||
|
||||
nuxt.hook('vite:extendConfig', (config) => {
|
||||
// Modify Vite config
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Path Resolution
|
||||
|
||||
```ts
|
||||
import { createResolver, resolvePath, findPath } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
async setup(options, nuxt) {
|
||||
// Resolver relative to module
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
const pluginPath = resolve('./runtime/plugin')
|
||||
|
||||
// Resolve with extensions and aliases
|
||||
const entrypoint = await resolvePath('@some/package')
|
||||
|
||||
// Find first existing file
|
||||
const configPath = await findPath([
|
||||
resolve('./config.ts'),
|
||||
resolve('./config.js'),
|
||||
])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Module Package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-nuxt-module",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/module.mjs",
|
||||
"require": "./dist/module.cjs"
|
||||
}
|
||||
},
|
||||
"main": "./dist/module.cjs",
|
||||
"module": "./dist/module.mjs",
|
||||
"types": "./dist/types.d.ts",
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"dev": "nuxi dev playground",
|
||||
"build": "nuxt-module-build build",
|
||||
"prepare": "nuxt-module-build build --stub"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/kit": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/module-builder": "latest",
|
||||
"nuxt": "^3.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Disabling Modules
|
||||
|
||||
Users can disable a module via config key:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
// Disable entirely
|
||||
myModule: false,
|
||||
|
||||
// Or with options
|
||||
myModule: {
|
||||
enabled: false,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Create module**: `npx nuxi init -t module my-module`
|
||||
2. **Develop**: `npm run dev` (runs playground)
|
||||
3. **Build**: `npm run build`
|
||||
4. **Test**: `npm run test`
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use `createResolver(import.meta.url)` for all path resolution
|
||||
- Prefix components to avoid naming conflicts
|
||||
- Make options type-safe with `ModuleOptions` interface
|
||||
- Use `moduleDependencies` instead of `installModule`
|
||||
- Provide sensible defaults for all options
|
||||
- Add compatibility requirements in `meta.compatibility`
|
||||
- Use virtual files for dynamic configuration
|
||||
- Separate client/server plugins appropriately
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/api/kit/modules
|
||||
- https://nuxt.com/docs/api/kit/components
|
||||
- https://nuxt.com/docs/api/kit/autoimports
|
||||
- https://nuxt.com/docs/api/kit/plugins
|
||||
- https://nuxt.com/docs/api/kit/templates
|
||||
- https://nuxt.com/docs/api/kit/nitro
|
||||
- https://nuxt.com/docs/api/kit/pages
|
||||
- https://nuxt.com/docs/api/kit/resolving
|
||||
-->
|
||||
357
.claude/skills/nuxt/references/best-practices-data-fetching.md
Normal file
357
.claude/skills/nuxt/references/best-practices-data-fetching.md
Normal file
@@ -0,0 +1,357 @@
|
||||
---
|
||||
name: data-fetching-best-practices
|
||||
description: Patterns and best practices for efficient data fetching in Nuxt
|
||||
---
|
||||
|
||||
# Data Fetching Best Practices
|
||||
|
||||
Effective data fetching patterns for SSR-friendly, performant Nuxt applications.
|
||||
|
||||
## Choose the Right Tool
|
||||
|
||||
| Scenario | Use |
|
||||
|----------|-----|
|
||||
| Component initial data | `useFetch` or `useAsyncData` |
|
||||
| User interactions (clicks, forms) | `$fetch` |
|
||||
| Third-party SDK/API | `useAsyncData` with custom function |
|
||||
| Multiple parallel requests | `useAsyncData` with `Promise.all` |
|
||||
|
||||
## Await vs Non-Await Usage
|
||||
|
||||
The `await` keyword controls whether data fetching **blocks navigation**:
|
||||
|
||||
### With `await` - Blocking Navigation
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Navigation waits until data is fetched (uses Vue Suspense)
|
||||
const { data } = await useFetch('/api/posts')
|
||||
// data.value is available immediately after this line
|
||||
</script>
|
||||
```
|
||||
|
||||
- **Server**: Fetches data and includes it in the payload
|
||||
- **Client hydration**: Uses payload data, no re-fetch
|
||||
- **Client navigation**: Blocks until data is ready
|
||||
|
||||
### Without `await` - Non-Blocking (Lazy)
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Navigation proceeds immediately, data fetches in background
|
||||
const { data, status } = useFetch('/api/posts', { lazy: true })
|
||||
// data.value may be undefined initially - check status!
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="status === 'pending'">Loading...</div>
|
||||
<div v-else>{{ data }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Equivalent to using `useLazyFetch`:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { data, status } = useLazyFetch('/api/posts')
|
||||
</script>
|
||||
```
|
||||
|
||||
### When to Use Each
|
||||
|
||||
| Pattern | Use Case |
|
||||
|---------|----------|
|
||||
| `await useFetch()` | Critical data needed for SEO/initial render |
|
||||
| `useFetch({ lazy: true })` | Non-critical data, better perceived performance |
|
||||
| `await useLazyFetch()` | Same as lazy, await only ensures initialization |
|
||||
|
||||
## Avoid Double Fetching
|
||||
|
||||
### ❌ Wrong: Using $fetch Alone in Setup
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// This fetches TWICE: once on server, once on client
|
||||
const data = await $fetch('/api/posts')
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Correct: Use useFetch
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Fetches on server, hydrates on client (no double fetch)
|
||||
const { data } = await useFetch('/api/posts')
|
||||
</script>
|
||||
```
|
||||
|
||||
## Use Explicit Cache Keys
|
||||
|
||||
### ❌ Avoid: Auto-generated Keys
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Key is auto-generated from file/line - can cause issues
|
||||
const { data } = await useAsyncData(() => fetchPosts())
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Better: Explicit Keys
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Explicit key for predictable caching
|
||||
const { data } = await useAsyncData(
|
||||
'posts',
|
||||
() => fetchPosts(),
|
||||
)
|
||||
|
||||
// Dynamic keys for parameterized data
|
||||
const route = useRoute()
|
||||
const { data: post } = await useAsyncData(
|
||||
`post-${route.params.id}`,
|
||||
() => fetchPost(route.params.id),
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Handle Loading States Properly
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { data, status, error } = await useFetch('/api/posts')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="status === 'pending'">
|
||||
<SkeletonLoader />
|
||||
</div>
|
||||
<div v-else-if="error">
|
||||
<ErrorMessage :error="error" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<PostList :posts="data" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Use Lazy Fetching for Non-critical Data
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const id = useRoute().params.id
|
||||
|
||||
// Critical data - blocks navigation
|
||||
const { data: post } = await useFetch(`/api/posts/${id}`)
|
||||
|
||||
// Non-critical data - doesn't block navigation
|
||||
const { data: comments, status } = useFetch(`/api/posts/${id}/comments`, {
|
||||
lazy: true,
|
||||
})
|
||||
|
||||
// Or use useLazyFetch
|
||||
const { data: related } = useLazyFetch(`/api/posts/${id}/related`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article>
|
||||
<h1>{{ post?.title }}</h1>
|
||||
<p>{{ post?.content }}</p>
|
||||
</article>
|
||||
|
||||
<section v-if="status === 'pending'">Loading comments...</section>
|
||||
<CommentList v-else :comments="comments" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Minimize Payload Size
|
||||
|
||||
### Use `pick` for Simple Filtering
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { data } = await useFetch('/api/users', {
|
||||
// Only include these fields in payload
|
||||
pick: ['id', 'name', 'avatar'],
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Use `transform` for Complex Transformations
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { data } = await useFetch('/api/posts', {
|
||||
transform: (posts) => {
|
||||
return posts.map(post => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
excerpt: post.content.slice(0, 100),
|
||||
date: new Date(post.createdAt).toLocaleDateString(),
|
||||
}))
|
||||
},
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Parallel Fetching
|
||||
|
||||
### Fetch Independent Data with useAsyncData
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { data } = await useAsyncData(
|
||||
'dashboard',
|
||||
async (_nuxtApp, { signal }) => {
|
||||
const [user, posts, stats] = await Promise.all([
|
||||
$fetch('/api/user', { signal }),
|
||||
$fetch('/api/posts', { signal }),
|
||||
$fetch('/api/stats', { signal }),
|
||||
])
|
||||
return { user, posts, stats }
|
||||
},
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
### Multiple useFetch Calls
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// These run in parallel automatically
|
||||
const [{ data: user }, { data: posts }] = await Promise.all([
|
||||
useFetch('/api/user'),
|
||||
useFetch('/api/posts'),
|
||||
])
|
||||
</script>
|
||||
```
|
||||
|
||||
## Efficient Refresh Patterns
|
||||
|
||||
### Watch Reactive Dependencies
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const page = ref(1)
|
||||
const category = ref('all')
|
||||
|
||||
const { data } = await useFetch('/api/posts', {
|
||||
query: { page, category },
|
||||
// Auto-refresh when these change
|
||||
watch: [page, category],
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Manual Refresh
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { data, refresh, status } = await useFetch('/api/posts')
|
||||
|
||||
async function refreshPosts() {
|
||||
await refresh()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Conditional Fetching
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const userId = ref<string | null>(null)
|
||||
|
||||
const { data, execute } = useFetch(() => `/api/users/${userId.value}`, {
|
||||
immediate: false, // Don't fetch until userId is set
|
||||
})
|
||||
|
||||
// Later, when userId is available
|
||||
function loadUser(id: string) {
|
||||
userId.value = id
|
||||
execute()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Server-only Fetching
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Only fetch on server, skip on client navigation
|
||||
const { data } = await useFetch('/api/static-content', {
|
||||
server: true,
|
||||
lazy: true,
|
||||
getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key],
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { data, error, refresh } = await useFetch('/api/posts')
|
||||
|
||||
// Watch for errors if need event-like handling
|
||||
watch(error, (err) => {
|
||||
if (err) {
|
||||
console.error('Fetch failed:', err)
|
||||
// Show toast, redirect, etc.
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="error">
|
||||
<p>Failed to load: {{ error.message }}</p>
|
||||
<button @click="refresh()">Retry</button>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Shared Data Across Components
|
||||
|
||||
```vue
|
||||
<!-- ComponentA.vue -->
|
||||
<script setup lang="ts">
|
||||
const { data } = await useFetch('/api/user', { key: 'current-user' })
|
||||
</script>
|
||||
|
||||
<!-- ComponentB.vue -->
|
||||
<script setup lang="ts">
|
||||
// Access cached data without refetching
|
||||
const { data: user } = useNuxtData('current-user')
|
||||
|
||||
// Or refresh it
|
||||
const { refresh } = await useFetch('/api/user', { key: 'current-user' })
|
||||
</script>
|
||||
```
|
||||
|
||||
## Avoid useAsyncData for Side Effects
|
||||
|
||||
### ❌ Wrong: Side Effects in useAsyncData
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Don't trigger Pinia actions or side effects
|
||||
await useAsyncData(() => store.fetchUser()) // Can cause issues
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Correct: Use callOnce for Side Effects
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
await callOnce(async () => {
|
||||
await store.fetchUser()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/getting-started/data-fetching
|
||||
- https://nuxt.com/docs/api/composables/use-fetch
|
||||
- https://nuxt.com/docs/api/composables/use-async-data
|
||||
- https://nuxt.com/docs/api/composables/use-lazy-fetch
|
||||
-->
|
||||
355
.claude/skills/nuxt/references/best-practices-ssr.md
Normal file
355
.claude/skills/nuxt/references/best-practices-ssr.md
Normal file
@@ -0,0 +1,355 @@
|
||||
---
|
||||
name: ssr-best-practices
|
||||
description: Avoiding SSR context leaks, hydration mismatches, and proper composable usage
|
||||
---
|
||||
|
||||
# SSR Best Practices
|
||||
|
||||
Patterns for avoiding common SSR pitfalls: context leaks, hydration mismatches, and composable errors.
|
||||
|
||||
## The "Nuxt Instance Unavailable" Error
|
||||
|
||||
This error occurs when calling Nuxt composables outside the proper context.
|
||||
|
||||
### ❌ Wrong: Composable Outside Setup
|
||||
|
||||
```ts
|
||||
// composables/bad.ts
|
||||
// Called at module level - no Nuxt context!
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
export function useMyComposable() {
|
||||
return config.public.apiBase
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct: Composable Inside Function
|
||||
|
||||
```ts
|
||||
// composables/good.ts
|
||||
export function useMyComposable() {
|
||||
// Called inside the composable - has context
|
||||
const config = useRuntimeConfig()
|
||||
return config.public.apiBase
|
||||
}
|
||||
```
|
||||
|
||||
### Valid Contexts for Composables
|
||||
|
||||
Nuxt composables work in:
|
||||
- `<script setup>` blocks
|
||||
- `setup()` function
|
||||
- `defineNuxtPlugin()` callbacks
|
||||
- `defineNuxtRouteMiddleware()` callbacks
|
||||
|
||||
```ts
|
||||
// ✅ Plugin
|
||||
export default defineNuxtPlugin(() => {
|
||||
const config = useRuntimeConfig() // Works
|
||||
})
|
||||
|
||||
// ✅ Middleware
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
const route = useRoute() // Works
|
||||
})
|
||||
```
|
||||
|
||||
## Avoid State Leaks Between Requests
|
||||
|
||||
### ❌ Wrong: Module-level State
|
||||
|
||||
```ts
|
||||
// composables/bad.ts
|
||||
// This state is SHARED between all requests on server!
|
||||
const globalState = ref({ user: null })
|
||||
|
||||
export function useUser() {
|
||||
return globalState
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct: Use useState
|
||||
|
||||
```ts
|
||||
// composables/good.ts
|
||||
export function useUser() {
|
||||
// useState creates request-isolated state
|
||||
return useState('user', () => ({ user: null }))
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
On the server, module-level state persists across requests, causing:
|
||||
- Data leaking between users
|
||||
- Security vulnerabilities
|
||||
- Memory leaks
|
||||
|
||||
## Hydration Mismatch Prevention
|
||||
|
||||
Hydration mismatches occur when server HTML differs from client render.
|
||||
|
||||
### ❌ Wrong: Browser APIs in Setup
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// localStorage doesn't exist on server!
|
||||
const theme = localStorage.getItem('theme') || 'light'
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Correct: Use SSR-safe Alternatives
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// useCookie works on both server and client
|
||||
const theme = useCookie('theme', { default: () => 'light' })
|
||||
</script>
|
||||
```
|
||||
|
||||
### ❌ Wrong: Random/Time-based Values
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>{{ Math.random() }}</div>
|
||||
<div>{{ new Date().toLocaleTimeString() }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### ✅ Correct: Use useState for Consistency
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// Value is generated once on server, hydrated on client
|
||||
const randomValue = useState('random', () => Math.random())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>{{ randomValue }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### ❌ Wrong: Conditional Rendering on Client State
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- window doesn't exist on server -->
|
||||
<div v-if="window?.innerWidth > 768">Desktop</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### ✅ Correct: Use CSS or ClientOnly
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- CSS media queries work on both -->
|
||||
<div class="hidden md:block">Desktop</div>
|
||||
<div class="md:hidden">Mobile</div>
|
||||
|
||||
<!-- Or use ClientOnly for JS-dependent rendering -->
|
||||
<ClientOnly>
|
||||
<ResponsiveComponent />
|
||||
<template #fallback>Loading...</template>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Browser-only Code
|
||||
|
||||
### Use `import.meta.client`
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
if (import.meta.client) {
|
||||
// Only runs in browser
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Use `onMounted` for DOM Access
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const el = ref<HTMLElement>()
|
||||
|
||||
onMounted(() => {
|
||||
// Safe - only runs on client after hydration
|
||||
el.value?.focus()
|
||||
initThirdPartyLib()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Dynamic Imports for Browser Libraries
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
onMounted(async () => {
|
||||
const { Chart } = await import('chart.js')
|
||||
new Chart(canvas.value, config)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Server-only Code
|
||||
|
||||
### Use `import.meta.server`
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
if (import.meta.server) {
|
||||
// Only runs on server
|
||||
const secrets = useRuntimeConfig().apiSecret
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Server Components
|
||||
|
||||
```vue
|
||||
<!-- components/ServerData.server.vue -->
|
||||
<script setup>
|
||||
// This entire component only runs on server
|
||||
const data = await fetchSensitiveData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>{{ data }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Async Composable Patterns
|
||||
|
||||
### ❌ Wrong: Await Before Composable
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
await someAsyncOperation()
|
||||
const route = useRoute() // May fail - context lost after await
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Correct: Get Context First
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// Get all composables before any await
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
await someAsyncOperation()
|
||||
// Now safe to use route and config
|
||||
</script>
|
||||
```
|
||||
|
||||
## Plugin Best Practices
|
||||
|
||||
### Client-only Plugins
|
||||
|
||||
```ts
|
||||
// plugins/analytics.client.ts
|
||||
export default defineNuxtPlugin(() => {
|
||||
// Only runs on client
|
||||
initAnalytics()
|
||||
})
|
||||
```
|
||||
|
||||
### Server-only Plugins
|
||||
|
||||
```ts
|
||||
// plugins/server-init.server.ts
|
||||
export default defineNuxtPlugin(() => {
|
||||
// Only runs on server
|
||||
initServerConnections()
|
||||
})
|
||||
```
|
||||
|
||||
### Provide/Inject Pattern
|
||||
|
||||
```ts
|
||||
// plugins/api.ts
|
||||
export default defineNuxtPlugin(() => {
|
||||
const api = createApiClient()
|
||||
|
||||
return {
|
||||
provide: {
|
||||
api,
|
||||
},
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const { $api } = useNuxtApp()
|
||||
const data = await $api.get('/users')
|
||||
</script>
|
||||
```
|
||||
|
||||
## Third-party Library Integration
|
||||
|
||||
### ❌ Wrong: Import at Top Level
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import SomeLibrary from 'browser-only-lib' // Breaks SSR
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Correct: Dynamic Import
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
let library: typeof import('browser-only-lib')
|
||||
|
||||
onMounted(async () => {
|
||||
library = await import('browser-only-lib')
|
||||
library.init()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Use ClientOnly Component
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<BrowserOnlyComponent />
|
||||
<template #fallback>
|
||||
<div class="skeleton">Loading...</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Debugging SSR Issues
|
||||
|
||||
### Check Rendering Context
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
console.log('Server:', import.meta.server)
|
||||
console.log('Client:', import.meta.client)
|
||||
</script>
|
||||
```
|
||||
|
||||
### Use Nuxt DevTools
|
||||
|
||||
DevTools shows payload data and hydration state.
|
||||
|
||||
### Common Error Messages
|
||||
|
||||
| Error | Cause |
|
||||
|-------|-------|
|
||||
| "Nuxt instance unavailable" | Composable called outside setup context |
|
||||
| "Hydration mismatch" | Server/client HTML differs |
|
||||
| "window is not defined" | Browser API used during SSR |
|
||||
| "document is not defined" | DOM access during SSR |
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/guide/concepts/auto-imports#vue-and-nuxt-composables
|
||||
- https://nuxt.com/docs/guide/best-practices/hydration
|
||||
- https://nuxt.com/docs/getting-started/state-management#best-practices
|
||||
-->
|
||||
263
.claude/skills/nuxt/references/core-cli.md
Normal file
263
.claude/skills/nuxt/references/core-cli.md
Normal file
@@ -0,0 +1,263 @@
|
||||
---
|
||||
name: cli-commands
|
||||
description: Nuxt CLI commands for development, building, and project management
|
||||
---
|
||||
|
||||
# CLI Commands
|
||||
|
||||
Nuxt provides CLI commands via `nuxi` (or `npx nuxt`) for development, building, and project management.
|
||||
|
||||
## Project Initialization
|
||||
|
||||
### Create New Project
|
||||
|
||||
```bash
|
||||
# Interactive project creation
|
||||
npx nuxi@latest init my-app
|
||||
|
||||
# With specific package manager
|
||||
npx nuxi@latest init my-app --packageManager pnpm
|
||||
|
||||
# With modules
|
||||
npx nuxi@latest init my-app --modules "@nuxt/ui,@nuxt/image"
|
||||
|
||||
# From template
|
||||
npx nuxi@latest init my-app --template v3
|
||||
|
||||
# Skip module selection prompt
|
||||
npx nuxi@latest init my-app --no-modules
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-t, --template` | Template name |
|
||||
| `--packageManager` | npm, pnpm, yarn, or bun |
|
||||
| `-M, --modules` | Modules to install (comma-separated) |
|
||||
| `--gitInit` | Initialize git repository |
|
||||
| `--no-install` | Skip installing dependencies |
|
||||
|
||||
## Development
|
||||
|
||||
### Start Dev Server
|
||||
|
||||
```bash
|
||||
# Start development server (default: http://localhost:3000)
|
||||
npx nuxt dev
|
||||
|
||||
# Custom port
|
||||
npx nuxt dev --port 4000
|
||||
|
||||
# Open in browser
|
||||
npx nuxt dev --open
|
||||
|
||||
# Listen on all interfaces (for mobile testing)
|
||||
npx nuxt dev --host 0.0.0.0
|
||||
|
||||
# With HTTPS
|
||||
npx nuxt dev --https
|
||||
|
||||
# Clear console on restart
|
||||
npx nuxt dev --clear
|
||||
|
||||
# Create public tunnel
|
||||
npx nuxt dev --tunnel
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-p, --port` | Port to listen on |
|
||||
| `-h, --host` | Host to listen on |
|
||||
| `-o, --open` | Open in browser |
|
||||
| `--https` | Enable HTTPS |
|
||||
| `--tunnel` | Create public tunnel (via untun) |
|
||||
| `--qr` | Show QR code for mobile |
|
||||
| `--clear` | Clear console on restart |
|
||||
|
||||
**Environment Variables:**
|
||||
- `NUXT_PORT` or `PORT` - Default port
|
||||
- `NUXT_HOST` or `HOST` - Default host
|
||||
|
||||
## Building
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
# Build for production
|
||||
npx nuxt build
|
||||
|
||||
# Build with prerendering
|
||||
npx nuxt build --prerender
|
||||
|
||||
# Build with specific preset
|
||||
npx nuxt build --preset node-server
|
||||
npx nuxt build --preset cloudflare-pages
|
||||
npx nuxt build --preset vercel
|
||||
|
||||
# Build with environment
|
||||
npx nuxt build --envName staging
|
||||
```
|
||||
|
||||
Output is created in `.output/` directory.
|
||||
|
||||
### Static Generation
|
||||
|
||||
```bash
|
||||
# Generate static site (prerenders all routes)
|
||||
npx nuxt generate
|
||||
```
|
||||
|
||||
Equivalent to `nuxt build --prerender`. Creates static HTML files for deployment to static hosting.
|
||||
|
||||
### Preview Production Build
|
||||
|
||||
```bash
|
||||
# Preview after build
|
||||
npx nuxt preview
|
||||
|
||||
# Custom port
|
||||
npx nuxt preview --port 4000
|
||||
```
|
||||
|
||||
## Utilities
|
||||
|
||||
### Prepare (Type Generation)
|
||||
|
||||
```bash
|
||||
# Generate TypeScript types and .nuxt directory
|
||||
npx nuxt prepare
|
||||
```
|
||||
|
||||
Run after cloning or when types are missing.
|
||||
|
||||
### Type Check
|
||||
|
||||
```bash
|
||||
# Run TypeScript type checking
|
||||
npx nuxt typecheck
|
||||
```
|
||||
|
||||
### Analyze Bundle
|
||||
|
||||
```bash
|
||||
# Analyze production bundle
|
||||
npx nuxt analyze
|
||||
```
|
||||
|
||||
Opens visual bundle analyzer.
|
||||
|
||||
### Cleanup
|
||||
|
||||
```bash
|
||||
# Remove generated files (.nuxt, .output, node_modules/.cache)
|
||||
npx nuxt cleanup
|
||||
```
|
||||
|
||||
### Info
|
||||
|
||||
```bash
|
||||
# Show environment info (useful for bug reports)
|
||||
npx nuxt info
|
||||
```
|
||||
|
||||
### Upgrade
|
||||
|
||||
```bash
|
||||
# Upgrade Nuxt to latest version
|
||||
npx nuxt upgrade
|
||||
|
||||
# Upgrade to nightly release
|
||||
npx nuxt upgrade --nightly
|
||||
```
|
||||
|
||||
## Module Commands
|
||||
|
||||
### Add Module
|
||||
|
||||
```bash
|
||||
# Add a Nuxt module
|
||||
npx nuxt module add @nuxt/ui
|
||||
npx nuxt module add @nuxt/image
|
||||
```
|
||||
|
||||
Installs and adds to `nuxt.config.ts`.
|
||||
|
||||
### Build Module (for module authors)
|
||||
|
||||
```bash
|
||||
# Build a Nuxt module
|
||||
npx nuxt build-module
|
||||
```
|
||||
|
||||
## DevTools
|
||||
|
||||
```bash
|
||||
# Enable DevTools globally
|
||||
npx nuxt devtools enable
|
||||
|
||||
# Disable DevTools
|
||||
npx nuxt devtools disable
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Install dependencies and start dev
|
||||
pnpm install
|
||||
pnpm dev # or npx nuxt dev
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
|
||||
```bash
|
||||
# Build and preview locally
|
||||
pnpm build
|
||||
pnpm preview
|
||||
|
||||
# Or for static hosting
|
||||
pnpm generate
|
||||
```
|
||||
|
||||
### After Cloning
|
||||
|
||||
```bash
|
||||
# Install deps and prepare types
|
||||
pnpm install
|
||||
npx nuxt prepare
|
||||
```
|
||||
|
||||
## Environment-specific Builds
|
||||
|
||||
```bash
|
||||
# Development build
|
||||
npx nuxt build --envName development
|
||||
|
||||
# Staging build
|
||||
npx nuxt build --envName staging
|
||||
|
||||
# Production build (default)
|
||||
npx nuxt build --envName production
|
||||
```
|
||||
|
||||
Corresponds to `$development`, `$env.staging`, `$production` in `nuxt.config.ts`.
|
||||
|
||||
## Layer Extension
|
||||
|
||||
```bash
|
||||
# Dev with additional layer
|
||||
npx nuxt dev --extends ./base-layer
|
||||
|
||||
# Build with layer
|
||||
npx nuxt build --extends ./base-layer
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/api/commands/dev
|
||||
- https://nuxt.com/docs/api/commands/build
|
||||
- https://nuxt.com/docs/api/commands/generate
|
||||
- https://nuxt.com/docs/api/commands/init
|
||||
-->
|
||||
183
.claude/skills/nuxt/references/core-config.md
Normal file
183
.claude/skills/nuxt/references/core-config.md
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
name: configuration
|
||||
description: Nuxt configuration files including nuxt.config.ts, app.config.ts, and runtime configuration
|
||||
---
|
||||
|
||||
# Nuxt Configuration
|
||||
|
||||
Nuxt uses configuration files to customize application behavior. The main configuration options are `nuxt.config.ts` for build-time settings and `app.config.ts` for runtime settings.
|
||||
|
||||
## nuxt.config.ts
|
||||
|
||||
The main configuration file at the root of your project:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
// Enable Nuxt 4 behavior
|
||||
future: {
|
||||
compatibilityVersion: 4,
|
||||
},
|
||||
devtools: { enabled: true },
|
||||
modules: ['@nuxt/ui'],
|
||||
})
|
||||
```
|
||||
|
||||
> In Nuxt 4, `compatibilityVersion: 4` is the default. When migrating from Nuxt 3, set `future.compatibilityVersion: 4` to opt into Nuxt 4 behavior early.
|
||||
|
||||
### Environment Overrides
|
||||
|
||||
Configure environment-specific settings:
|
||||
|
||||
```ts
|
||||
export default defineNuxtConfig({
|
||||
$production: {
|
||||
routeRules: {
|
||||
'/**': { isr: true },
|
||||
},
|
||||
},
|
||||
$development: {
|
||||
// Development-specific config
|
||||
},
|
||||
$env: {
|
||||
staging: {
|
||||
// Staging environment config
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Use `--envName` flag to select environment: `nuxt build --envName staging`
|
||||
|
||||
## Runtime Config
|
||||
|
||||
For values that need to be overridden via environment variables:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
// Server-only keys
|
||||
apiSecret: '123',
|
||||
// Keys within public are exposed to client
|
||||
public: {
|
||||
apiBase: '/api',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Override with environment variables:
|
||||
|
||||
```ini
|
||||
# .env
|
||||
NUXT_API_SECRET=api_secret_token
|
||||
NUXT_PUBLIC_API_BASE=https://api.example.com
|
||||
```
|
||||
|
||||
Access in components/composables:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const config = useRuntimeConfig()
|
||||
// Server: config.apiSecret, config.public.apiBase
|
||||
// Client: config.public.apiBase only
|
||||
</script>
|
||||
```
|
||||
|
||||
## App Config
|
||||
|
||||
For public tokens determined at build time (not overridable via env vars):
|
||||
|
||||
```ts
|
||||
// app/app.config.ts
|
||||
export default defineAppConfig({
|
||||
title: 'Hello Nuxt',
|
||||
theme: {
|
||||
dark: true,
|
||||
colors: {
|
||||
primary: '#ff0000',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Access in components:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const appConfig = useAppConfig()
|
||||
</script>
|
||||
```
|
||||
|
||||
## runtimeConfig vs app.config
|
||||
|
||||
| Feature | runtimeConfig | app.config |
|
||||
|---------|--------------|------------|
|
||||
| Client-side | Hydrated | Bundled |
|
||||
| Environment variables | Yes | No |
|
||||
| Reactive | Yes | Yes |
|
||||
| Hot module replacement | No | Yes |
|
||||
| Non-primitive JS types | No | Yes |
|
||||
|
||||
**Use runtimeConfig** for secrets and values that change per environment.
|
||||
**Use app.config** for public tokens, theme settings, and non-sensitive config.
|
||||
|
||||
## External Tool Configuration
|
||||
|
||||
Nuxt uses `nuxt.config.ts` as single source of truth. Configure external tools within it:
|
||||
|
||||
```ts
|
||||
export default defineNuxtConfig({
|
||||
// Nitro configuration
|
||||
nitro: {
|
||||
// nitro options
|
||||
},
|
||||
// Vite configuration
|
||||
vite: {
|
||||
// vite options
|
||||
vue: {
|
||||
// @vitejs/plugin-vue options
|
||||
},
|
||||
},
|
||||
// PostCSS configuration
|
||||
postcss: {
|
||||
// postcss options
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Vue Configuration
|
||||
|
||||
Enable Vue experimental features:
|
||||
|
||||
```ts
|
||||
export default defineNuxtConfig({
|
||||
vue: {
|
||||
propsDestructure: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Nuxt 4 Default Behaviors
|
||||
|
||||
With `compatibilityVersion: 4`, the following defaults change:
|
||||
|
||||
| Setting | Nuxt 3 default | Nuxt 4 default |
|
||||
|---------|---------------|----------------|
|
||||
| `srcDir` | root | `app/` |
|
||||
| `useAsyncData` data ref | `ref` (deep) | `shallowRef` |
|
||||
| `useAsyncData` dedupe | `defer` | `cancel` |
|
||||
| `useFetch` data ref | `ref` (deep) | `shallowRef` |
|
||||
|
||||
```ts
|
||||
// Explicitly opt back into deep ref if needed
|
||||
const { data } = await useAsyncData('key', fetchFn, { deep: true })
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/getting-started/configuration
|
||||
- https://nuxt.com/docs/guide/going-further/runtime-config
|
||||
- https://nuxt.com/docs/api/nuxt-config
|
||||
-->
|
||||
269
.claude/skills/nuxt/references/core-data-fetching.md
Normal file
269
.claude/skills/nuxt/references/core-data-fetching.md
Normal file
@@ -0,0 +1,269 @@
|
||||
---
|
||||
name: data-fetching
|
||||
description: useFetch, useAsyncData, and $fetch for SSR-friendly data fetching in Nuxt 4
|
||||
---
|
||||
|
||||
# Data Fetching
|
||||
|
||||
Nuxt provides composables for SSR-friendly data fetching that prevent double-fetching and handle hydration.
|
||||
|
||||
> **Nuxt 4 changes:** `data` is now a `shallowRef` by default (was deep `ref`). `dedupe` defaults to `'cancel'` (was `'defer'`). Use `deep: true` option to restore deep reactivity.
|
||||
|
||||
## Overview
|
||||
|
||||
- `$fetch` - Basic fetch utility (use for client-side events)
|
||||
- `useFetch` - SSR-safe wrapper around $fetch (use for component data)
|
||||
- `useAsyncData` - SSR-safe wrapper for any async function
|
||||
|
||||
## useFetch
|
||||
|
||||
Primary composable for fetching data in components:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { data, status, error, refresh, clear } = await useFetch('/api/posts')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="status === 'pending'">Loading...</div>
|
||||
<div v-else-if="error">Error: {{ error.message }}</div>
|
||||
<div v-else>
|
||||
<article v-for="post in data" :key="post.id">
|
||||
{{ post.title }}
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### With Options
|
||||
|
||||
```ts
|
||||
const { data } = await useFetch('/api/posts', {
|
||||
// Query parameters
|
||||
query: { page: 1, limit: 10 },
|
||||
// Request body (for POST/PUT)
|
||||
body: { title: 'New Post' },
|
||||
// HTTP method
|
||||
method: 'POST',
|
||||
// Only pick specific fields
|
||||
pick: ['id', 'title'],
|
||||
// Transform response
|
||||
transform: (posts) => posts.map(p => ({ ...p, slug: slugify(p.title) })),
|
||||
// Custom key for caching
|
||||
key: 'posts-list',
|
||||
// Don't fetch on server
|
||||
server: false,
|
||||
// Don't block navigation
|
||||
lazy: true,
|
||||
// Don't fetch immediately
|
||||
immediate: false,
|
||||
// Default value
|
||||
default: () => [],
|
||||
})
|
||||
```
|
||||
|
||||
### Reactive Parameters
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const page = ref(1)
|
||||
const { data } = await useFetch('/api/posts', {
|
||||
query: { page }, // Automatically refetches when page changes
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Computed URL
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const id = ref(1)
|
||||
const { data } = await useFetch(() => `/api/posts/${id.value}`)
|
||||
// Refetches when id changes
|
||||
</script>
|
||||
```
|
||||
|
||||
## useAsyncData
|
||||
|
||||
For wrapping any async function:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { data, error } = await useAsyncData('user', () => {
|
||||
return myCustomFetch('/user/profile')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Multiple Requests
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { data } = await useAsyncData('cart', async () => {
|
||||
const [coupons, offers] = await Promise.all([
|
||||
$fetch('/api/coupons'),
|
||||
$fetch('/api/offers'),
|
||||
])
|
||||
return { coupons, offers }
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## $fetch
|
||||
|
||||
For client-side events (form submissions, button clicks):
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
async function submitForm() {
|
||||
const result = await $fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: { name: 'John' },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Important**: Don't use `$fetch` alone in setup for initial data - it will fetch twice (server + client). Use `useFetch` or `useAsyncData` instead.
|
||||
|
||||
## Return Values
|
||||
|
||||
All composables return:
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `data` | `ShallowRef<T>` | Fetched data (shallowRef in Nuxt 4) |
|
||||
| `error` | `Ref<Error>` | Error if request failed |
|
||||
| `status` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | Request status |
|
||||
| `refresh` | `() => Promise` | Refetch data |
|
||||
| `execute` | `() => Promise` | Alias for refresh |
|
||||
| `clear` | `() => void` | Reset data and error |
|
||||
|
||||
> In Nuxt 4, `data` is a `shallowRef`. Mutating nested properties won't trigger reactivity — reassign the whole value or use `deep: true`:
|
||||
> ```ts
|
||||
> const { data } = await useFetch('/api/user', { deep: true }) // deep ref
|
||||
> ```
|
||||
|
||||
## Lazy Fetching
|
||||
|
||||
Don't block navigation:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Using lazy option
|
||||
const { data, status } = await useFetch('/api/posts', { lazy: true })
|
||||
|
||||
// Or use lazy variants
|
||||
const { data, status } = await useLazyFetch('/api/posts')
|
||||
const { data, status } = await useLazyAsyncData('key', fetchFn)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Refresh & Watch
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const category = ref('tech')
|
||||
|
||||
const { data, refresh } = await useFetch('/api/posts', {
|
||||
query: { category },
|
||||
// Auto-refresh when category changes
|
||||
watch: [category],
|
||||
})
|
||||
|
||||
// Manual refresh
|
||||
const refreshData = () => refresh()
|
||||
</script>
|
||||
```
|
||||
|
||||
## Deduplication (Nuxt 4)
|
||||
|
||||
In Nuxt 4, `dedupe` defaults to `'cancel'` — if a new request fires before the previous one completes, the previous request is cancelled:
|
||||
|
||||
```ts
|
||||
// Nuxt 4 default behavior
|
||||
const { data } = await useFetch('/api/posts')
|
||||
// Concurrent calls cancel the previous one
|
||||
|
||||
// Opt into old Nuxt 3 'defer' behavior
|
||||
const { data } = await useFetch('/api/posts', { dedupe: 'defer' })
|
||||
```
|
||||
|
||||
## getCachedData
|
||||
|
||||
Custom cache control in Nuxt 4:
|
||||
|
||||
```ts
|
||||
const { data } = await useAsyncData('posts', () => $fetch('/api/posts'), {
|
||||
getCachedData(key, nuxtApp) {
|
||||
// Return cached data to skip fetching, or undefined to fetch
|
||||
return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
Data is cached by key. Share data across components:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// In component A
|
||||
const { data } = await useFetch('/api/user', { key: 'current-user' })
|
||||
|
||||
// In component B - uses cached data
|
||||
const { data } = useNuxtData('current-user')
|
||||
</script>
|
||||
```
|
||||
|
||||
Refresh cached data globally:
|
||||
|
||||
```ts
|
||||
// Refresh specific key
|
||||
await refreshNuxtData('current-user')
|
||||
|
||||
// Refresh all data
|
||||
await refreshNuxtData()
|
||||
|
||||
// Clear cached data
|
||||
clearNuxtData('current-user')
|
||||
```
|
||||
|
||||
## Interceptors
|
||||
|
||||
```ts
|
||||
const { data } = await useFetch('/api/auth', {
|
||||
onRequest({ options }) {
|
||||
options.headers.set('Authorization', `Bearer ${token}`)
|
||||
},
|
||||
onRequestError({ error }) {
|
||||
console.error('Request failed:', error)
|
||||
},
|
||||
onResponse({ response }) {
|
||||
// Process response
|
||||
},
|
||||
onResponseError({ response }) {
|
||||
if (response.status === 401) {
|
||||
navigateTo('/login')
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Passing Headers (SSR)
|
||||
|
||||
`useFetch` automatically proxies cookies/headers from client to server. For `$fetch`:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const headers = useRequestHeaders(['cookie'])
|
||||
const data = await $fetch('/api/user', { headers })
|
||||
</script>
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/getting-started/data-fetching
|
||||
- https://nuxt.com/docs/api/composables/use-fetch
|
||||
- https://nuxt.com/docs/api/composables/use-async-data
|
||||
-->
|
||||
224
.claude/skills/nuxt/references/core-deployment.md
Normal file
224
.claude/skills/nuxt/references/core-deployment.md
Normal file
@@ -0,0 +1,224 @@
|
||||
---
|
||||
name: deployment
|
||||
description: Deploying Nuxt applications to various hosting platforms
|
||||
---
|
||||
|
||||
# Deployment
|
||||
|
||||
Nuxt is platform-agnostic thanks to [Nitro](https://nitro.build), its server engine. You can deploy to almost any platform with minimal configuration—Node.js servers, static hosting, serverless functions, or edge networks.
|
||||
|
||||
> **Full list of supported platforms:** https://nitro.build/deploy
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
### Node.js Server
|
||||
|
||||
```bash
|
||||
# Build for Node.js
|
||||
nuxt build
|
||||
|
||||
# Run production server
|
||||
node .output/server/index.mjs
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
- `PORT` or `NITRO_PORT` (default: 3000)
|
||||
- `HOST` or `NITRO_HOST` (default: 0.0.0.0)
|
||||
|
||||
### Static Generation
|
||||
|
||||
```bash
|
||||
# Generate static site
|
||||
nuxt generate
|
||||
```
|
||||
|
||||
Output in `.output/public/` - deploy to any static host.
|
||||
|
||||
### Preset Configuration
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
nitro: {
|
||||
preset: 'vercel', // or 'netlify', 'cloudflare-pages', etc.
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Or via environment variable:
|
||||
|
||||
```bash
|
||||
NITRO_PRESET=vercel nuxt build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended Platforms
|
||||
|
||||
When helping users choose a deployment platform, consider their needs:
|
||||
|
||||
### Vercel
|
||||
|
||||
**Best for:** Projects wanting zero-config deployment with excellent DX
|
||||
|
||||
```bash
|
||||
# Install Vercel CLI
|
||||
npm i -g vercel
|
||||
|
||||
# Deploy
|
||||
vercel
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Zero configuration for Nuxt (auto-detects)
|
||||
- Excellent preview deployments for PRs
|
||||
- Built-in analytics and speed insights
|
||||
- Edge Functions support
|
||||
- Great free tier for personal projects
|
||||
|
||||
**Cons:**
|
||||
- Can get expensive at scale (bandwidth costs)
|
||||
- Vendor lock-in concerns
|
||||
- Limited build minutes on free tier
|
||||
|
||||
**Recommended when:** User wants fastest setup, values DX, building SaaS or marketing sites.
|
||||
|
||||
---
|
||||
|
||||
### Netlify
|
||||
|
||||
**Best for:** JAMstack sites, static-heavy apps, teams needing forms/identity
|
||||
|
||||
```bash
|
||||
# Install Netlify CLI
|
||||
npm i -g netlify-cli
|
||||
|
||||
# Deploy
|
||||
netlify deploy --prod
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Great free tier with generous bandwidth
|
||||
- Built-in forms, identity, and functions
|
||||
- Excellent for static sites with some dynamic features
|
||||
- Good preview deployments
|
||||
- Split testing built-in
|
||||
|
||||
**Cons:**
|
||||
- SSR/serverless functions can be slower than Vercel
|
||||
- Less optimized for full SSR apps
|
||||
- Build minutes can run out on free tier
|
||||
|
||||
**Recommended when:** User has static-heavy site, needs built-in forms/auth, or prefers Netlify ecosystem.
|
||||
|
||||
---
|
||||
|
||||
### Cloudflare Pages
|
||||
|
||||
**Best for:** Global performance, edge computing, cost-conscious projects
|
||||
|
||||
```bash
|
||||
# Build with Cloudflare preset
|
||||
NITRO_PRESET=cloudflare-pages nuxt build
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Unlimited bandwidth on free tier
|
||||
- Excellent global edge network (fastest TTFB)
|
||||
- Workers for edge computing
|
||||
- Very cost-effective at scale
|
||||
- D1, KV, R2 for data storage
|
||||
|
||||
**Cons:**
|
||||
- Workers have execution limits (CPU time)
|
||||
- Some Node.js APIs not available in Workers
|
||||
- Less mature than Vercel/Netlify for frameworks
|
||||
|
||||
**Recommended when:** User prioritizes performance, global reach, or cost at scale.
|
||||
|
||||
---
|
||||
|
||||
### GitHub Actions + Self-hosted/VPS
|
||||
|
||||
**Best for:** Full control, existing infrastructure, CI/CD customization
|
||||
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
# Deploy to your server (example: rsync to VPS)
|
||||
- name: Deploy to server
|
||||
run: rsync -avz .output/ user@server:/app/
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Full control over build and deployment
|
||||
- No vendor lock-in
|
||||
- Can deploy anywhere (VPS, Docker, Kubernetes)
|
||||
- Free CI/CD minutes for public repos
|
||||
- Customizable workflows
|
||||
|
||||
**Cons:**
|
||||
- Requires more setup and maintenance
|
||||
- Need to manage your own infrastructure
|
||||
- No built-in preview deployments
|
||||
- SSL, scaling, monitoring are your responsibility
|
||||
|
||||
**Recommended when:** User has existing infrastructure, needs full control, or deploying to private/enterprise environments.
|
||||
|
||||
---
|
||||
|
||||
## Quick Decision Guide
|
||||
|
||||
| Need | Recommendation |
|
||||
|------|----------------|
|
||||
| Fastest setup, small team | **Vercel** |
|
||||
| Static site with forms | **Netlify** |
|
||||
| Cost-sensitive at scale | **Cloudflare Pages** |
|
||||
| Full control / enterprise | **GitHub Actions + VPS** |
|
||||
| Docker/Kubernetes | **GitHub Actions + Container Registry** |
|
||||
| Serverless APIs | **Vercel** or **AWS Lambda** |
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.output .output
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
```
|
||||
|
||||
```bash
|
||||
docker build -t my-nuxt-app .
|
||||
docker run -p 3000:3000 my-nuxt-app
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/getting-started/deployment
|
||||
- https://nitro.build/deploy
|
||||
-->
|
||||
289
.claude/skills/nuxt/references/core-directory-structure.md
Normal file
289
.claude/skills/nuxt/references/core-directory-structure.md
Normal file
@@ -0,0 +1,289 @@
|
||||
---
|
||||
name: directory-structure
|
||||
description: Nuxt project folder structure, conventions, and file organization
|
||||
---
|
||||
|
||||
# Directory Structure
|
||||
|
||||
Nuxt uses conventions-based directory structure. Understanding it is key to effective development.
|
||||
|
||||
## Standard Project Structure (Nuxt 4)
|
||||
|
||||
In Nuxt 4, the **`app/` directory is the default source directory** (`srcDir` defaults to `app/`).
|
||||
|
||||
```
|
||||
my-nuxt-app/
|
||||
├── app/ # Application source (default srcDir in Nuxt 4)
|
||||
│ ├── app.vue # Root component
|
||||
│ ├── app.config.ts # App configuration (runtime)
|
||||
│ ├── error.vue # Error page
|
||||
│ ├── router.options.ts # Vue Router options (Nuxt 4)
|
||||
│ ├── components/ # Auto-imported Vue components
|
||||
│ ├── composables/ # Auto-imported composables
|
||||
│ ├── layouts/ # Layout components
|
||||
│ ├── middleware/ # Route middleware
|
||||
│ ├── pages/ # File-based routing
|
||||
│ ├── plugins/ # Vue plugins
|
||||
│ └── utils/ # Auto-imported utilities
|
||||
├── assets/ # Build-processed assets (CSS, images) — stays at root
|
||||
├── public/ # Static assets (served as-is) — stays at root
|
||||
├── server/ # Server-side code — stays at root
|
||||
│ ├── api/ # API routes (/api/*)
|
||||
│ ├── routes/ # Server routes
|
||||
│ ├── middleware/ # Server middleware
|
||||
│ ├── plugins/ # Nitro plugins
|
||||
│ └── utils/ # Server utilities (auto-imported)
|
||||
├── shared/ # Shared code between app and server (Nuxt 4)
|
||||
│ ├── utils/ # Auto-imported in both app and server
|
||||
│ └── types/ # Shared TypeScript types
|
||||
├── content/ # Content files (@nuxt/content)
|
||||
├── layers/ # Local layers (auto-scanned)
|
||||
├── modules/ # Local modules
|
||||
├── nuxt.config.ts # Nuxt configuration
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Key Directories
|
||||
|
||||
### `app/` Directory
|
||||
|
||||
In Nuxt 4, this is the default source directory (`srcDir: 'app/'`). All application code goes here.
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts - override source directory
|
||||
export default defineNuxtConfig({
|
||||
srcDir: 'src/', // Change from default 'app/' to 'src/'
|
||||
})
|
||||
```
|
||||
|
||||
### `shared/` Directory (New in Nuxt 4)
|
||||
|
||||
Code shared between the Vue app and the Nitro server:
|
||||
|
||||
```
|
||||
shared/
|
||||
├── utils/
|
||||
│ └── format.ts → auto-imported in both app/ and server/
|
||||
└── types/
|
||||
└── user.ts → shared TypeScript interfaces
|
||||
```
|
||||
|
||||
### `app/components/`
|
||||
|
||||
Vue components auto-imported by name:
|
||||
|
||||
```
|
||||
components/
|
||||
├── Button.vue → <Button />
|
||||
├── Card.vue → <Card />
|
||||
├── base/
|
||||
│ └── Button.vue → <BaseButton />
|
||||
├── ui/
|
||||
│ ├── Input.vue → <UiInput />
|
||||
│ └── Modal.vue → <UiModal />
|
||||
└── TheHeader.vue → <TheHeader />
|
||||
```
|
||||
|
||||
**Lazy loading**: Prefix with `Lazy` for dynamic import:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LazyHeavyChart v-if="showChart" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**Client/Server only**:
|
||||
|
||||
```
|
||||
components/
|
||||
├── Comments.client.vue → Only rendered on client
|
||||
└── ServerData.server.vue → Only rendered on server
|
||||
```
|
||||
|
||||
### `app/composables/`
|
||||
|
||||
Vue composables auto-imported (top-level files only):
|
||||
|
||||
```
|
||||
composables/
|
||||
├── useAuth.ts → useAuth()
|
||||
├── useFoo.ts → useFoo()
|
||||
└── nested/
|
||||
└── utils.ts → NOT auto-imported
|
||||
```
|
||||
|
||||
Re-export nested composables:
|
||||
|
||||
```ts
|
||||
// composables/index.ts
|
||||
export { useHelper } from './nested/utils'
|
||||
```
|
||||
|
||||
### `app/pages/`
|
||||
|
||||
File-based routing:
|
||||
|
||||
```
|
||||
pages/
|
||||
├── index.vue → /
|
||||
├── about.vue → /about
|
||||
├── blog/
|
||||
│ ├── index.vue → /blog
|
||||
│ └── [slug].vue → /blog/:slug
|
||||
├── users/
|
||||
│ └── [id]/
|
||||
│ └── profile.vue → /users/:id/profile
|
||||
├── [...slug].vue → /* (catch-all)
|
||||
├── [[optional]].vue → /:optional? (optional param)
|
||||
└── (marketing)/ → Route group (not in URL)
|
||||
└── pricing.vue → /pricing
|
||||
```
|
||||
|
||||
**Pages are optional**: Without `pages/`, no vue-router is included.
|
||||
|
||||
### `app/layouts/`
|
||||
|
||||
Layout components wrapping pages:
|
||||
|
||||
```
|
||||
layouts/
|
||||
├── default.vue → Default layout
|
||||
├── admin.vue → Admin layout
|
||||
└── blank.vue → No layout
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- layouts/default.vue -->
|
||||
<template>
|
||||
<div>
|
||||
<TheHeader />
|
||||
<slot />
|
||||
<TheFooter />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Use in pages:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
// layout: false // Disable layout
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### `app/middleware/`
|
||||
|
||||
Route middleware:
|
||||
|
||||
```
|
||||
middleware/
|
||||
├── auth.ts → Named middleware
|
||||
├── admin.ts → Named middleware
|
||||
└── logger.global.ts → Global middleware (runs on every route)
|
||||
```
|
||||
|
||||
### `app/plugins/`
|
||||
|
||||
Nuxt plugins (auto-registered):
|
||||
|
||||
```
|
||||
plugins/
|
||||
├── 01.analytics.ts → Order with number prefix
|
||||
├── 02.auth.ts
|
||||
├── vue-query.client.ts → Client-only plugin
|
||||
└── server-init.server.ts → Server-only plugin
|
||||
```
|
||||
|
||||
### `server/` Directory
|
||||
|
||||
Nitro server code:
|
||||
|
||||
```
|
||||
server/
|
||||
├── api/
|
||||
│ ├── users.ts → GET /api/users
|
||||
│ ├── users.post.ts → POST /api/users
|
||||
│ └── users/[id].ts → /api/users/:id
|
||||
├── routes/
|
||||
│ └── sitemap.xml.ts → /sitemap.xml
|
||||
├── middleware/
|
||||
│ └── auth.ts → Runs on every request
|
||||
├── plugins/
|
||||
│ └── db.ts → Server startup plugins
|
||||
└── utils/
|
||||
└── db.ts → Auto-imported server utilities
|
||||
```
|
||||
|
||||
### `public/` Directory
|
||||
|
||||
Static assets served at root URL:
|
||||
|
||||
```
|
||||
public/
|
||||
├── favicon.ico → /favicon.ico
|
||||
├── robots.txt → /robots.txt
|
||||
└── images/
|
||||
└── logo.png → /images/logo.png
|
||||
```
|
||||
|
||||
### `assets/` Directory
|
||||
|
||||
Build-processed assets:
|
||||
|
||||
```
|
||||
assets/
|
||||
├── css/
|
||||
│ └── main.css
|
||||
├── images/
|
||||
│ └── hero.png
|
||||
└── fonts/
|
||||
└── custom.woff2
|
||||
```
|
||||
|
||||
Reference in components:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<img src="~/assets/images/hero.png" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import '~/assets/css/main.css';
|
||||
</style>
|
||||
```
|
||||
|
||||
## Special Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/app.vue` | Root component (optional with pages/) |
|
||||
| `app/app.config.ts` | Runtime app configuration |
|
||||
| `app/error.vue` | Custom error page |
|
||||
| `app/router.options.ts` | Vue Router configuration (Nuxt 4) |
|
||||
| `app/spa-loading-template.html` | SPA loading UI |
|
||||
| `nuxt.config.ts` | Build-time configuration |
|
||||
| `.nuxtignore` | Ignore files from Nuxt |
|
||||
| `.env` | Environment variables |
|
||||
|
||||
## File Naming Conventions
|
||||
|
||||
| Pattern | Meaning |
|
||||
|---------|---------|
|
||||
| `[param]` | Dynamic route parameter |
|
||||
| `[[param]]` | Optional parameter |
|
||||
| `[...slug]` | Catch-all route |
|
||||
| `(group)` | Route group (not in URL) |
|
||||
| `.client.vue` | Client-only component |
|
||||
| `.server.vue` | Server-only component |
|
||||
| `.global.ts` | Global middleware |
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/directory-structure
|
||||
- https://nuxt.com/docs/directory-structure/app
|
||||
- https://nuxt.com/docs/directory-structure/server
|
||||
-->
|
||||
292
.claude/skills/nuxt/references/core-modules.md
Normal file
292
.claude/skills/nuxt/references/core-modules.md
Normal file
@@ -0,0 +1,292 @@
|
||||
---
|
||||
name: nuxt-modules
|
||||
description: Creating and using Nuxt modules to extend framework functionality
|
||||
---
|
||||
|
||||
# Nuxt Modules
|
||||
|
||||
Modules extend Nuxt's core functionality. They run at build time and can add components, composables, plugins, and configuration.
|
||||
|
||||
## Using Modules
|
||||
|
||||
Install and add to `nuxt.config.ts`:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
modules: [
|
||||
// npm package
|
||||
'@nuxt/ui',
|
||||
// Local module
|
||||
'./modules/my-module',
|
||||
// Inline module
|
||||
(options, nuxt) => {
|
||||
console.log('Inline module')
|
||||
},
|
||||
// With options
|
||||
['@nuxt/image', { provider: 'cloudinary' }],
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Creating Modules
|
||||
|
||||
### Basic Module
|
||||
|
||||
```ts
|
||||
// modules/my-module.ts
|
||||
export default defineNuxtModule({
|
||||
meta: {
|
||||
name: 'my-module',
|
||||
configKey: 'myModule',
|
||||
},
|
||||
defaults: {
|
||||
enabled: true,
|
||||
},
|
||||
setup(options, nuxt) {
|
||||
if (!options.enabled) return
|
||||
|
||||
console.log('My module is running!')
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Adding Components
|
||||
|
||||
```ts
|
||||
// modules/ui/index.ts
|
||||
import { addComponent, createResolver } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup(options, nuxt) {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
// Add single component
|
||||
addComponent({
|
||||
name: 'MyButton',
|
||||
filePath: resolve('./runtime/components/MyButton.vue'),
|
||||
})
|
||||
|
||||
// Add components directory
|
||||
addComponentsDir({
|
||||
path: resolve('./runtime/components'),
|
||||
prefix: 'My',
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Adding Composables
|
||||
|
||||
```ts
|
||||
// modules/utils/index.ts
|
||||
import { addImports, createResolver } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup() {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
// Add auto-imported composable
|
||||
addImports({
|
||||
name: 'useMyUtil',
|
||||
from: resolve('./runtime/composables/useMyUtil'),
|
||||
})
|
||||
|
||||
// Add directory for auto-imports
|
||||
addImportsDir(resolve('./runtime/composables'))
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Adding Plugins
|
||||
|
||||
```ts
|
||||
// modules/analytics/index.ts
|
||||
import { addPlugin, createResolver } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup() {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
addPlugin({
|
||||
src: resolve('./runtime/plugin'),
|
||||
mode: 'client', // 'client', 'server', or 'all'
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Plugin file:
|
||||
|
||||
```ts
|
||||
// modules/analytics/runtime/plugin.ts
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
console.log('Page loaded')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Adding Server Routes
|
||||
|
||||
```ts
|
||||
// modules/api/index.ts
|
||||
import { addServerHandler, createResolver } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup() {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
addServerHandler({
|
||||
route: '/api/my-endpoint',
|
||||
handler: resolve('./runtime/server/api/my-endpoint'),
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Extending Config
|
||||
|
||||
```ts
|
||||
// modules/config/index.ts
|
||||
export default defineNuxtModule({
|
||||
setup(options, nuxt) {
|
||||
// Add CSS
|
||||
nuxt.options.css.push('my-module/styles.css')
|
||||
|
||||
// Add runtime config
|
||||
nuxt.options.runtimeConfig.public.myModule = {
|
||||
apiUrl: options.apiUrl,
|
||||
}
|
||||
|
||||
// Extend Vite config
|
||||
nuxt.options.vite.optimizeDeps ||= {}
|
||||
nuxt.options.vite.optimizeDeps.include ||= []
|
||||
nuxt.options.vite.optimizeDeps.include.push('some-package')
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Module Hooks
|
||||
|
||||
```ts
|
||||
export default defineNuxtModule({
|
||||
setup(options, nuxt) {
|
||||
// Build-time hooks
|
||||
nuxt.hook('modules:done', () => {
|
||||
console.log('All modules loaded')
|
||||
})
|
||||
|
||||
nuxt.hook('components:dirs', (dirs) => {
|
||||
dirs.push({ path: '~/extra-components' })
|
||||
})
|
||||
|
||||
nuxt.hook('pages:extend', (pages) => {
|
||||
pages.push({
|
||||
name: 'custom-page',
|
||||
path: '/custom',
|
||||
file: resolve('./runtime/pages/custom.vue'),
|
||||
})
|
||||
})
|
||||
|
||||
nuxt.hook('imports:extend', (imports) => {
|
||||
imports.push({ name: 'myHelper', from: 'my-package' })
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Module Options
|
||||
|
||||
Type-safe options with defaults:
|
||||
|
||||
```ts
|
||||
export interface ModuleOptions {
|
||||
apiKey: string
|
||||
enabled?: boolean
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
export default defineNuxtModule<ModuleOptions>({
|
||||
meta: {
|
||||
name: 'my-module',
|
||||
configKey: 'myModule',
|
||||
},
|
||||
defaults: {
|
||||
enabled: true,
|
||||
prefix: 'My',
|
||||
},
|
||||
setup(options, nuxt) {
|
||||
// options is typed as ModuleOptions
|
||||
if (!options.apiKey) {
|
||||
console.warn('API key not provided')
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
modules: ['my-module'],
|
||||
myModule: {
|
||||
apiKey: 'xxx',
|
||||
prefix: 'Custom',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Local Modules
|
||||
|
||||
Place in `modules/` directory:
|
||||
|
||||
```
|
||||
modules/
|
||||
├── my-module/
|
||||
│ ├── index.ts
|
||||
│ └── runtime/
|
||||
│ ├── components/
|
||||
│ ├── composables/
|
||||
│ └── plugin.ts
|
||||
```
|
||||
|
||||
Auto-registered or manually added:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
modules: [
|
||||
'~/modules/my-module', // Explicit
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Module Dependencies
|
||||
|
||||
```ts
|
||||
export default defineNuxtModule({
|
||||
meta: {
|
||||
name: 'my-module',
|
||||
},
|
||||
moduleDependencies: {
|
||||
'@nuxt/image': {
|
||||
version: '>=1.0.0',
|
||||
defaults: {
|
||||
provider: 'ipx',
|
||||
},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
// @nuxt/image is guaranteed to be installed
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/guide/modules
|
||||
- https://nuxt.com/docs/guide/modules/module-anatomy
|
||||
- https://nuxt.com/docs/api/kit
|
||||
-->
|
||||
226
.claude/skills/nuxt/references/core-routing.md
Normal file
226
.claude/skills/nuxt/references/core-routing.md
Normal file
@@ -0,0 +1,226 @@
|
||||
---
|
||||
name: routing
|
||||
description: File-based routing, dynamic routes, navigation, and middleware in Nuxt
|
||||
---
|
||||
|
||||
# Routing
|
||||
|
||||
Nuxt uses file-system routing based on vue-router. Files in `app/pages/` automatically create routes.
|
||||
|
||||
## Basic Routing
|
||||
|
||||
```
|
||||
pages/
|
||||
├── index.vue → /
|
||||
├── about.vue → /about
|
||||
└── posts/
|
||||
├── index.vue → /posts
|
||||
└── [id].vue → /posts/:id
|
||||
```
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
Use brackets for dynamic segments:
|
||||
|
||||
```
|
||||
pages/
|
||||
├── users/
|
||||
│ └── [id].vue → /users/:id
|
||||
├── posts/
|
||||
│ └── [...slug].vue → /posts/* (catch-all)
|
||||
└── [[optional]].vue → /:optional? (optional param)
|
||||
```
|
||||
|
||||
Access route parameters:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
// /posts/123 → route.params.id = '123'
|
||||
console.log(route.params.id)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
### NuxtLink Component
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<nav>
|
||||
<NuxtLink to="/">Home</NuxtLink>
|
||||
<NuxtLink to="/about">About</NuxtLink>
|
||||
<NuxtLink :to="{ name: 'posts-id', params: { id: 1 } }">Post 1</NuxtLink>
|
||||
</nav>
|
||||
</template>
|
||||
```
|
||||
|
||||
NuxtLink automatically prefetches linked pages when they enter the viewport.
|
||||
|
||||
### Programmatic Navigation
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const router = useRouter()
|
||||
|
||||
function goToPost(id: number) {
|
||||
navigateTo(`/posts/${id}`)
|
||||
// or
|
||||
router.push({ name: 'posts-id', params: { id } })
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Route Middleware
|
||||
|
||||
### Named Middleware
|
||||
|
||||
```ts
|
||||
// middleware/auth.ts
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const isAuthenticated = false // Your auth logic
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Apply to pages:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
// or multiple: middleware: ['auth', 'admin']
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Global Middleware
|
||||
|
||||
Name files with `.global` suffix:
|
||||
|
||||
```ts
|
||||
// middleware/logging.global.ts
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
console.log('Navigating to:', to.path)
|
||||
})
|
||||
```
|
||||
|
||||
### Inline Middleware
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: [
|
||||
function (to, from) {
|
||||
// Inline middleware logic
|
||||
},
|
||||
],
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Page Meta
|
||||
|
||||
Configure page-level options:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
title: 'My Page',
|
||||
layout: 'custom',
|
||||
middleware: 'auth',
|
||||
validate: (route) => {
|
||||
// Return false for 404, or object with status/statusText
|
||||
return /^\d+$/.test(route.params.id as string)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Route Validation
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
validate: (route) => {
|
||||
// Must return boolean or object with status
|
||||
return typeof route.params.id === 'string' && /^\d+$/.test(route.params.id)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Layouts
|
||||
|
||||
Define layouts in `app/layouts/`:
|
||||
|
||||
```vue
|
||||
<!-- layouts/default.vue -->
|
||||
<template>
|
||||
<div>
|
||||
<header>Header</header>
|
||||
<slot />
|
||||
<footer>Footer</footer>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- layouts/admin.vue -->
|
||||
<template>
|
||||
<div class="admin">
|
||||
<AdminSidebar />
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Use in pages:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
Dynamic layout:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const layout = ref('default')
|
||||
|
||||
function enableAdmin() {
|
||||
setPageLayout('admin')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Navigation Hooks
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
onBeforeRouteLeave((to, from) => {
|
||||
// Confirm before leaving
|
||||
const answer = window.confirm('Leave?')
|
||||
if (!answer) return false
|
||||
})
|
||||
|
||||
onBeforeRouteUpdate((to, from) => {
|
||||
// Called when route changes but component is reused
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/getting-started/routing
|
||||
- https://nuxt.com/docs/directory-structure/app/pages
|
||||
- https://nuxt.com/docs/directory-structure/app/middleware
|
||||
-->
|
||||
328
.claude/skills/nuxt/references/features-components-autoimport.md
Normal file
328
.claude/skills/nuxt/references/features-components-autoimport.md
Normal file
@@ -0,0 +1,328 @@
|
||||
---
|
||||
name: components-auto-imports
|
||||
description: Auto-imported components, lazy loading, and hydration strategies
|
||||
---
|
||||
|
||||
# Components Auto-imports
|
||||
|
||||
Nuxt automatically imports Vue components from `app/components/` directory.
|
||||
|
||||
## Basic Auto-imports
|
||||
|
||||
```
|
||||
components/
|
||||
├── Button.vue → <Button />
|
||||
├── Card.vue → <Card />
|
||||
└── AppHeader.vue → <AppHeader />
|
||||
```
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- No imports needed -->
|
||||
<AppHeader />
|
||||
<Card>
|
||||
<Button>Click me</Button>
|
||||
</Card>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Nested Directory Names
|
||||
|
||||
Component names include directory path:
|
||||
|
||||
```
|
||||
components/
|
||||
├── base/
|
||||
│ └── Button.vue → <BaseButton />
|
||||
├── form/
|
||||
│ ├── Input.vue → <FormInput />
|
||||
│ └── Select.vue → <FormSelect />
|
||||
└── ui/
|
||||
└── modal/
|
||||
└── Dialog.vue → <UiModalDialog />
|
||||
```
|
||||
|
||||
### Disable Path Prefix
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
components: [
|
||||
{
|
||||
path: '~/components',
|
||||
pathPrefix: false, // Use filename only
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
With `pathPrefix: false`:
|
||||
```
|
||||
components/base/Button.vue → <Button />
|
||||
```
|
||||
|
||||
## Lazy Loading
|
||||
|
||||
Prefix with `Lazy` for dynamic imports:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const showChart = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Component code loaded only when rendered -->
|
||||
<LazyHeavyChart v-if="showChart" />
|
||||
<button @click="showChart = true">Show Chart</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Reduces initial bundle size
|
||||
- Code-splits component into separate chunk
|
||||
- Loads on-demand
|
||||
|
||||
## Lazy Hydration Strategies
|
||||
|
||||
Control when lazy components become interactive:
|
||||
|
||||
### `hydrate-on-visible`
|
||||
|
||||
Hydrate when component enters viewport:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LazyComments hydrate-on-visible />
|
||||
</template>
|
||||
```
|
||||
|
||||
### `hydrate-on-idle`
|
||||
|
||||
Hydrate when browser is idle:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LazyAnalytics hydrate-on-idle />
|
||||
</template>
|
||||
```
|
||||
|
||||
### `hydrate-on-interaction`
|
||||
|
||||
Hydrate on user interaction:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Hydrates on click, focus, or pointerenter -->
|
||||
<LazyDropdown hydrate-on-interaction />
|
||||
|
||||
<!-- Specific event -->
|
||||
<LazyTooltip hydrate-on-interaction="mouseover" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### `hydrate-on-media-query`
|
||||
|
||||
Hydrate when media query matches:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LazyMobileMenu hydrate-on-media-query="(max-width: 768px)" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### `hydrate-after`
|
||||
|
||||
Hydrate after delay (milliseconds):
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LazyAds :hydrate-after="3000" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### `hydrate-when`
|
||||
|
||||
Hydrate on condition:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const isReady = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LazyEditor :hydrate-when="isReady" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### `hydrate-never`
|
||||
|
||||
Never hydrate (static only):
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LazyStaticFooter hydrate-never />
|
||||
</template>
|
||||
```
|
||||
|
||||
### Hydration Event
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LazyChart hydrate-on-visible @hydrated="onChartReady" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function onChartReady() {
|
||||
console.log('Chart is now interactive')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Client/Server Components
|
||||
|
||||
### Client-only (`.client.vue`)
|
||||
|
||||
```
|
||||
components/
|
||||
└── BrowserChart.client.vue
|
||||
```
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Only rendered in browser -->
|
||||
<BrowserChart />
|
||||
</template>
|
||||
```
|
||||
|
||||
### Server-only (`.server.vue`)
|
||||
|
||||
```
|
||||
components/
|
||||
└── ServerMarkdown.server.vue
|
||||
```
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Rendered on server, not hydrated -->
|
||||
<ServerMarkdown :content="markdown" />
|
||||
</template>
|
||||
```
|
||||
|
||||
Requires experimental flag:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
experimental: {
|
||||
componentIslands: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Paired Components
|
||||
|
||||
```
|
||||
components/
|
||||
├── Comments.client.vue # Browser version
|
||||
└── Comments.server.vue # SSR version
|
||||
```
|
||||
|
||||
Server version renders during SSR, client version takes over after hydration.
|
||||
|
||||
## Dynamic Components
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { SomeComponent } from '#components'
|
||||
|
||||
const dynamicComponent = resolveComponent('MyButton')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="dynamicComponent" />
|
||||
<component :is="SomeComponent" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Direct Imports
|
||||
|
||||
Bypass auto-imports when needed:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { LazyMountainsList, NuxtLink } from '#components'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Custom Directories
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
components: [
|
||||
{ path: '~/components/ui', prefix: 'Ui' },
|
||||
{ path: '~/components/forms', prefix: 'Form' },
|
||||
'~/components', // Default, should come last
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Global Components
|
||||
|
||||
Register globally (creates async chunks):
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
components: {
|
||||
global: true,
|
||||
dirs: ['~/components'],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Or use `.global.vue` suffix:
|
||||
|
||||
```
|
||||
components/
|
||||
└── Icon.global.vue → Available globally
|
||||
```
|
||||
|
||||
## Disabling Component Auto-imports
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
components: {
|
||||
dirs: [], // Disable auto-imports
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Library Authors
|
||||
|
||||
Register components from npm package:
|
||||
|
||||
```ts
|
||||
// my-ui-lib/nuxt.ts
|
||||
import { addComponentsDir, createResolver, defineNuxtModule } from '@nuxt/kit'
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup() {
|
||||
const resolver = createResolver(import.meta.url)
|
||||
|
||||
addComponentsDir({
|
||||
path: resolver.resolve('./components'),
|
||||
prefix: 'MyUi',
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/directory-structure/app/components
|
||||
- https://nuxt.com/docs/guide/concepts/auto-imports#auto-imported-components
|
||||
-->
|
||||
272
.claude/skills/nuxt/references/features-components.md
Normal file
272
.claude/skills/nuxt/references/features-components.md
Normal file
@@ -0,0 +1,272 @@
|
||||
---
|
||||
name: built-in-components
|
||||
description: NuxtLink, NuxtPage, NuxtLayout, and other built-in Nuxt components
|
||||
---
|
||||
|
||||
# Built-in Components
|
||||
|
||||
Nuxt provides several built-in components for common functionality.
|
||||
|
||||
## NuxtLink
|
||||
|
||||
Optimized link component with prefetching:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Basic usage -->
|
||||
<NuxtLink to="/about">About</NuxtLink>
|
||||
|
||||
<!-- With route object -->
|
||||
<NuxtLink :to="{ name: 'posts-id', params: { id: 1 } }">Post 1</NuxtLink>
|
||||
|
||||
<!-- External link (opens in new tab) -->
|
||||
<NuxtLink to="https://nuxt.com" external>Nuxt</NuxtLink>
|
||||
|
||||
<!-- Disable prefetching -->
|
||||
<NuxtLink to="/heavy-page" :prefetch="false">Heavy Page</NuxtLink>
|
||||
|
||||
<!-- Replace history instead of push -->
|
||||
<NuxtLink to="/page" replace>Replace</NuxtLink>
|
||||
|
||||
<!-- Custom active class -->
|
||||
<NuxtLink
|
||||
to="/dashboard"
|
||||
active-class="text-primary"
|
||||
exact-active-class="font-bold"
|
||||
>
|
||||
Dashboard
|
||||
</NuxtLink>
|
||||
</template>
|
||||
```
|
||||
|
||||
## NuxtPage
|
||||
|
||||
Renders the current page component (used in layouts):
|
||||
|
||||
```vue
|
||||
<!-- app/app.vue -->
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
```
|
||||
|
||||
With page transitions:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<NuxtPage :transition="{ name: 'fade', mode: 'out-in' }" />
|
||||
</template>
|
||||
```
|
||||
|
||||
Pass props to page:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<NuxtPage :page-key="route.fullPath" :foobar="123" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## NuxtLayout
|
||||
|
||||
Controls layout rendering:
|
||||
|
||||
```vue
|
||||
<!-- app/app.vue -->
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
```
|
||||
|
||||
Dynamic layout:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<NuxtLayout :name="layout">
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const layout = computed(() => isAdmin ? 'admin' : 'default')
|
||||
</script>
|
||||
```
|
||||
|
||||
Layout with transitions:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<NuxtLayout :transition="{ name: 'slide', mode: 'out-in' }">
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
```
|
||||
|
||||
## NuxtLoadingIndicator
|
||||
|
||||
Progress bar for page navigation:
|
||||
|
||||
```vue
|
||||
<!-- app/app.vue -->
|
||||
<template>
|
||||
<NuxtLoadingIndicator
|
||||
color="repeating-linear-gradient(to right, #00dc82 0%, #34cdfe 50%, #0047e1 100%)"
|
||||
:height="3"
|
||||
:duration="2000"
|
||||
:throttle="200"
|
||||
/>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
```
|
||||
|
||||
## NuxtErrorBoundary
|
||||
|
||||
Catch and handle errors in child components:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<NuxtErrorBoundary @error="handleError">
|
||||
<ComponentThatMightFail />
|
||||
|
||||
<template #error="{ error, clearError }">
|
||||
<div class="error">
|
||||
<p>Something went wrong: {{ error.message }}</p>
|
||||
<button @click="clearError">Try again</button>
|
||||
</div>
|
||||
</template>
|
||||
</NuxtErrorBoundary>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function handleError(error) {
|
||||
console.error('Error caught:', error)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## ClientOnly
|
||||
|
||||
Render content only on client-side:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<!-- Browser-only component -->
|
||||
<BrowserOnlyChart :data="chartData" />
|
||||
|
||||
<template #fallback>
|
||||
<p>Loading chart...</p>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
```
|
||||
|
||||
## DevOnly
|
||||
|
||||
Render content only in development:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<DevOnly>
|
||||
<DebugPanel />
|
||||
</DevOnly>
|
||||
</template>
|
||||
```
|
||||
|
||||
## NuxtIsland
|
||||
|
||||
Server components (experimental):
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<NuxtIsland name="HeavyComponent" :props="{ data: complexData }" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## NuxtImg and NuxtPicture
|
||||
|
||||
Optimized images (requires `@nuxt/image` module):
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Basic optimized image -->
|
||||
<NuxtImg src="/images/hero.jpg" width="800" height="600" />
|
||||
|
||||
<!-- Responsive with srcset -->
|
||||
<NuxtImg
|
||||
src="/images/hero.jpg"
|
||||
sizes="sm:100vw md:50vw lg:400px"
|
||||
:modifiers="{ format: 'webp' }"
|
||||
/>
|
||||
|
||||
<!-- Art direction with picture -->
|
||||
<NuxtPicture
|
||||
src="/images/hero.jpg"
|
||||
:img-attrs="{ alt: 'Hero image' }"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Teleport
|
||||
|
||||
Render content outside component tree:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<button @click="showModal = true">Open Modal</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showModal" class="modal">
|
||||
<p>Modal content</p>
|
||||
<button @click="showModal = false">Close</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
```
|
||||
|
||||
For SSR, use `<ClientOnly>` with Teleport:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<Teleport to="#teleports">
|
||||
<Modal />
|
||||
</Teleport>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
```
|
||||
|
||||
## NuxtRouteAnnouncer
|
||||
|
||||
Accessibility: announces page changes to screen readers. **Included by default in Nuxt 4's generated `app.vue`:**
|
||||
|
||||
```vue
|
||||
<!-- app/app.vue (Nuxt 4 default) -->
|
||||
<template>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
```
|
||||
|
||||
Customize the announcement:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<NuxtRouteAnnouncer :atomic="true" />
|
||||
</template>
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/api/components/nuxt-link
|
||||
- https://nuxt.com/docs/api/components/nuxt-page
|
||||
- https://nuxt.com/docs/api/components/nuxt-layout
|
||||
- https://nuxt.com/docs/api/components/client-only
|
||||
-->
|
||||
296
.claude/skills/nuxt/references/features-composables.md
Normal file
296
.claude/skills/nuxt/references/features-composables.md
Normal file
@@ -0,0 +1,296 @@
|
||||
---
|
||||
name: composables-auto-imports
|
||||
description: Auto-imported Vue APIs, Nuxt composables, and custom utilities
|
||||
---
|
||||
|
||||
# Composables Auto-imports
|
||||
|
||||
Nuxt automatically imports Vue APIs, Nuxt composables, and your custom composables/utilities.
|
||||
|
||||
## Built-in Auto-imports
|
||||
|
||||
### Vue APIs
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// No imports needed - all auto-imported
|
||||
const count = ref(0)
|
||||
const doubled = computed(() => count.value * 2)
|
||||
|
||||
watch(count, (newVal) => {
|
||||
console.log('Count changed:', newVal)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
console.log('Component mounted')
|
||||
})
|
||||
|
||||
// Lifecycle hooks
|
||||
onBeforeMount(() => {})
|
||||
onUnmounted(() => {})
|
||||
onBeforeUnmount(() => {})
|
||||
|
||||
// Reactivity
|
||||
const state = reactive({ name: 'John' })
|
||||
const shallow = shallowRef({ deep: 'object' })
|
||||
</script>
|
||||
```
|
||||
|
||||
### Nuxt Composables
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// All auto-imported
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const config = useRuntimeConfig()
|
||||
const appConfig = useAppConfig()
|
||||
const nuxtApp = useNuxtApp()
|
||||
|
||||
// Data fetching
|
||||
const { data } = await useFetch('/api/data')
|
||||
const { data: asyncData } = await useAsyncData('key', () => fetchData())
|
||||
|
||||
// State
|
||||
const state = useState('key', () => 'initial')
|
||||
const cookie = useCookie('token')
|
||||
|
||||
// Head/SEO
|
||||
useHead({ title: 'My Page' })
|
||||
useSeoMeta({ description: 'Page description' })
|
||||
|
||||
// Request helpers (SSR)
|
||||
const headers = useRequestHeaders()
|
||||
const event = useRequestEvent()
|
||||
const url = useRequestURL()
|
||||
|
||||
// New in Nuxt 4
|
||||
const host = useRequestHeader('host') // Single header (replaces useRequestHeaders(['host']))
|
||||
useResponseHeader('x-custom', 'value') // Set response header
|
||||
const announcer = useRouteAnnouncer() // Accessibility: route change announcements
|
||||
</script>
|
||||
```
|
||||
|
||||
## Custom Composables (`app/composables/`)
|
||||
|
||||
### Creating Composables
|
||||
|
||||
```ts
|
||||
// composables/useCounter.ts
|
||||
export function useCounter(initial = 0) {
|
||||
const count = ref(initial)
|
||||
const increment = () => count.value++
|
||||
const decrement = () => count.value--
|
||||
return { count, increment, decrement }
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// composables/useAuth.ts
|
||||
export function useAuth() {
|
||||
const user = useState<User | null>('user', () => null)
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
|
||||
async function login(credentials: Credentials) {
|
||||
user.value = await $fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: credentials,
|
||||
})
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
user.value = null
|
||||
}
|
||||
|
||||
return { user, isLoggedIn, login, logout }
|
||||
}
|
||||
```
|
||||
|
||||
### Using Composables
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Auto-imported - no import statement needed
|
||||
const { count, increment } = useCounter(10)
|
||||
const { user, isLoggedIn, login } = useAuth()
|
||||
</script>
|
||||
```
|
||||
|
||||
### File Scanning Rules
|
||||
|
||||
Only top-level files are scanned:
|
||||
|
||||
```
|
||||
composables/
|
||||
├── useAuth.ts → useAuth() ✓
|
||||
├── useCounter.ts → useCounter() ✓
|
||||
├── index.ts → exports ✓
|
||||
└── nested/
|
||||
└── helper.ts → NOT auto-imported ✗
|
||||
```
|
||||
|
||||
Re-export nested composables:
|
||||
|
||||
```ts
|
||||
// composables/index.ts
|
||||
export { useHelper } from './nested/helper'
|
||||
```
|
||||
|
||||
Or configure scanning:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
imports: {
|
||||
dirs: [
|
||||
'composables',
|
||||
'composables/**', // Scan all nested
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Utilities (`app/utils/`)
|
||||
|
||||
```ts
|
||||
// utils/format.ts
|
||||
export function formatDate(date: Date) {
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(amount)
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Auto-imported
|
||||
const date = formatDate(new Date())
|
||||
const price = formatCurrency(99.99)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Server Utils (`server/utils/`)
|
||||
|
||||
```ts
|
||||
// server/utils/db.ts
|
||||
export function useDb() {
|
||||
return createDbConnection()
|
||||
}
|
||||
|
||||
// server/utils/auth.ts
|
||||
export function verifyToken(token: string) {
|
||||
return jwt.verify(token, process.env.JWT_SECRET)
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/api/users.ts
|
||||
export default defineEventHandler(() => {
|
||||
const db = useDb() // Auto-imported
|
||||
return db.query('SELECT * FROM users')
|
||||
})
|
||||
```
|
||||
|
||||
## Third-party Package Imports
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
imports: {
|
||||
presets: [
|
||||
{
|
||||
from: 'vue-i18n',
|
||||
imports: ['useI18n'],
|
||||
},
|
||||
{
|
||||
from: 'date-fns',
|
||||
imports: ['format', 'parseISO', 'differenceInDays'],
|
||||
},
|
||||
{
|
||||
from: '@vueuse/core',
|
||||
imports: ['useMouse', 'useWindowSize'],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Explicit Imports
|
||||
|
||||
Use `#imports` alias when needed:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useFetch } from '#imports'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Composable Context Rules
|
||||
|
||||
Nuxt composables must be called in valid context:
|
||||
|
||||
```ts
|
||||
// ❌ Wrong - module level
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
export function useMyComposable() {}
|
||||
```
|
||||
|
||||
```ts
|
||||
// ✅ Correct - inside function
|
||||
export function useMyComposable() {
|
||||
const config = useRuntimeConfig()
|
||||
return { apiBase: config.public.apiBase }
|
||||
}
|
||||
```
|
||||
|
||||
**Valid contexts:**
|
||||
- `<script setup>` block
|
||||
- `setup()` function
|
||||
- `defineNuxtPlugin()` callback
|
||||
- `defineNuxtRouteMiddleware()` callback
|
||||
|
||||
### Running Outside Context (Nuxt 4)
|
||||
|
||||
Use `runWithContext` to call composables outside setup:
|
||||
|
||||
```ts
|
||||
// plugins/example.ts
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
someLibrary.onEvent(async () => {
|
||||
// runWithContext restores the Nuxt context
|
||||
await nuxtApp.runWithContext(() => {
|
||||
const user = useUser() // Works inside runWithContext
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
## Disabling Auto-imports
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
// Disable all auto-imports
|
||||
imports: {
|
||||
autoImport: false,
|
||||
},
|
||||
|
||||
// Or disable only directory scanning (keep Vue/Nuxt imports)
|
||||
imports: {
|
||||
scan: false,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/guide/concepts/auto-imports
|
||||
- https://nuxt.com/docs/directory-structure/app/composables
|
||||
- https://nuxt.com/docs/directory-structure/app/utils
|
||||
-->
|
||||
265
.claude/skills/nuxt/references/features-server.md
Normal file
265
.claude/skills/nuxt/references/features-server.md
Normal file
@@ -0,0 +1,265 @@
|
||||
---
|
||||
name: server-routes
|
||||
description: API routes, server middleware, and Nitro server engine in Nuxt
|
||||
---
|
||||
|
||||
# Server Routes
|
||||
|
||||
Nuxt includes Nitro server engine for building full-stack applications with API routes and server middleware.
|
||||
|
||||
## API Routes
|
||||
|
||||
Create files in `server/api/` directory:
|
||||
|
||||
```ts
|
||||
// server/api/hello.ts
|
||||
export default defineEventHandler((event) => {
|
||||
return { message: 'Hello World' }
|
||||
})
|
||||
```
|
||||
|
||||
Access at `/api/hello`.
|
||||
|
||||
### HTTP Methods
|
||||
|
||||
```ts
|
||||
// server/api/users.get.ts - GET /api/users
|
||||
export default defineEventHandler(() => {
|
||||
return getUsers()
|
||||
})
|
||||
|
||||
// server/api/users.post.ts - POST /api/users
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
return createUser(body)
|
||||
})
|
||||
|
||||
// server/api/users/[id].put.ts - PUT /api/users/:id
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
return updateUser(id, body)
|
||||
})
|
||||
|
||||
// server/api/users/[id].delete.ts - DELETE /api/users/:id
|
||||
export default defineEventHandler((event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
return deleteUser(id)
|
||||
})
|
||||
```
|
||||
|
||||
### Route Parameters
|
||||
|
||||
```ts
|
||||
// server/api/posts/[id].ts
|
||||
export default defineEventHandler((event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
return getPost(id)
|
||||
})
|
||||
|
||||
// Catch-all: server/api/[...path].ts
|
||||
export default defineEventHandler((event) => {
|
||||
const path = getRouterParam(event, 'path')
|
||||
return { path }
|
||||
})
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
```ts
|
||||
// server/api/search.ts
|
||||
// GET /api/search?q=nuxt&page=1
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
// { q: 'nuxt', page: '1' }
|
||||
return search(query.q, Number(query.page))
|
||||
})
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
```ts
|
||||
// server/api/submit.post.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
// Validate and process body
|
||||
return { success: true, data: body }
|
||||
})
|
||||
```
|
||||
|
||||
### Headers and Cookies
|
||||
|
||||
```ts
|
||||
// server/api/auth.ts
|
||||
export default defineEventHandler((event) => {
|
||||
// Read headers
|
||||
const auth = getHeader(event, 'authorization')
|
||||
|
||||
// Read cookies
|
||||
const cookies = parseCookies(event)
|
||||
const token = getCookie(event, 'token')
|
||||
|
||||
// Set headers
|
||||
setHeader(event, 'X-Custom-Header', 'value')
|
||||
|
||||
// Set cookies
|
||||
setCookie(event, 'token', 'new-token', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
maxAge: 60 * 60 * 24, // 1 day
|
||||
})
|
||||
|
||||
return { authenticated: !!token }
|
||||
})
|
||||
```
|
||||
|
||||
## Server Middleware
|
||||
|
||||
Runs on every request before routes:
|
||||
|
||||
```ts
|
||||
// server/middleware/auth.ts
|
||||
export default defineEventHandler((event) => {
|
||||
const token = getCookie(event, 'token')
|
||||
|
||||
// Attach data to event context
|
||||
event.context.user = token ? verifyToken(token) : null
|
||||
})
|
||||
|
||||
// server/middleware/log.ts
|
||||
export default defineEventHandler((event) => {
|
||||
console.log(`${event.method} ${event.path}`)
|
||||
})
|
||||
```
|
||||
|
||||
Access context in routes:
|
||||
|
||||
```ts
|
||||
// server/api/profile.ts
|
||||
export default defineEventHandler((event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
return user
|
||||
})
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```ts
|
||||
// server/api/users/[id].ts
|
||||
export default defineEventHandler((event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
const user = findUser(id)
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'User not found',
|
||||
})
|
||||
}
|
||||
|
||||
return user
|
||||
})
|
||||
```
|
||||
|
||||
## Server Utils
|
||||
|
||||
Auto-imported in `server/utils/`:
|
||||
|
||||
```ts
|
||||
// server/utils/db.ts
|
||||
export function useDb() {
|
||||
return createDbConnection()
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/api/users.ts
|
||||
export default defineEventHandler(() => {
|
||||
const db = useDb() // Auto-imported
|
||||
return db.query('SELECT * FROM users')
|
||||
})
|
||||
```
|
||||
|
||||
## Server Plugins
|
||||
|
||||
Run once when server starts:
|
||||
|
||||
```ts
|
||||
// server/plugins/db.ts
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
// Initialize database connection
|
||||
const db = createDbConnection()
|
||||
|
||||
// Add to context
|
||||
nitroApp.hooks.hook('request', (event) => {
|
||||
event.context.db = db
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Streaming Responses
|
||||
|
||||
```ts
|
||||
// server/api/stream.ts
|
||||
export default defineEventHandler((event) => {
|
||||
setHeader(event, 'Content-Type', 'text/event-stream')
|
||||
setHeader(event, 'Cache-Control', 'no-cache')
|
||||
setHeader(event, 'Connection', 'keep-alive')
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
controller.enqueue(`data: ${JSON.stringify({ count: i })}\n\n`)
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
return stream
|
||||
})
|
||||
```
|
||||
|
||||
## Server Storage
|
||||
|
||||
Key-value storage with multiple drivers:
|
||||
|
||||
```ts
|
||||
// server/api/cache.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const storage = useStorage()
|
||||
|
||||
// Set value
|
||||
await storage.setItem('key', { data: 'value' })
|
||||
|
||||
// Get value
|
||||
const data = await storage.getItem('key')
|
||||
|
||||
return data
|
||||
})
|
||||
```
|
||||
|
||||
Configure storage drivers in `nuxt.config.ts`:
|
||||
|
||||
```ts
|
||||
export default defineNuxtConfig({
|
||||
nitro: {
|
||||
storage: {
|
||||
redis: {
|
||||
driver: 'redis',
|
||||
url: 'redis://localhost:6379',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/getting-started/server
|
||||
- https://nuxt.com/docs/directory-structure/server
|
||||
- https://nitro.build/guide
|
||||
-->
|
||||
194
.claude/skills/nuxt/references/features-state.md
Normal file
194
.claude/skills/nuxt/references/features-state.md
Normal file
@@ -0,0 +1,194 @@
|
||||
---
|
||||
name: state-management
|
||||
description: useState composable and SSR-friendly state management in Nuxt
|
||||
---
|
||||
|
||||
# State Management
|
||||
|
||||
Nuxt provides `useState` for SSR-friendly reactive state that persists across components.
|
||||
|
||||
## useState
|
||||
|
||||
SSR-safe replacement for `ref` that shares state across components:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// State is shared by key 'counter' across all components
|
||||
const counter = useState('counter', () => 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
Counter: {{ counter }}
|
||||
<button @click="counter++">+</button>
|
||||
<button @click="counter--">-</button>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Creating Shared State
|
||||
|
||||
Define reusable state composables:
|
||||
|
||||
```ts
|
||||
// composables/useUser.ts
|
||||
export function useUser() {
|
||||
return useState<User | null>('user', () => null)
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
return useState('locale', () => 'en')
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Same state instance everywhere
|
||||
const user = useUser()
|
||||
const locale = useLocale()
|
||||
</script>
|
||||
```
|
||||
|
||||
## Initializing State
|
||||
|
||||
Use `callOnce` to initialize state with async data:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const config = useState('site-config')
|
||||
|
||||
await callOnce(async () => {
|
||||
config.value = await $fetch('/api/config')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ❌ Don't Define State Outside Setup
|
||||
|
||||
```ts
|
||||
// ❌ Wrong - causes memory leaks and shared state across requests
|
||||
export const globalState = ref({ user: null })
|
||||
```
|
||||
|
||||
### ✅ Use useState Instead
|
||||
|
||||
```ts
|
||||
// ✅ Correct - SSR-safe
|
||||
export const useGlobalState = () => useState('global', () => ({ user: null }))
|
||||
```
|
||||
|
||||
## Clearing State
|
||||
|
||||
```ts
|
||||
// Clear specific state
|
||||
clearNuxtState('counter')
|
||||
|
||||
// Clear multiple states
|
||||
clearNuxtState(['counter', 'user'])
|
||||
|
||||
// Clear all state (use with caution)
|
||||
clearNuxtState()
|
||||
```
|
||||
|
||||
## With Pinia
|
||||
|
||||
For complex state management, use Pinia:
|
||||
|
||||
```bash
|
||||
npx nuxi module add pinia
|
||||
```
|
||||
|
||||
```ts
|
||||
// stores/counter.ts
|
||||
export const useCounterStore = defineStore('counter', {
|
||||
state: () => ({
|
||||
count: 0,
|
||||
}),
|
||||
actions: {
|
||||
increment() {
|
||||
this.count++
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
// stores/user.ts (Composition API style)
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
|
||||
async function login(credentials: Credentials) {
|
||||
user.value = await $fetch('/api/login', {
|
||||
method: 'POST',
|
||||
body: credentials,
|
||||
})
|
||||
}
|
||||
|
||||
return { user, isLoggedIn, login }
|
||||
})
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const counterStore = useCounterStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// Initialize store data once
|
||||
await callOnce(async () => {
|
||||
await userStore.fetchUser()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Advanced: Locale Example
|
||||
|
||||
```ts
|
||||
// composables/useLocale.ts
|
||||
export function useLocale() {
|
||||
return useState('locale', () => useDefaultLocale().value)
|
||||
}
|
||||
|
||||
export function useDefaultLocale(fallback = 'en-US') {
|
||||
const locale = ref(fallback)
|
||||
|
||||
if (import.meta.server) {
|
||||
const reqLocale = useRequestHeaders()['accept-language']?.split(',')[0]
|
||||
if (reqLocale) locale.value = reqLocale
|
||||
}
|
||||
else if (import.meta.client) {
|
||||
const navLang = navigator.language
|
||||
if (navLang) locale.value = navLang
|
||||
}
|
||||
|
||||
return locale
|
||||
}
|
||||
```
|
||||
|
||||
## State Serialization
|
||||
|
||||
`useState` values are serialized to JSON. Avoid:
|
||||
|
||||
- Functions
|
||||
- Classes
|
||||
- Symbols
|
||||
- Circular references
|
||||
|
||||
```ts
|
||||
// ❌ Won't work
|
||||
useState('fn', () => () => console.log('hi'))
|
||||
useState('instance', () => new MyClass())
|
||||
|
||||
// ✅ Works
|
||||
useState('data', () => ({ name: 'John', age: 30 }))
|
||||
useState('items', () => ['a', 'b', 'c'])
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/getting-started/state-management
|
||||
- https://nuxt.com/docs/api/composables/use-state
|
||||
- https://nuxt.com/docs/api/utils/clear-nuxt-state
|
||||
-->
|
||||
239
.claude/skills/nuxt/references/rendering-modes.md
Normal file
239
.claude/skills/nuxt/references/rendering-modes.md
Normal file
@@ -0,0 +1,239 @@
|
||||
---
|
||||
name: rendering-modes
|
||||
description: Universal rendering, client-side rendering, and hybrid rendering in Nuxt
|
||||
---
|
||||
|
||||
# Rendering Modes
|
||||
|
||||
Nuxt supports multiple rendering modes: universal (SSR), client-side (CSR), and hybrid rendering.
|
||||
|
||||
## Universal Rendering (Default)
|
||||
|
||||
Server renders HTML, then hydrates on client:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts - this is the default
|
||||
export default defineNuxtConfig({
|
||||
ssr: true,
|
||||
})
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Fast initial page load (HTML is ready)
|
||||
- SEO-friendly (content is in HTML)
|
||||
- Works without JavaScript initially
|
||||
|
||||
**How it works:**
|
||||
1. Server executes Vue code, generates HTML
|
||||
2. Browser displays HTML immediately
|
||||
3. JavaScript loads and hydrates the page
|
||||
4. Page becomes fully interactive
|
||||
|
||||
## Client-Side Rendering
|
||||
|
||||
Render entirely in the browser:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
})
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Simpler development (no SSR constraints)
|
||||
- Cheaper hosting (static files only)
|
||||
- Works offline
|
||||
|
||||
**Use cases:**
|
||||
- Admin dashboards
|
||||
- SaaS applications
|
||||
- Apps behind authentication
|
||||
|
||||
### SPA Loading Template
|
||||
|
||||
Provide loading UI while app hydrates:
|
||||
|
||||
```html
|
||||
<!-- app/spa-loading-template.html -->
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #00dc82;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Hybrid Rendering
|
||||
|
||||
Mix rendering modes per route using route rules:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
routeRules: {
|
||||
// Static pages - prerendered at build
|
||||
'/': { prerender: true },
|
||||
'/about': { prerender: true },
|
||||
|
||||
// ISR - regenerate in background
|
||||
'/blog/**': { isr: 3600 }, // Cache for 1 hour
|
||||
'/products/**': { swr: true }, // Stale-while-revalidate
|
||||
|
||||
// Client-only rendering
|
||||
'/admin/**': { ssr: false },
|
||||
'/dashboard/**': { ssr: false },
|
||||
|
||||
// Server-rendered (default)
|
||||
'/api/**': { cors: true },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Route Rules Reference
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| `prerender: true` | Pre-render at build time |
|
||||
| `ssr: false` | Client-side only |
|
||||
| `swr: number \| true` | Stale-while-revalidate caching |
|
||||
| `isr: number \| true` | Incremental static regeneration |
|
||||
| `cache: { maxAge: number }` | Cache with TTL |
|
||||
| `redirect: string` | Redirect to another path |
|
||||
| `cors: true` | Add CORS headers |
|
||||
| `headers: object` | Custom response headers |
|
||||
|
||||
### Inline Route Rules
|
||||
|
||||
Define per-page:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
defineRouteRules({
|
||||
prerender: true,
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Prerendering
|
||||
|
||||
Generate static HTML at build time:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
// Prerender specific routes
|
||||
routeRules: {
|
||||
'/': { prerender: true },
|
||||
'/about': { prerender: true },
|
||||
'/posts/*': { prerender: true },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Or use `nuxt generate`:
|
||||
|
||||
```bash
|
||||
nuxt generate
|
||||
```
|
||||
|
||||
### Programmatic Prerendering
|
||||
|
||||
In Nuxt 4, `prerender:routes` receives a `Set` (not an array):
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
hooks: {
|
||||
async 'prerender:routes'({ routes }) {
|
||||
// routes is a Set<string> in Nuxt 4
|
||||
const posts = await fetchPostSlugs()
|
||||
for (const slug of posts) {
|
||||
routes.add(`/posts/${slug}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Or in pages:
|
||||
|
||||
```ts
|
||||
// server/api/posts.ts or a plugin
|
||||
prerenderRoutes(['/posts/1', '/posts/2', '/posts/3'])
|
||||
```
|
||||
|
||||
## Edge-Side Rendering
|
||||
|
||||
Render at CDN edge servers:
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
nitro: {
|
||||
preset: 'cloudflare-pages', // or 'vercel-edge', 'netlify-edge'
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Supported platforms:
|
||||
- Cloudflare Pages/Workers
|
||||
- Vercel Edge Functions
|
||||
- Netlify Edge Functions
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
Use `import.meta.server` and `import.meta.client`:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
if (import.meta.server) {
|
||||
// Server-only code
|
||||
console.log('Running on server')
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
// Client-only code
|
||||
console.log('Running in browser')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
For components:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<BrowserOnlyComponent />
|
||||
<template #fallback>
|
||||
<p>Loading...</p>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
```
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://nuxt.com/docs/guide/concepts/rendering
|
||||
- https://nuxt.com/docs/getting-started/prerendering
|
||||
- https://nuxt.com/docs/api/nuxt-config#routerules
|
||||
-->
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
4
app/app.vue
Normal file
4
app/app.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtPage />
|
||||
</template>
|
||||
19
app/components/InfoRow.vue
Normal file
19
app/components/InfoRow.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline; padding: 8px 0; border-bottom: 1px solid #e5e7eb;">
|
||||
<span style="font-size: 13px; color: #6b7280; flex-shrink: 0; margin-right: 16px;">{{ label }}</span>
|
||||
<span
|
||||
style="font-size: 13px; font-weight: 500; text-align: right; word-break: break-all;"
|
||||
:style="highlight ? { color: '#111827', fontFamily: 'monospace', fontSize: '12px' } : { color: '#111827' }"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
value: string
|
||||
highlight?: boolean
|
||||
}>()
|
||||
</script>
|
||||
50
app/components/PageLayout.vue
Normal file
50
app/components/PageLayout.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div style="font-family: sans-serif; max-width: 720px; margin: 60px auto; padding: 0 24px;">
|
||||
<!-- 헤더 -->
|
||||
<div style="margin-bottom: 32px;">
|
||||
<NuxtLink to="/" style="font-size: 14px; color: #6b7280; text-decoration: none;">← 목록으로</NuxtLink>
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-top: 12px;">
|
||||
<span
|
||||
style="font-size: 12px; font-weight: 600; padding: 3px 12px; border-radius: 99px; color: white;"
|
||||
:style="{ background: badgeColor }"
|
||||
>
|
||||
{{ badge }}
|
||||
</span>
|
||||
<h1 style="margin: 0; font-size: 24px;">{{ title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 데이터 카드 -->
|
||||
<div style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 10px; padding: 20px; margin-bottom: 24px;">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div style="margin-bottom: 24px;">
|
||||
<h3 style="font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 8px;">동작 원리</h3>
|
||||
<ul style="margin: 0; padding-left: 20px; font-size: 14px; color: #4b5563; line-height: 1.8;">
|
||||
<slot name="explain" />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 코드 -->
|
||||
<div>
|
||||
<h3 style="font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 8px;">설정 코드</h3>
|
||||
<div style="background: #1e293b; color: #e2e8f0; border-radius: 8px; padding: 16px; font-size: 13px; line-height: 1.6; overflow-x: auto;">
|
||||
<slot name="code" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
badge: string
|
||||
badgeColor: string
|
||||
title: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
pre { margin: 0; white-space: pre-wrap; word-break: break-all; }
|
||||
</style>
|
||||
103
app/pages/hybrid.vue
Normal file
103
app/pages/hybrid.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<PageLayout badge="Hybrid" badge-color="#8b5cf6" title="Hybrid / ISR">
|
||||
<InfoRow label="렌더링 방식" value="ISR (Incremental Static Regeneration)" />
|
||||
<InfoRow label="캐시 TTL" value="60초" />
|
||||
<InfoRow label="서버 렌더링 시간 (캐시됨)" :value="data?.time ?? '로딩 중...'" highlight />
|
||||
<InfoRow label="클라이언트 현재 시간 (실시간)" :value="clientTime" highlight />
|
||||
<InfoRow label="캐시 경과" :value="elapsedText" />
|
||||
|
||||
<!-- 환경 안내 배너 -->
|
||||
<div
|
||||
:style="{
|
||||
marginTop: '12px',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
background: isDev ? '#fef3c7' : '#d1fae5',
|
||||
color: isDev ? '#92400e' : '#065f46',
|
||||
border: isDev ? '1px solid #fcd34d' : '1px solid #6ee7b7',
|
||||
}"
|
||||
>
|
||||
<strong>{{ isDev ? '⚠ 개발 모드' : '✓ 프로덕션 모드' }}</strong>
|
||||
{{ isDev
|
||||
? ' — ISR은 개발 서버에서 동작하지 않습니다. nuxt build 후 nuxt preview로 확인하세요.'
|
||||
: ' — ISR이 활성화되어 있습니다. 서버 렌더링 시간이 60초간 고정됩니다.'
|
||||
}}
|
||||
</div>
|
||||
|
||||
<template #explain>
|
||||
<li>
|
||||
<strong>ISR 동작 흐름:</strong> 첫 요청 시 서버 렌더링 및 캐시 저장
|
||||
→ 60초 이내 재방문 시 캐시된 HTML 즉시 반환
|
||||
→ 60초 경과 후 첫 요청에서 캐시 반환 + 백그라운드 재생성
|
||||
→ 다음 요청부터 새 HTML 반환
|
||||
</li>
|
||||
<li>
|
||||
<strong>확인 방법:</strong>
|
||||
<ol style="margin: 4px 0 0; padding-left: 20px;">
|
||||
<li><code>npm run build</code> → <code>npm run preview</code> 실행</li>
|
||||
<li>이 페이지를 열고 <strong>서버 렌더링 시간</strong> 기록</li>
|
||||
<li>60초 이내 새로고침 → 서버 시간이 <strong>동일</strong>함 (캐시)</li>
|
||||
<li>60초 후 새로고침 → 여전히 이전 값 (캐시 반환 + 백그라운드 재생성 시작)</li>
|
||||
<li>한 번 더 새로고침 → 서버 시간이 <strong>변경</strong>됨 (새 HTML)</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li><code>swr: true</code>는 TTL 없이 항상 캐시를 반환하고 매 요청마다 백그라운드 갱신합니다.</li>
|
||||
<li>한 앱에서 SSR / SSG / SPA / ISR을 경로마다 다르게 혼합할 수 있습니다.</li>
|
||||
</template>
|
||||
|
||||
<template #code>
|
||||
<pre>{{ codeExample }}</pre>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data } = await useFetch('/api/time')
|
||||
|
||||
// 클라이언트 실시간 시계
|
||||
const clientTime = ref('')
|
||||
const elapsedText = ref('-')
|
||||
|
||||
let interval: ReturnType<typeof setInterval>
|
||||
|
||||
onMounted(() => {
|
||||
const serverTime = data.value?.time ? new Date(data.value.time) : null
|
||||
|
||||
interval = setInterval(() => {
|
||||
const now = new Date()
|
||||
clientTime.value = now.toISOString()
|
||||
|
||||
if (serverTime) {
|
||||
const elapsed = Math.floor((now.getTime() - serverTime.getTime()) / 1000)
|
||||
const remaining = Math.max(0, 60 - elapsed)
|
||||
elapsedText.value = elapsed < 60
|
||||
? `${elapsed}초 경과 — 캐시 유효 (${remaining}초 남음)`
|
||||
: `${elapsed}초 경과 — 캐시 만료, 다음 요청 시 재생성`
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => clearInterval(interval))
|
||||
|
||||
// 개발/프로덕션 구분
|
||||
const isDev = import.meta.dev
|
||||
|
||||
const codeExample = `// nuxt.config.ts — 경로별 전략 혼합
|
||||
export default defineNuxtConfig({
|
||||
routeRules: {
|
||||
'/': { prerender: true }, // SSG
|
||||
'/blog/**': { isr: 3600 }, // ISR (1시간 캐시)
|
||||
'/products/**': { swr: true }, // Stale-While-Revalidate
|
||||
'/dashboard/**':{ ssr: false }, // SPA
|
||||
'/hybrid': { isr: 60 }, // ISR (60초 캐시) ← 이 페이지
|
||||
},
|
||||
})
|
||||
|
||||
// ISR 확인 방법
|
||||
// 1. npm run build
|
||||
// 2. npm run preview
|
||||
// 3. /hybrid 접속 후 서버 렌더링 시간 기록
|
||||
// 4. 60초 이내 새로고침 → 동일한 시간 (캐시)
|
||||
// 5. 60초 후 새로고침 2회 → 시간 변경 (재생성 완료)`
|
||||
</script>
|
||||
105
app/pages/index.vue
Normal file
105
app/pages/index.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div style="font-family: sans-serif; max-width: 720px; margin: 60px auto; padding: 0 24px;">
|
||||
<h1>Nuxt 4 렌더링 전략 데모</h1>
|
||||
<p style="color: #666;">각 페이지에서 렌더링 방식의 차이를 확인해보세요.</p>
|
||||
|
||||
<div style="display: grid; gap: 16px; margin-top: 32px;">
|
||||
<NuxtLink
|
||||
v-for="page in pages"
|
||||
:key="page.to"
|
||||
:to="page.to"
|
||||
style="display: block; padding: 20px 24px; border: 1px solid #e5e7eb; border-radius: 10px; text-decoration: none; color: inherit;"
|
||||
>
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 6px;">
|
||||
<span
|
||||
style="font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 99px; color: white;"
|
||||
:style="{ background: page.color }"
|
||||
>
|
||||
{{ page.badge }}
|
||||
</span>
|
||||
<strong>{{ page.title }}</strong>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">{{ page.description }}</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 전략 비교표 -->
|
||||
<div style="margin-top: 48px;">
|
||||
<h2 style="font-size: 16px; font-weight: 600; margin-bottom: 16px;">전략 비교</h2>
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
|
||||
<thead>
|
||||
<tr style="background: #f9fafb;">
|
||||
<th v-for="col in table.cols" :key="col" style="padding: 10px 14px; text-align: left; border: 1px solid #e5e7eb; font-weight: 600; white-space: nowrap;">
|
||||
{{ col }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in table.rows" :key="row[0]">
|
||||
<td
|
||||
v-for="(cell, i) in row"
|
||||
:key="i"
|
||||
style="padding: 10px 14px; border: 1px solid #e5e7eb; white-space: nowrap;"
|
||||
:style="getCellStyle(cell, i)"
|
||||
v-html="cell"
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
function getCellStyle(cell: string, colIndex: number) {
|
||||
if (colIndex === 0) return { fontWeight: '600' }
|
||||
if (cell.includes('필요')) return { color: '#dc2626', fontWeight: '600' }
|
||||
if (cell === '✓') return { color: '#10b981', fontWeight: '700', textAlign: 'center' as const }
|
||||
if (cell === '✗') return { color: '#9ca3af', textAlign: 'center' as const }
|
||||
return { color: '#374151' }
|
||||
}
|
||||
|
||||
const table = {
|
||||
cols: ['전략', '콘텐츠 최신성', '재배포', 'SEO', '서버 부하', '적합한 경우'],
|
||||
rows: [
|
||||
['SSR', '항상 최신', '불필요', '✓', '높음', '실시간 데이터, 개인화 페이지'],
|
||||
['SSG', '빌드 시 고정', '변경 시 필요', '✓', '없음', '블로그, 문서, 변경이 드문 콘텐츠'],
|
||||
['ISR', 'N초마다 갱신', '불필요', '✓', '낮음', '상품 목록, 뉴스 (준실시간)'],
|
||||
['SWR', '매 요청 후 갱신', '불필요', '✓', '낮음', '대시보드 (약간의 지연 허용)'],
|
||||
['SPA', '항상 최신', '불필요', '✗', '없음', '인증 후 관리자 페이지'],
|
||||
],
|
||||
}
|
||||
|
||||
const pages = [
|
||||
{
|
||||
to: '/ssr',
|
||||
badge: 'SSR',
|
||||
color: '#3b82f6',
|
||||
title: 'Server-Side Rendering',
|
||||
description: '요청마다 서버에서 HTML을 생성합니다. 페이지를 새로고침할 때마다 서버 시간이 바뀝니다.',
|
||||
},
|
||||
{
|
||||
to: '/ssg',
|
||||
badge: 'SSG',
|
||||
color: '#10b981',
|
||||
title: 'Static Site Generation',
|
||||
description: '빌드 시점에 HTML을 미리 생성합니다. 새로고침해도 빌드 시간이 고정되어 있습니다.',
|
||||
},
|
||||
{
|
||||
to: '/spa',
|
||||
badge: 'SPA',
|
||||
color: '#f59e0b',
|
||||
title: 'Single Page Application',
|
||||
description: '클라이언트에서만 렌더링합니다. 초기 HTML은 비어있고, JS 실행 후 데이터가 나타납니다.',
|
||||
},
|
||||
{
|
||||
to: '/hybrid',
|
||||
badge: 'Hybrid',
|
||||
color: '#8b5cf6',
|
||||
title: 'Hybrid / ISR',
|
||||
description: 'nuxt.config의 routeRules로 경로마다 전략을 다르게 설정합니다. 이 페이지는 ISR(60초 캐시)입니다.',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
39
app/pages/spa.vue
Normal file
39
app/pages/spa.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<PageLayout badge="SPA" badge-color="#f59e0b" title="Single Page Application">
|
||||
<InfoRow label="렌더링 위치" value="브라우저 (클라이언트)" />
|
||||
<InfoRow
|
||||
label="클라이언트 시간"
|
||||
:value="data?.time ?? (pending ? '클라이언트에서 로딩 중...' : '-')"
|
||||
highlight
|
||||
/>
|
||||
<InfoRow label="메시지" :value="data?.message ?? ''" />
|
||||
<InfoRow label="서버 HTML 확인" value="페이지 소스 보기 → 데이터가 비어있음" />
|
||||
|
||||
<template #explain>
|
||||
<li><code>defineRouteRules({ ssr: false })</code>로 서버 렌더링을 비활성화합니다.</li>
|
||||
<li>서버는 <strong>빈 HTML 껍데기</strong>만 전달하고, 데이터는 브라우저에서 가져옵니다.</li>
|
||||
<li>페이지 소스(Ctrl+U)를 보면 데이터가 없습니다.</li>
|
||||
<li>인증 이후 대시보드, 관리자 페이지처럼 SEO가 불필요한 곳에 적합합니다.</li>
|
||||
</template>
|
||||
|
||||
<template #code>
|
||||
<pre>{{ codeExample }}</pre>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// nuxt.config.ts에서 설정됨: routeRules: { '/spa': { ssr: false } }
|
||||
// useFetch는 클라이언트에서만 실행됨
|
||||
const { data, pending } = await useFetch('/api/time')
|
||||
|
||||
const codeExample = `// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
routeRules: {
|
||||
'/spa': { ssr: false },
|
||||
},
|
||||
})
|
||||
|
||||
// useFetch → 서버에서 실행되지 않음
|
||||
// 브라우저 로드 후 클라이언트에서 /api/time 호출`
|
||||
</script>
|
||||
62
app/pages/ssg.vue
Normal file
62
app/pages/ssg.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<PageLayout badge="SSG" badge-color="#10b981" title="Static Site Generation">
|
||||
<InfoRow label="렌더링 위치" value="빌드 시 서버 (nuxt generate 1회)" />
|
||||
<InfoRow label="빌드 시 생성된 시간" :value="data?.time ?? '로딩 중...'" highlight />
|
||||
<InfoRow label="메시지" :value="data?.message ?? ''" />
|
||||
<InfoRow label="클라이언트 API 요청" value="없음 — payload에서 hydration" />
|
||||
|
||||
<template #explain>
|
||||
<li>
|
||||
<strong>nuxt.config</strong>의 <code>routeRules: { '/ssg': { prerender: true } }</code>로 설정합니다.
|
||||
</li>
|
||||
<li>
|
||||
<code>nuxt generate</code> 실행 시 <code>/api/build-time</code>을 <strong>딱 1회 호출</strong>하고,
|
||||
응답값을 HTML 안의 Nuxt payload에 직렬화합니다.
|
||||
<br />
|
||||
<code style="font-size: 12px; color: #374151;"><script>window.__NUXT__ = { data: { time: "2026-..." } }</script></code>
|
||||
</li>
|
||||
<li>
|
||||
사용자가 페이지를 방문하면 이미 완성된 HTML이 반환됩니다.
|
||||
클라이언트의 JS는 payload를 읽어 hydration하므로 <strong>추가 네트워크 요청이 발생하지 않습니다.</strong>
|
||||
</li>
|
||||
<li>새로고침해도 시간이 고정됩니다 — CDN에서 동일한 정적 파일을 제공하기 때문입니다.</li>
|
||||
<li>
|
||||
<strong>⚠ SSG의 한계:</strong> 콘텐츠가 바뀌면 <code>nuxt generate</code> 재실행 + 재배포가 필요합니다.
|
||||
자주 바뀌는 데이터라면 재배포 없이 자동 재생성하는 <strong>ISR(<code>isr: N</code>)</strong> 또는
|
||||
<strong>SWR(<code>swr: true</code>)</strong>을 고려하세요.
|
||||
</li>
|
||||
<li>
|
||||
<strong>확인 방법:</strong> <code>npm run generate</code> 후
|
||||
<code>.output/public/ssg/index.html</code>을 열어 <code>__NUXT__</code>를 검색하면
|
||||
빌드 시 생성된 시간값이 HTML에 포함된 것을 볼 수 있습니다.
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<template #code>
|
||||
<pre>{{ codeExample }}</pre>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// nuxt.config.ts에서 설정됨: routeRules: { '/ssg': { prerender: true } }
|
||||
// nuxt generate 시 /api/build-time을 1회 호출 → HTML payload에 포함
|
||||
const { data } = await useFetch('/api/build-time')
|
||||
|
||||
const codeExample = `// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
routeRules: { '/ssg': { prerender: true } },
|
||||
})
|
||||
|
||||
// nuxt generate 실행 흐름:
|
||||
// 1. Nitro 서버 기동
|
||||
// 2. /ssg 페이지 pre-render
|
||||
// 3. useFetch('/api/build-time') → 1회 호출
|
||||
// 4. 응답값을 HTML payload에 직렬화
|
||||
// <script>window.__NUXT__ = { data: { time: "..." } }<\/script>
|
||||
// 5. .output/public/ssg/index.html 파일 저장
|
||||
|
||||
// 사용자 방문 시:
|
||||
// → 정적 HTML 즉시 반환 (서버 렌더링 없음)
|
||||
// → JS가 payload 읽어 hydration (API 요청 없음)`
|
||||
</script>
|
||||
30
app/pages/ssr.vue
Normal file
30
app/pages/ssr.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<PageLayout badge="SSR" badge-color="#3b82f6" title="Server-Side Rendering">
|
||||
<InfoRow label="렌더링 위치" value="서버 (요청마다)" />
|
||||
<InfoRow label="서버 시간" :value="data?.time ?? '로딩 중...'" highlight />
|
||||
<InfoRow label="메시지" :value="data?.message ?? ''" />
|
||||
|
||||
<template #explain>
|
||||
<li>페이지 요청 시 서버에서 <code>/api/time</code>을 호출하고 HTML을 완성해 전달합니다.</li>
|
||||
<li><strong>새로고침할 때마다 시간이 바뀝니다.</strong> — 서버가 매번 렌더링하기 때문입니다.</li>
|
||||
<li>SEO에 유리하고, 항상 최신 데이터를 보여줍니다.</li>
|
||||
<li>별도 설정 없이 Nuxt의 기본 동작입니다.</li>
|
||||
</template>
|
||||
|
||||
<template #code>
|
||||
<pre>{{ codeExample }}</pre>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// SSR은 Nuxt 기본값 — 별도 설정 불필요
|
||||
const { data } = await useFetch('/api/time')
|
||||
|
||||
const codeExample = `// nuxt.config.ts — 기본값이므로 별도 설정 없음
|
||||
export default defineNuxtConfig({})
|
||||
|
||||
// pages/ssr.vue
|
||||
const { data } = await useFetch('/api/time')
|
||||
// 요청마다 서버에서 /api/time을 호출 → 항상 최신 데이터`
|
||||
</script>
|
||||
549
docs/PRD.md
Normal file
549
docs/PRD.md
Normal file
@@ -0,0 +1,549 @@
|
||||
# InvoiceLink MVP PRD
|
||||
|
||||
> **버전**: 0.1.0 | **작성일**: 2026-03-14 | **작성자**: 1인 개발
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 및 문제 정의
|
||||
|
||||
### Pain Point
|
||||
|
||||
| # | 현재 방식 | 문제 |
|
||||
|---|-----------|------|
|
||||
| 1 | 이메일 첨부 (Word/HWP) | 파일 버전 불일치, 수정 후 재발송 필요, 모바일에서 열기 어려움 |
|
||||
| 2 | 엑셀 공유 (Google Sheets 링크) | 브랜딩 부재, 인쇄 레이아웃 깨짐, 실수로 수정 가능 |
|
||||
| 3 | 구두/카카오톡 문자 전달 | 공식 증빙 불가, 분쟁 발생 시 근거 없음, PDF 재요청 빈번 |
|
||||
|
||||
### 타겟 페르소나
|
||||
|
||||
- **노션을 이미 사용하는 프리랜서 / 소규모 에이전시** (1~5인)
|
||||
- 월 10~50건 견적서 발행
|
||||
- 별도 ERP 도입 비용을 감당하기 어렵고, 노션 DB를 이미 운영 중
|
||||
|
||||
### 핵심 문제 정의
|
||||
|
||||
> 노션에 입력한 견적 데이터를 클라이언트에게 **전문적인 웹 뷰어**로 즉시 공유하고, **단일 PDF**로 제공하는 통합 수단이 없다.
|
||||
|
||||
**구현 시 주의사항**: 문제 범위를 "노션 사용자"로 명시적으로 한정함으로써 범용 견적 SaaS와의 차별점을 유지한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 목표 (Goals & Non-Goals)
|
||||
|
||||
### MVP Goals
|
||||
|
||||
| # | 목표 | 측정 기준 |
|
||||
|---|------|-----------|
|
||||
| G1 | 견적서 발행에서 클라이언트 PDF 수령까지 **5분 이내** | 사용자 테스트 3명 중 3명 달성 |
|
||||
| G2 | 노션 DB 속성 매핑 오류율 **0%** (필수 속성 10개 기준) | QA 체크리스트 통과 |
|
||||
| G3 | PDF 생성 응답 시간 **10초 이내** (A4 1페이지 기준) | Vercel 함수 p95 지표 |
|
||||
|
||||
### Non-Goals (MVP 제외)
|
||||
|
||||
- 클라이언트 전자 서명 / 결재 기능
|
||||
- 다국어 지원 (한국어 단일)
|
||||
- Notion OAuth 앱 등록 (Public Integration)
|
||||
- 반복 견적 템플릿 / 자동 발송 스케줄러
|
||||
- 결제 연동 (세금계산서 발행, PG 연동)
|
||||
- 멀티 워크스페이스 지원
|
||||
|
||||
**구현 시 주의사항**: Non-Goal은 스코프 크리프 방어선이다. 기능 요청이 들어오면 이 목록을 먼저 확인한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 사용자 스토리
|
||||
|
||||
### 공급자 (Supplier)
|
||||
|
||||
**S1. 노션 연동**
|
||||
> 나는 공급자로서, 기존 노션 DB를 그대로 사용하기 위해 Integration Token을 InvoiceLink에 등록할 수 있다.
|
||||
|
||||
- AC1: Token 저장 후 "연결 테스트" 버튼 클릭 시 DB 접근 가능 여부를 3초 이내에 표시한다.
|
||||
- AC2: 토큰이 유효하지 않으면 구체적인 오류 메시지(인증 실패 / 권한 없음)를 표시한다.
|
||||
- AC3: Token은 암호화되어 Supabase에 저장되며 UI에서 마스킹(`ntn_****`)으로 표시된다.
|
||||
|
||||
**S2. 견적서 발행**
|
||||
> 나는 공급자로서, 노션 DB 항목을 선택하여 고유 링크를 즉시 생성할 수 있다.
|
||||
|
||||
- AC1: 필수 속성 10개가 모두 있을 때만 "발행" 버튼이 활성화된다.
|
||||
- AC2: 발행 클릭 시 `/q/[uuid-v4]` 형식의 URL이 클립보드에 복사되고 토스트 알림이 표시된다.
|
||||
- AC3: 발행된 견적서는 대시보드 목록에 `sent` 상태로 즉시 반영된다.
|
||||
|
||||
**S3. 링크 관리**
|
||||
> 나는 공급자로서, 발행한 링크를 비활성화하여 클라이언트가 더 이상 열람하지 못하게 할 수 있다.
|
||||
|
||||
- AC1: 대시보드에서 토글 클릭 → 1초 이내 상태가 `expired`로 변경된다.
|
||||
- AC2: 비활성화된 링크 접근 시 클라이언트에게 "만료된 견적서" 안내 페이지를 표시한다.
|
||||
- AC3: 비활성화는 토글로 재활성화할 수 있다.
|
||||
|
||||
**S4. 열람/다운로드 추적**
|
||||
> 나는 공급자로서, 클라이언트가 견적서를 열람했는지 PDF를 다운로드했는지 확인할 수 있다.
|
||||
|
||||
- AC1: 클라이언트가 견적서 링크를 열면 대시보드의 상태가 `viewed`로 변경된다.
|
||||
- AC2: PDF 다운로드 시 대시보드에 "다운로드 완료" 배지가 표시된다.
|
||||
- AC3: 최초 열람 일시가 KST 기준으로 표시된다.
|
||||
|
||||
### 클라이언트 (Client)
|
||||
|
||||
**C1. 견적서 열람**
|
||||
> 나는 클라이언트로서, 링크 하나만으로 로그인 없이 견적서를 확인할 수 있다.
|
||||
|
||||
- AC1: 링크 접근 시 로그인 요구 없이 견적서 페이지가 로드된다.
|
||||
- AC2: 모바일(375px)에서 품목 테이블이 가로 스크롤로 열람 가능하다.
|
||||
- AC3: 유효기간이 지난 견적서는 만료 안내만 표시하고 내용은 노출하지 않는다.
|
||||
|
||||
**C2. PDF 다운로드**
|
||||
> 나는 클라이언트로서, 견적서를 PDF로 저장하여 내부 결재에 사용할 수 있다.
|
||||
|
||||
- AC1: "PDF 다운로드" 버튼 클릭 시 10초 이내에 파일이 저장된다.
|
||||
- AC2: 파일명은 `견적서_{견적번호}_{YYYYMMDD}.pdf` 규칙을 따른다.
|
||||
- AC3: A4 1페이지에 모든 내용이 인쇄 최적화된 레이아웃으로 출력된다.
|
||||
|
||||
**C3. 견적서 진위 확인**
|
||||
> 나는 클라이언트로서, 견적서가 공식적으로 발행된 것임을 신뢰할 수 있다.
|
||||
|
||||
- AC1: 견적서 페이지에 발행 일시와 견적번호가 명시된다.
|
||||
- AC2: 링크가 만료되었거나 비활성화된 경우 변조 가능성 없이 안내 메시지만 표시한다.
|
||||
|
||||
**구현 시 주의사항**: 클라이언트는 계정이 없으므로 모든 클라이언트 기능은 Public 라우트로 설계하되, uuid-v4로 추측 공격을 방어한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 기능 명세
|
||||
|
||||
### F1. 노션 연동
|
||||
|
||||
#### 연동 방식
|
||||
|
||||
- **Notion Internal Integration Token** 사용 (OAuth Public App은 Non-Goal)
|
||||
- 설정 위치: 대시보드 → 설정 → 노션 연동
|
||||
- Token 저장: Supabase `suppliers.notion_token` 컬럼 (AES-256 암호화, 서버 사이드 복호화)
|
||||
|
||||
#### 환경변수 (서버 전용)
|
||||
|
||||
```env
|
||||
NOTION_TOKEN=ntn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
```typescript
|
||||
// nuxt.config.ts
|
||||
runtimeConfig: {
|
||||
notionToken: process.env.NOTION_TOKEN, // 서버 전용, public 미노출
|
||||
}
|
||||
```
|
||||
|
||||
#### SDK 초기화
|
||||
|
||||
```typescript
|
||||
// server/utils/notion.ts
|
||||
import { Client } from '@notionhq/client'
|
||||
|
||||
export function createNotionClient(token: string) {
|
||||
return new Client({ auth: token, timeoutMs: 30_000 })
|
||||
}
|
||||
```
|
||||
|
||||
#### 필수 데이터베이스 속성 매핑
|
||||
|
||||
| 노션 속성명 | 노션 타입 | InvoiceLink 필드 | 추출 방법 | 필수 여부 |
|
||||
|-------------|-----------|------------------|-----------|-----------|
|
||||
| 견적번호 | `title` | `quote_number` | `prop.title.map(t => t.plain_text).join('')` | 필수 |
|
||||
| 발행일 | `date` | `issued_at` | `prop.date.start` | 필수 |
|
||||
| 유효기간 | `date` | `expires_at` | `prop.date.start` | 필수 |
|
||||
| 수신자명 | `rich_text` | `client_name` | `prop.rich_text.map(t => t.plain_text).join('')` | 필수 |
|
||||
| 수신자 이메일 | `email` | `client_email` | `prop.email \|\| ''` | 선택 |
|
||||
| 항목명 | `rich_text` | `item_name` | `prop.rich_text.map(t => t.plain_text).join('')` | 필수 |
|
||||
| 수량 | `number` | `quantity` | `prop.number` | 필수 |
|
||||
| 단가 (원) | `number` | `unit_price` | `prop.number` | 필수 |
|
||||
| 세율 (%) | `number` | `tax_rate` | `prop.number` | 필수 |
|
||||
| 비고 | `rich_text` | `notes` | `prop.rich_text.map(t => t.plain_text).join('')` | 선택 |
|
||||
|
||||
#### 속성 추출 헬퍼 (TypeScript)
|
||||
|
||||
```typescript
|
||||
// server/utils/notion-extract.ts
|
||||
import type { PageObjectResponse } from '@notionhq/client/build/src/api-endpoints'
|
||||
type Prop = PageObjectResponse['properties'][string]
|
||||
|
||||
export const extract = {
|
||||
title: (p: Prop): string =>
|
||||
p.type === 'title' ? p.title.map(t => t.plain_text).join('') : '',
|
||||
text: (p: Prop): string =>
|
||||
p.type === 'rich_text' ? p.rich_text.map(t => t.plain_text).join('') : '',
|
||||
number: (p: Prop): number | null =>
|
||||
p.type === 'number' ? p.number : null,
|
||||
date: (p: Prop): string | null =>
|
||||
p.type === 'date' && p.date ? p.date.start : null,
|
||||
email: (p: Prop): string =>
|
||||
p.type === 'email' ? (p.email ?? '') : '',
|
||||
}
|
||||
```
|
||||
|
||||
#### 동기화 트리거: 수동 버튼
|
||||
|
||||
- **선택**: 수동 버튼 (대시보드 "동기화" 클릭)
|
||||
- **근거**: Notion Webhook은 Public Integration만 지원하여 Internal Token 방식으로는 불가. 자동 폴링은 rate limit(3 req/s) 소진 위험. 1인 MVP에서 수동 동기화가 구현 복잡도 대비 충분한 UX를 제공함.
|
||||
|
||||
#### Rate Limit 대응
|
||||
|
||||
- Notion API: 평균 3 req/s per integration
|
||||
- 서버 라우트에서 Nitro `defineCachedFunction` 적용 (TTL: 60초)
|
||||
- 429 응답 시 지수 백오프(1s → 2s → 4s, 최대 3회 재시도)
|
||||
|
||||
```typescript
|
||||
// server/utils/notion-retry.ts
|
||||
import { APIResponseError } from '@notionhq/client'
|
||||
|
||||
export async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
|
||||
for (let i = 0; i <= max; i++) {
|
||||
try { return await fn() }
|
||||
catch (e) {
|
||||
if (e instanceof APIResponseError && e.status === 429 && i < max) {
|
||||
const wait = Math.pow(2, i) * 1000
|
||||
await new Promise(r => setTimeout(r, wait))
|
||||
continue
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
```
|
||||
|
||||
#### 에러 처리
|
||||
|
||||
| HTTP 상태 | 원인 | 사용자 메시지 |
|
||||
|-----------|------|---------------|
|
||||
| 400 | 잘못된 속성명/필터 | "DB 속성명을 확인하세요. 대소문자와 공백이 정확해야 합니다." |
|
||||
| 401 | 토큰 만료/무효 | "Notion Integration Token이 유효하지 않습니다." |
|
||||
| 404 | DB ID 오류 또는 권한 미부여 | "데이터베이스를 찾을 수 없습니다. DB에 Integration을 연결했는지 확인하세요." |
|
||||
| 429 | Rate limit 초과 | "잠시 후 다시 시도하세요. (요청 제한)" |
|
||||
|
||||
**구현 시 주의사항**: 노션 속성명은 한국어 포함 대소문자·공백이 완전히 일치해야 하므로, 설정 화면에서 속성명 매핑을 사용자가 직접 입력하는 방식을 고려한다.
|
||||
|
||||
---
|
||||
|
||||
### F2. 고유 링크 생성
|
||||
|
||||
- **URL 구조**: `/q/[uuid-v4]` — 128비트 랜덤, 추측 불가
|
||||
- **유효기간**: 기본값 30일. 발행 시 커스터마이징 가능 (7일 / 14일 / 30일 / 무제한)
|
||||
- **접근 제어**: 링크 소유 공급자만 비활성화 가능. 비활성화 즉시 클라이언트 접근 차단.
|
||||
- **클립보드 복사 UX**: `navigator.clipboard.writeText()` + `@nuxt/ui` Toast 알림
|
||||
|
||||
```typescript
|
||||
// server/api/quotes/index.post.ts
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
const slug = uuidv4() // e.g. "550e8400-e29b-41d4-a716-446655440000"
|
||||
// quote 레코드 생성 후 /q/${slug} 반환
|
||||
```
|
||||
|
||||
**구현 시 주의사항**: `crypto.randomUUID()`를 Node.js 환경에서 직접 사용할 수 있으나, 브라우저 호환성을 위해 `uuid` 패키지를 명시적으로 사용한다.
|
||||
|
||||
---
|
||||
|
||||
### F3. 견적서 웹 뷰어
|
||||
|
||||
#### 레이아웃 구성
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [로고] 상호명 / 연락처 / 이메일 │ ← 헤더
|
||||
├─────────────────────────────────────────┤
|
||||
│ 견적번호: Q-2026-001 발행일: 2026-03-14 │
|
||||
│ 유효기간: 2026-04-14 수신자: 홍길동 님 │ ← 견적 메타
|
||||
├─────────────────────────────────────────┤
|
||||
│ 항목명 수량 단가 세액 소계 │
|
||||
│ ────────────────────────────────────── │
|
||||
│ 웹 개발 1 3,000,000 300,000 3.3M │ ← 품목 테이블
|
||||
│ 디자인 1 1,000,000 100,000 1.1M │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 공급가액 4,000,000 │
|
||||
│ 세액 400,000 │ ← 합계 영역
|
||||
│ 총액 4,400,000 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 비고: 계약금 50% 선입금 후 착수 │
|
||||
│ 사업자번호: 000-00-00000 │ ← 푸터
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 반응형 요구사항
|
||||
|
||||
- 모바일(375px): 품목 테이블 `overflow-x: auto`, 헤더는 세로 스택
|
||||
- PDF 출력: `@media print` CSS로 A4(210×297mm) 최적화, 푸터 고정
|
||||
|
||||
#### 브랜딩 커스터마이징 (MVP)
|
||||
|
||||
- 로고 이미지 업로드 → Supabase Storage 저장
|
||||
- Primary 컬러 6가지 프리셋: `#2563EB` `#16A34A` `#DC2626` `#D97706` `#7C3AED` `#0891B2`
|
||||
|
||||
**구현 시 주의사항**: `@nuxt/ui` 컴포넌트의 color prop과 CSS 변수(`--color-primary`)를 연동하여 동적 테마 전환을 구현한다.
|
||||
|
||||
---
|
||||
|
||||
### F4. PDF 다운로드
|
||||
|
||||
- **생성 방식**: 서버사이드 헤드리스 렌더링
|
||||
- **라우트**: `server/api/quote/[id]/pdf.get.ts`
|
||||
- **라이브러리**: `@sparticuz/chromium` + `puppeteer-core`
|
||||
- **근거**: 클라이언트 `window.print()`는 브라우저별 출력 차이 발생. 서버사이드가 레이아웃 일관성 보장.
|
||||
|
||||
```typescript
|
||||
// server/api/quote/[id]/pdf.get.ts
|
||||
import chromium from '@sparticuz/chromium'
|
||||
import puppeteer from 'puppeteer-core'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
// 1. DB에서 quote 조회
|
||||
// 2. HTML 템플릿 렌더링
|
||||
// 3. Puppeteer로 PDF 생성
|
||||
const browser = await puppeteer.launch({
|
||||
args: chromium.args,
|
||||
executablePath: await chromium.executablePath(),
|
||||
headless: chromium.headless,
|
||||
})
|
||||
const page = await browser.newPage()
|
||||
await page.setContent(htmlContent, { waitUntil: 'networkidle0' })
|
||||
const pdf = await page.pdf({ format: 'A4', printBackground: true })
|
||||
await browser.close()
|
||||
|
||||
setResponseHeaders(event, {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||||
})
|
||||
return pdf
|
||||
})
|
||||
```
|
||||
|
||||
- **파일명 규칙**: `견적서_{quote_number}_{YYYYMMDD}.pdf`
|
||||
- **이벤트 로깅**: 다운로드 완료 시 `quote_events` 테이블에 `{ type: 'pdf_downloaded', quote_id, created_at }` 삽입
|
||||
- **Vercel 제약**: 서버리스 함수 메모리 1GB, 실행 시간 최대 60초(Pro 플랜). `@sparticuz/chromium`은 50MB 이하로 제한 내 처리 가능.
|
||||
|
||||
**구현 시 주의사항**: Vercel Edge Runtime은 Node.js API 미지원. PDF 라우트는 반드시 `export const runtime = 'nodejs'`를 명시한다.
|
||||
|
||||
---
|
||||
|
||||
### F5. 공급자 대시보드
|
||||
|
||||
- **견적서 목록**: 발행일 내림차순, 20개씩 페이지네이션
|
||||
- **상태 뱃지**:
|
||||
|
||||
| 상태 | 조건 | 색상 |
|
||||
|------|------|------|
|
||||
| `draft` | 발행 전 | 회색 |
|
||||
| `sent` | 링크 생성 완료 | 파란색 |
|
||||
| `viewed` | 클라이언트 최초 열람 | 초록색 |
|
||||
| `expired` | 유효기간 경과 또는 수동 만료 | 빨간색 |
|
||||
|
||||
- **이벤트 표시**: "열람 ✓" / "PDF 다운로드 ✓" 아이콘 배지
|
||||
- **링크 만료 토글**: `UToggle` 컴포넌트, PATCH `/api/quotes/[id]` 호출
|
||||
|
||||
**구현 시 주의사항**: 상태 전환 로직은 서버에서만 처리하며 클라이언트에서 직접 `status` 필드를 조작하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 모델
|
||||
|
||||
```sql
|
||||
-- suppliers: 공급자 (Supabase Auth users와 1:1)
|
||||
CREATE TABLE suppliers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
company_name TEXT NOT NULL,
|
||||
contact TEXT,
|
||||
logo_url TEXT,
|
||||
primary_color CHAR(7) DEFAULT '#2563EB',
|
||||
notion_token TEXT, -- AES-256 암호화 저장
|
||||
notion_db_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
-- RLS: 본인 레코드만 읽기/쓰기
|
||||
ALTER TABLE suppliers ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "suppliers_self" ON suppliers
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- quotes: 견적서 헤더
|
||||
CREATE TABLE quotes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE,
|
||||
slug UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), -- /q/[slug]
|
||||
notion_page_id TEXT,
|
||||
quote_number TEXT NOT NULL,
|
||||
client_name TEXT NOT NULL,
|
||||
client_email TEXT,
|
||||
issued_at DATE NOT NULL,
|
||||
expires_at DATE,
|
||||
notes TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'sent'
|
||||
CHECK (status IN ('draft','sent','viewed','expired')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
total_amount NUMERIC(12,2),
|
||||
tax_amount NUMERIC(12,2),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- RLS: 공급자는 본인 견적만, 공개 slug 읽기는 허용
|
||||
ALTER TABLE quotes ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "quotes_supplier" ON quotes
|
||||
USING (supplier_id IN (SELECT id FROM suppliers WHERE user_id = auth.uid()));
|
||||
CREATE POLICY "quotes_public_read" ON quotes
|
||||
FOR SELECT USING (is_active = TRUE);
|
||||
|
||||
-- quote_items: 견적 항목
|
||||
CREATE TABLE quote_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||
item_name TEXT NOT NULL,
|
||||
quantity NUMERIC(10,2) NOT NULL DEFAULT 1,
|
||||
unit_price NUMERIC(12,2) NOT NULL,
|
||||
tax_rate NUMERIC(5,2) NOT NULL DEFAULT 10,
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- RLS: quotes와 동일 공급자 정책 상속
|
||||
ALTER TABLE quote_items ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "quote_items_via_quotes" ON quote_items
|
||||
USING (quote_id IN (SELECT id FROM quotes
|
||||
WHERE supplier_id IN (SELECT id FROM suppliers WHERE user_id = auth.uid())));
|
||||
CREATE POLICY "quote_items_public_read" ON quote_items
|
||||
FOR SELECT USING (quote_id IN (SELECT id FROM quotes WHERE is_active = TRUE));
|
||||
|
||||
-- quote_events: 열람/다운로드 이벤트 로그
|
||||
CREATE TABLE quote_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('viewed','pdf_downloaded')),
|
||||
ip_hash TEXT, -- 개인정보 최소화: IP를 SHA-256 해시로 저장
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- RLS: 공급자만 이벤트 읽기, 삽입은 서버(service_role)만
|
||||
ALTER TABLE quote_events ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "events_supplier_read" ON quote_events
|
||||
FOR SELECT USING (quote_id IN (
|
||||
SELECT id FROM quotes
|
||||
WHERE supplier_id IN (SELECT id FROM suppliers WHERE user_id = auth.uid())
|
||||
));
|
||||
```
|
||||
|
||||
**구현 시 주의사항**: `quote_events` 삽입은 서버 라우트에서 `service_role` 키로 처리하고 클라이언트에 해당 키를 노출하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 6. API 설계 (Nuxt Server Routes)
|
||||
|
||||
| Method | Route | 설명 | Auth |
|
||||
|--------|-------|------|------|
|
||||
| `GET` | `/api/quote/[slug]` | 슬러그로 견적서 + 항목 조회 | Public (slug 보유자) |
|
||||
| `GET` | `/api/quote/[slug]/pdf` | PDF 생성 및 스트림 응답 | Public |
|
||||
| `POST` | `/api/quotes` | 견적서 생성 (노션 데이터 포함) | Supplier (세션 필요) |
|
||||
| `PATCH` | `/api/quotes/[id]` | 상태/활성화 변경 | Supplier |
|
||||
| `DELETE` | `/api/quotes/[id]` | 견적서 삭제 | Supplier |
|
||||
| `GET` | `/api/notion/sync` | 노션 DB 페이지 목록 조회 | Supplier |
|
||||
| `POST` | `/api/notion/validate` | Token + DB ID 유효성 검사 | Supplier |
|
||||
|
||||
### 요청/응답 예시
|
||||
|
||||
```typescript
|
||||
// POST /api/quotes
|
||||
// Request body (Zod 검증)
|
||||
const createQuoteSchema = z.object({
|
||||
notionPageId: z.string(),
|
||||
expiresAt: z.string().date().optional(),
|
||||
primaryColor: z.string().regex(/^#[0-9A-F]{6}$/i).optional(),
|
||||
})
|
||||
|
||||
// GET /api/quote/[slug] Response
|
||||
interface QuoteResponse {
|
||||
quote: { quoteNumber: string; clientName: string; issuedAt: string; expiresAt: string | null }
|
||||
items: Array<{ itemName: string; quantity: number; unitPrice: number; taxRate: number }>
|
||||
supplier: { companyName: string; contact: string; logoUrl: string | null; primaryColor: string }
|
||||
}
|
||||
```
|
||||
|
||||
**구현 시 주의사항**: 모든 입력값은 Zod로 서버에서 재검증. 클라이언트 검증만으로는 부족하다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 기술 스택 최종 제안
|
||||
|
||||
| 용도 | 라이브러리 | 선택 이유 |
|
||||
|------|-----------|-----------|
|
||||
| **PDF 생성** | `@sparticuz/chromium` + `puppeteer-core` | Vercel 서버리스 환경에서 Chromium 번들링 가능한 유일한 실용적 옵션 |
|
||||
| **노션 API 클라이언트** | `@notionhq/client` | Notion 공식 TypeScript SDK, 타입 완전 지원 |
|
||||
| **날짜 처리** | `date-fns` | 경량(tree-shaking), 순수 함수형, `format(date, 'yyyyMMdd')` 직관적 API |
|
||||
| **숫자/통화 포맷** | `Intl.NumberFormat` (내장) | 외부 의존성 없이 `new Intl.NumberFormat('ko-KR').format(n)` 으로 천 단위 콤마 처리 |
|
||||
| **이메일 발송 (선택)** | `Resend` + `react-email` | Vercel 환경 최적화, 무료 플랜 월 3,000건 충분, SDK 단순함 |
|
||||
| **입력 검증** | `zod` | 기존 스택 일관성, Nuxt server route와 네이티브 통합 |
|
||||
| **UUID 생성** | `crypto.randomUUID()` (Node 내장) | 외부 의존성 불필요, RFC 4122 v4 준수 |
|
||||
|
||||
**구현 시 주의사항**: `puppeteer-core` 버전은 `@sparticuz/chromium`과 호환 버전을 반드시 맞춘다. (패키지 README에서 버전 매트릭스 확인)
|
||||
|
||||
---
|
||||
|
||||
## 8. MVP 마일스톤
|
||||
|
||||
| Phase | 기간 | 완성 기준 |
|
||||
|-------|------|-----------|
|
||||
| **Phase 1** 인프라 + 노션 연동 | Week 1~2 | Supabase 스키마 적용, 노션 토큰 등록·검증, DB 항목 목록 조회 API 동작 |
|
||||
| **Phase 2** 핵심 기능 | Week 3~4 | 견적서 발행(링크 생성), 웹 뷰어 렌더링, PDF 다운로드 (A4 출력 확인) |
|
||||
| **Launch** 대시보드 + 안정화 | Week 5 | 공급자 대시보드(상태·이벤트 추적), 에러 처리 완비, Vercel 배포 |
|
||||
|
||||
### 런치 기준 (Launch Criteria)
|
||||
|
||||
- [ ] 노션 연동 → 견적서 발행 → PDF 수령 E2E 시나리오 3회 반복 성공
|
||||
- [ ] PDF 생성 p95 응답 시간 10초 이내 (Vercel 로그 확인)
|
||||
- [ ] 만료된 링크 접근 시 클라이언트에게 적절한 안내 페이지 표시
|
||||
- [ ] Supabase RLS 정책 검증: 타 공급자 데이터 접근 불가 확인
|
||||
- [ ] 모바일(375px) Chrome에서 웹 뷰어 및 PDF 다운로드 정상 동작
|
||||
|
||||
**구현 시 주의사항**: 런치 기준은 QA 체크리스트로 관리하며, 모든 항목 체크 완료 전까지 프로덕션 도메인 공유를 금한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 성공 지표 (Metrics)
|
||||
|
||||
### Primary Metric (북극성)
|
||||
|
||||
- **주간 활성 견적서 발행 수** (WAQ: Weekly Active Quotes)
|
||||
- 측정: Supabase `quotes` 테이블 `created_at` 집계
|
||||
- 목표: 런치 4주 후 WAQ ≥ 10
|
||||
|
||||
### Supporting Metrics
|
||||
|
||||
| 지표 | 설명 | 측정 도구 |
|
||||
|------|------|-----------|
|
||||
| PDF 변환 성공률 | 전체 다운로드 시도 중 성공 비율 ≥ 95% | Supabase `quote_events` + Vercel 함수 에러 로그 |
|
||||
| 견적서 열람률 | 발행된 링크 중 클라이언트가 실제 열람한 비율 | `quote_events` `viewed` / `quotes` `sent` |
|
||||
| 평균 발행 소요 시간 | 노션 동기화 클릭 → 링크 복사 완료까지 | 브라우저 Performance API (PostHog 커스텀 이벤트) |
|
||||
|
||||
**구현 시 주의사항**: PostHog는 무료 플랜(월 100만 이벤트)으로 충분. Nuxt 플러그인으로 초기화하고 `usePostHog()`로 이벤트 전송.
|
||||
|
||||
---
|
||||
|
||||
## 10. 리스크 & Open Questions
|
||||
|
||||
### 리스크
|
||||
|
||||
| 리스크 | 발생 확률 | 영향도 | 대응 전략 |
|
||||
|--------|-----------|--------|-----------|
|
||||
| Vercel 서버리스 Cold Start (Chromium) | 높음 | 중간 | Vercel Pro Fluid Compute 또는 최소 warm-up 요청 설정. 대안: `html-pdf-node` 검토 |
|
||||
| Notion API Rate Limit (3 req/s) | 중간 | 중간 | Nitro 60초 캐시 + 지수 백오프. 동기화는 수동 트리거로 제한 |
|
||||
| Notion API 속성명 변경 | 낮음 | 높음 | 속성명 매핑을 대시보드에서 사용자가 설정 가능하게 구현 |
|
||||
| Supabase Free Tier 제한 (500MB DB) | 낮음 | 낮음 | `quote_events` 90일 이상 데이터 주기적 아카이브 또는 자동 삭제 정책 |
|
||||
|
||||
### Open Questions
|
||||
|
||||
| # | 질문 | 현재 유력 가설 |
|
||||
|---|------|----------------|
|
||||
| OQ1 | 노션 속성명 매핑을 코드에 하드코딩할 것인가, 대시보드에서 설정 가능하게 할 것인가? | 설정 가능하게 구현. 초기 기본값 제공, 변경 허용. |
|
||||
| OQ2 | PDF 생성을 Vercel 서버리스로 할 것인가, 별도 마이크로서비스(Render)로 분리할 것인가? | MVP는 Vercel 단일 배포. 10초 초과 시 Render 분리 검토. |
|
||||
| OQ3 | 클라이언트 열람 이벤트를 IP 기반으로 중복 제거할 것인가? | SHA-256 해시로 저장, 24시간 내 동일 IP 재열람은 이벤트 미중복 기록. |
|
||||
| OQ4 | 견적서 항목이 여러 노션 페이지(하위 DB)에 분산된 경우를 지원할 것인가? | MVP는 단일 DB, 단일 페이지 = 단일 견적서 구조만 지원. |
|
||||
| OQ5 | 무료 플랜과 유료 플랜을 MVP에서 구분할 것인가? | MVP는 전체 무료. 100건 발행 초과 시 유료 전환 검토. |
|
||||
|
||||
**구현 시 주의사항**: OQ1~OQ5는 Phase 1 시작 전에 의사결정을 완료하고 CLAUDE.md 또는 별도 ADR 문서에 기록한다.
|
||||
209
docs/PRD_PROMPT.md
Normal file
209
docs/PRD_PROMPT.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# PRD 생성 메타 프롬프트
|
||||
# 노션 견적서 → 웹 뷰어 & PDF 다운로드 MVP
|
||||
|
||||
> **사용법**: 아래 `---START---`부터 `---END---`까지 전체를 복사하여 Claude에게 붙여넣으세요.
|
||||
|
||||
---START---
|
||||
|
||||
## 역할
|
||||
|
||||
당신은 시니어 프로덕트 매니저 겸 풀스택 아키텍트입니다.
|
||||
아래 제품 아이디어와 기술 제약조건을 바탕으로 실행 가능한 MVP PRD 문서를 작성하세요.
|
||||
|
||||
---
|
||||
|
||||
## 제품 아이디어
|
||||
|
||||
**서비스명 (가칭)**: InvoiceLink
|
||||
|
||||
**한 줄 요약**:
|
||||
노션 데이터베이스에 입력한 견적서를 고유 링크로 공유하면,
|
||||
클라이언트가 브라우저에서 확인하고 PDF로 다운로드할 수 있는 웹 서비스.
|
||||
|
||||
**핵심 사용 흐름**:
|
||||
1. 공급자(supplier)가 노션 데이터베이스에 견적 항목을 입력한다
|
||||
2. InvoiceLink 대시보드에서 "견적서 발행" 클릭 → 고유 URL 생성
|
||||
3. 공급자가 URL을 클라이언트에게 전달(이메일, 카카오톡 등)
|
||||
4. 클라이언트가 링크를 열면 브랜딩된 견적서 페이지를 확인한다
|
||||
5. 클라이언트가 "PDF 다운로드" 버튼을 클릭하여 파일을 저장한다
|
||||
|
||||
---
|
||||
|
||||
## 기술 제약조건 (반드시 반영)
|
||||
|
||||
이 프로젝트의 기존 스택을 기반으로 설계한다:
|
||||
|
||||
- **프레임워크**: Nuxt 3 (App Router 방식, `app/` 디렉토리 구조)
|
||||
- **UI**: `@nuxt/ui` v4
|
||||
- **인증/DB**: Supabase (`@nuxtjs/supabase`) — magic link + Google OAuth
|
||||
- **AI 기능 (선택)**: Anthropic Claude Sonnet 4.6 (`@anthropic-ai/sdk`) — streaming
|
||||
- **패키지 매니저**: pnpm
|
||||
- **검증**: Zod
|
||||
- **아이콘**: Lucide + Iconify
|
||||
- **배포**: Vercel 또는 Netlify (서버리스 우선)
|
||||
- **개발 인원**: 1인, 빠른 출시 우선, 비용 최소화
|
||||
|
||||
---
|
||||
|
||||
## PRD 작성 구조 (아래 순서대로 빠짐없이 작성)
|
||||
|
||||
### 1. 배경 및 문제 정의
|
||||
|
||||
- 현재 견적서 전달 방식의 Pain Point 3가지 (이메일 첨부, 엑셀, 구두 전달 등)
|
||||
- 타겟 사용자 페르소나: 노션을 이미 사용하는 프리랜서 / 소규모 에이전시
|
||||
- 해결하려는 핵심 문제를 **1문장**으로 정의
|
||||
|
||||
### 2. 목표 (Goals & Non-Goals)
|
||||
|
||||
**MVP Goals** (3개 이내, 측정 가능하게):
|
||||
- 예: "견적서 발행부터 클라이언트 PDF 수령까지 5분 이내"
|
||||
|
||||
**Non-Goals** (MVP에서 의도적으로 제외):
|
||||
- 클라이언트 서명/결재 기능
|
||||
- 다국어 지원
|
||||
- 그 외 범위 방어 항목 추가
|
||||
|
||||
### 3. 사용자 스토리
|
||||
|
||||
형식: `나는 [역할]로서, [목적]을 위해 [행동]을 할 수 있다.`
|
||||
|
||||
각 스토리마다 **수용 기준(Acceptance Criteria)** 2~3개 포함.
|
||||
|
||||
- 공급자(Supplier) 스토리 4개
|
||||
- 클라이언트(Client) 스토리 3개
|
||||
|
||||
### 4. 기능 명세
|
||||
|
||||
#### F1. 노션 연동
|
||||
|
||||
- 연동 방식: Notion Integration Token (OAuth는 Non-Goal)
|
||||
- 필수 데이터베이스 속성 매핑 테이블:
|
||||
|
||||
| 노션 속성명 | 타입 | InvoiceLink 필드 | 필수 여부 |
|
||||
|---|---|---|---|
|
||||
| 견적번호 | Title | quote_number | 필수 |
|
||||
| 발행일 | Date | issued_at | 필수 |
|
||||
| 유효기간 | Date | expires_at | 필수 |
|
||||
| 수신자명 | Text | client_name | 필수 |
|
||||
| 수신자 이메일 | Email | client_email | 선택 |
|
||||
| 항목명 | Text | item_name | 필수 |
|
||||
| 수량 | Number | quantity | 필수 |
|
||||
| 단가 (원) | Number | unit_price | 필수 |
|
||||
| 세율 (%) | Number | tax_rate | 필수 |
|
||||
| 비고 | Text | notes | 선택 |
|
||||
|
||||
- 동기화 트리거 방식 및 근거 (수동 버튼 vs 자동 webhook 중 선택)
|
||||
- 에러 처리: 필수 필드 누락 시 동작 정의
|
||||
|
||||
#### F2. 고유 링크 생성
|
||||
|
||||
- URL 구조: `/q/[uuid-v4]` (추측 불가 설계)
|
||||
- 링크 유효기간 정책 (기본값 및 커스터마이징 범위)
|
||||
- 비공개/공개 접근 제어: 링크 소유자만 비활성화 가능
|
||||
- 링크 복사 UX (클립보드 복사 버튼)
|
||||
|
||||
#### F3. 견적서 웹 뷰어
|
||||
|
||||
레이아웃 구성 요소:
|
||||
- 헤더: 공급자 로고, 상호명, 연락처
|
||||
- 견적 메타: 견적번호, 발행일, 유효기간, 수신자 정보
|
||||
- 품목 테이블: 항목명 / 수량 / 단가 / 세액 / 소계
|
||||
- 합계 영역: 공급가액, 세액, 총액 (천 단위 콤마 포맷)
|
||||
- 푸터: 비고, 공급자 사업자 정보
|
||||
|
||||
반응형 요구사항:
|
||||
- 모바일(375px)에서 품목 테이블 가로 스크롤 허용
|
||||
- PDF 출력 시 A4 1페이지 기준 최적화
|
||||
|
||||
브랜딩 커스터마이징 범위 (MVP):
|
||||
- 로고 이미지 업로드
|
||||
- Primary 컬러 선택 (6가지 프리셋)
|
||||
|
||||
#### F4. PDF 다운로드
|
||||
|
||||
- 생성 방식: **서버사이드** — Nuxt server route(`server/api/quote/[id]/pdf.get.ts`)에서 `@sparticuz/chromium` + `puppeteer-core`로 헤드리스 렌더링
|
||||
- 근거: 클라이언트 `window.print()`는 브라우저별 출력 차이 존재, 서버사이드가 일관성 보장
|
||||
- 파일명 규칙: `견적서_{견적번호}_{YYYYMMDD}.pdf`
|
||||
- 다운로드 이벤트: Supabase `quote_events` 테이블에 `pdf_downloaded` 로그 기록
|
||||
- Vercel 서버리스 함수 메모리 제한(1GB) 내 처리 가능 여부 명시
|
||||
|
||||
#### F5. 공급자 대시보드
|
||||
|
||||
- 견적서 목록 (발행일 내림차순, 페이지네이션)
|
||||
- 견적서별 상태 표시: `draft` / `sent` / `viewed` / `expired`
|
||||
- 클라이언트 조회 여부 및 PDF 다운로드 여부 표시
|
||||
- 견적서 비활성화(링크 만료) 토글
|
||||
|
||||
### 5. 데이터 모델
|
||||
|
||||
아래 엔티티에 대해 Supabase(PostgreSQL) 기준 핵심 필드 정의.
|
||||
필드명 영문 snake_case, PostgreSQL 타입 명시, RLS 정책 방향 포함.
|
||||
|
||||
- `suppliers` (공급자)
|
||||
- `quotes` (견적서 헤더)
|
||||
- `quote_items` (견적 항목)
|
||||
- `quote_events` (조회/다운로드 이벤트 로그)
|
||||
|
||||
### 6. API 설계 (Nuxt Server Routes)
|
||||
|
||||
| Method | Route | 설명 | Auth |
|
||||
|---|---|---|---|
|
||||
| GET | /api/quote/[id] | 견적서 데이터 조회 | Public (링크 보유자) |
|
||||
| GET | /api/quote/[id]/pdf | PDF 생성 및 다운로드 | Public |
|
||||
| POST | /api/quotes | 견적서 생성 (노션 동기화) | Supplier |
|
||||
| PATCH | /api/quotes/[id] | 견적서 상태 변경 | Supplier |
|
||||
| GET | /api/notion/sync | 노션 DB 항목 목록 조회 | Supplier |
|
||||
|
||||
### 7. 기술 스택 최종 제안
|
||||
|
||||
아래 항목별로 선택한 라이브러리와 선택 이유를 한 줄로 명시:
|
||||
|
||||
- PDF 생성
|
||||
- 노션 API 클라이언트
|
||||
- 날짜 처리
|
||||
- 숫자/통화 포맷
|
||||
- 이메일 발송 (선택)
|
||||
|
||||
### 8. MVP 마일스톤
|
||||
|
||||
| Phase | 기간 | 완성 기준 |
|
||||
|---|---|---|
|
||||
| Phase 1 | Week 1~2 | ? |
|
||||
| Phase 2 | Week 3~4 | ? |
|
||||
| Launch | Week 5 | ? |
|
||||
|
||||
**런치 기준(Launch Criteria)**: 이 조건이 모두 충족되어야 배포 가능
|
||||
|
||||
- [ ] 조건 1
|
||||
- [ ] 조건 2
|
||||
- [ ] 조건 3
|
||||
|
||||
### 9. 성공 지표 (Metrics)
|
||||
|
||||
- **Primary Metric** (북극성 지표) 1개
|
||||
- **Supporting Metrics** 3개 이내
|
||||
- 각 지표의 측정 방법 및 도구 명시 (Supabase Analytics, PostHog 등)
|
||||
|
||||
### 10. 리스크 & 미해결 질문
|
||||
|
||||
**리스크**:
|
||||
- Vercel 서버리스에서 Headless Chrome 실행 시 Cold Start 지연 가능성
|
||||
- 노션 API rate limit (평균 3 req/s) 대응 전략 필요
|
||||
|
||||
**Open Questions** (의사결정 필요 항목 3~5개):
|
||||
각 질문에 현재 유력한 가설(assumption)을 함께 명시.
|
||||
|
||||
---
|
||||
|
||||
## 출력 형식 요구사항
|
||||
|
||||
- 마크다운 형식
|
||||
- 섹션: H2(`##`), 서브섹션: H3(`###`), 세부항목: H4(`####`)
|
||||
- 테이블, 체크리스트, 코드블록 적극 활용
|
||||
- SQL DDL은 코드블록(`sql`)으로 표현
|
||||
- 분량: 2,000~3,000 단어
|
||||
- 톤: 군더더기 없는 기술 문서체 (한국어)
|
||||
- 추상적 표현 금지 — 모든 기능은 구현 레벨로 구체화
|
||||
- 각 섹션 말미에 **구현 시 주의사항** 한 줄 추가
|
||||
|
||||
---END---
|
||||
182
docs/curriculum/01-rendering-strategy.md
Normal file
182
docs/curriculum/01-rendering-strategy.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 1. 렌더링 방식 + 배포 환경 선택 전략
|
||||
|
||||
> Nuxt 4 기준으로 작성됨 (2026-03)
|
||||
|
||||
## 렌더링 전략 개요
|
||||
|
||||
Nuxt는 네 가지 렌더링 전략을 지원한다. `nuxt.config.ts` 설정만으로 전환 가능하다.
|
||||
|
||||
| 전략 | 설명 | 주요 사용 사례 |
|
||||
|------|------|----------------|
|
||||
| **SSR** (Server-Side Rendering) | 요청마다 서버에서 HTML 생성 | 실시간 데이터, SEO가 중요한 페이지 |
|
||||
| **SSG** (Static Site Generation) | 빌드 시 HTML 미리 생성 | 블로그, 문서, 변경이 적은 콘텐츠 |
|
||||
| **SPA** (Single Page Application) | 클라이언트에서만 렌더링 | 인증 이후 대시보드, 관리자 페이지 |
|
||||
| **Hybrid** | 페이지마다 다른 전략 적용 | 복합 요구사항을 가진 앱 |
|
||||
|
||||
---
|
||||
|
||||
## nuxt.config.ts 핵심 옵션
|
||||
|
||||
### 전역 렌더링 모드 설정
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
// SSR 활성화 여부 (기본값: true)
|
||||
ssr: true,
|
||||
|
||||
app: {
|
||||
head: {
|
||||
title: '앱 제목',
|
||||
meta: [{ name: 'description', content: '설명' }]
|
||||
}
|
||||
},
|
||||
|
||||
// 런타임 환경변수 (서버/클라이언트 구분)
|
||||
runtimeConfig: {
|
||||
// 서버에서만 접근 가능
|
||||
apiSecret: process.env.API_SECRET,
|
||||
// 클라이언트에도 노출됨 (public)
|
||||
public: {
|
||||
apiBase: process.env.API_BASE_URL
|
||||
}
|
||||
},
|
||||
|
||||
// Nitro 서버 엔진 설정
|
||||
nitro: {
|
||||
preset: 'node-server', // 배포 환경에 따라 변경
|
||||
},
|
||||
|
||||
// 라우트별 개별 전략 (Hybrid 렌더링)
|
||||
routeRules: {
|
||||
'/admin/**': { ssr: false }, // SPA로 처리
|
||||
'/blog/**': { prerender: true }, // SSG로 미리 생성
|
||||
'/api/**': { cors: true }, // API 라우트 CORS 허용
|
||||
'/products/**': { swr: 3600 }, // SWR: 1시간 캐시, 백그라운드 재생성
|
||||
'/dashboard': { isr: 60 }, // ISR: 60초 캐시 (CDN 지원 플랫폼)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 전략별 선택 기준
|
||||
|
||||
### SSR을 선택해야 할 때
|
||||
- 검색엔진 최적화(SEO)가 필요한 공개 페이지
|
||||
- 사용자마다 다른 데이터를 보여줘야 할 때 (개인화된 피드 등)
|
||||
- 실시간으로 자주 바뀌는 데이터 (재고, 가격 등)
|
||||
|
||||
```ts
|
||||
// 기본값이 ssr: true이므로 별도 설정 불필요
|
||||
export default defineNuxtConfig({
|
||||
ssr: true
|
||||
})
|
||||
```
|
||||
|
||||
### SSG를 선택해야 할 때
|
||||
- 변경이 드문 콘텐츠 (블로그, 마케팅 페이지)
|
||||
- CDN 캐시 최대 활용이 필요한 경우
|
||||
- 서버 운영 비용을 최소화하고 싶을 때
|
||||
|
||||
```ts
|
||||
// 빌드 시 모든 페이지를 미리 생성
|
||||
export default defineNuxtConfig({
|
||||
routeRules: {
|
||||
'/**': { prerender: true }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### SPA를 선택해야 할 때
|
||||
- 로그인 후에만 접근하는 페이지 (SEO 불필요)
|
||||
- 복잡한 인터랙션이 많은 대시보드
|
||||
- API 서버를 별도로 운영하고 있을 때
|
||||
|
||||
```ts
|
||||
export default defineNuxtConfig({
|
||||
ssr: false // 전체를 SPA로
|
||||
// 또는 routeRules로 특정 경로만 SPA
|
||||
})
|
||||
```
|
||||
|
||||
### Hybrid (SWR / ISR 포함)를 선택해야 할 때
|
||||
- 페이지마다 요구사항이 다를 때
|
||||
- 대부분 정적이지만 일부 페이지는 SSR이 필요할 때
|
||||
|
||||
```ts
|
||||
export default defineNuxtConfig({
|
||||
routeRules: {
|
||||
'/': { prerender: true }, // 홈: SSG
|
||||
'/products/**': { swr: 3600 }, // 상품: SWR 1시간 캐시 + 백그라운드 재생성
|
||||
'/blog': { isr: 3600 }, // 블로그: ISR (CDN 캐시, Vercel/Netlify)
|
||||
'/blog/**': { isr: true }, // 글: 다음 배포까지 CDN에 캐시
|
||||
'/cart': { ssr: false }, // 장바구니: SPA
|
||||
'/checkout': { ssr: true }, // 결제: SSR (실시간)
|
||||
'/old-page': { redirect: '/new-page' }, // 리다이렉트
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SEO 최적화와 캐시 전략
|
||||
|
||||
### SEO를 위한 메타태그 설정
|
||||
|
||||
```ts
|
||||
// pages/products/[id].vue
|
||||
definePageMeta({
|
||||
title: '상품 상세'
|
||||
})
|
||||
|
||||
// 또는 useHead()로 동적 설정
|
||||
useHead({
|
||||
title: computed(() => product.value?.name),
|
||||
meta: [
|
||||
{ name: 'description', content: computed(() => product.value?.description) }
|
||||
]
|
||||
})
|
||||
|
||||
// useSeoMeta() — 타입 안전한 방식 (권장)
|
||||
useSeoMeta({
|
||||
title: '상품명',
|
||||
ogTitle: '상품명',
|
||||
description: '상품 설명',
|
||||
ogImage: '/og-image.png'
|
||||
})
|
||||
```
|
||||
|
||||
### routeRules를 활용한 캐시 전략
|
||||
|
||||
```ts
|
||||
routeRules: {
|
||||
// CDN에 1시간 캐시
|
||||
'/blog/**': {
|
||||
prerender: true,
|
||||
headers: { 'cache-control': 's-maxage=3600' }
|
||||
},
|
||||
// SWR: 서버/리버스 프록시에서 캐시, 만료 후 백그라운드 재생성
|
||||
'/products/**': { swr: 3600 },
|
||||
// ISR: CDN 플랫폼(Vercel, Netlify)에서 캐시, 만료 후 재생성
|
||||
'/deals/**': { isr: 60 },
|
||||
// 캐시 완전 비활성화
|
||||
'/api/live/**': { headers: { 'cache-control': 'no-store' } }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 배포 환경별 Nitro preset
|
||||
|
||||
```ts
|
||||
nitro: {
|
||||
preset: 'vercel' // Vercel
|
||||
preset: 'netlify' // Netlify
|
||||
preset: 'cloudflare' // Cloudflare Workers
|
||||
preset: 'node-server' // Node.js 서버 (기본)
|
||||
preset: 'static' // 정적 파일로 빌드
|
||||
}
|
||||
```
|
||||
|
||||
> `NITRO_PRESET` 환경변수로도 설정 가능하다. 배포 플랫폼에 맞게 자동 감지되는 경우가 많다.
|
||||
164
docs/curriculum/02-rendering-lifecycle.md
Normal file
164
docs/curriculum/02-rendering-lifecycle.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# 2. Rendering 흐름 & Lifecycle
|
||||
|
||||
## SSR → Hydration → CSR 전환 흐름
|
||||
|
||||
Nuxt SSR은 세 단계로 구성된다.
|
||||
|
||||
```
|
||||
[브라우저 요청]
|
||||
↓
|
||||
[서버] setup() 실행 → HTML 생성 → 클라이언트로 전송
|
||||
↓
|
||||
[브라우저] HTML 즉시 표시 (사용자에게 보임)
|
||||
↓
|
||||
[브라우저] Vue JS 로드 → Hydration (서버 HTML에 이벤트 연결)
|
||||
↓
|
||||
[브라우저] onMounted() 실행 → 완전한 SPA처럼 동작
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle 훅 실행 위치
|
||||
|
||||
| 훅 | 서버 | 클라이언트 | 설명 |
|
||||
|----|------|------------|------|
|
||||
| `setup()` | ✅ | ✅ | 컴포넌트 초기화, 양쪽 모두 실행 |
|
||||
| `onServerPrefetch()` | ✅ | ❌ | 서버에서만 실행, SSR용 데이터 페칭 |
|
||||
| `onBeforeMount()` | ❌ | ✅ | DOM 마운트 직전 |
|
||||
| `onMounted()` | ❌ | ✅ | DOM 마운트 완료, 브라우저 API 사용 가능 |
|
||||
| `onBeforeUnmount()` | ❌ | ✅ | 컴포넌트 제거 직전 |
|
||||
| `onUnmounted()` | ❌ | ✅ | 컴포넌트 제거 완료 |
|
||||
|
||||
---
|
||||
|
||||
## 실전 코드 예시
|
||||
|
||||
### setup()에서의 서버/클라이언트 분기
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// setup()은 서버와 클라이언트 양쪽에서 실행됨
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// 서버/클라이언트 환경 확인
|
||||
if (import.meta.server) {
|
||||
console.log('서버에서만 출력')
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
console.log('클라이언트에서만 출력')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### onServerPrefetch — 서버에서 데이터 미리 준비
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const data = ref(null)
|
||||
|
||||
// 서버에서 먼저 데이터를 가져오고, 클라이언트로 상태 전달
|
||||
onServerPrefetch(async () => {
|
||||
data.value = await $fetch('/api/products')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
> `useAsyncData` / `useFetch`가 내부적으로 `onServerPrefetch`를 사용한다.
|
||||
> 직접 쓸 일은 드물지만, 동작 원리 이해에 중요하다.
|
||||
|
||||
### onMounted — 브라우저 전용 작업
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ❌ 잘못된 예: setup()에서 window 접근 → 서버에서 오류 발생
|
||||
// const width = window.innerWidth
|
||||
|
||||
// ✅ 올바른 예: onMounted에서 브라우저 API 사용
|
||||
const windowWidth = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
windowWidth.value = window.innerWidth
|
||||
|
||||
// 이벤트 리스너, setTimeout, localStorage 등 모두 여기서
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 반드시 정리해야 메모리 누수를 막는다
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hydration이란?
|
||||
|
||||
서버가 생성한 정적 HTML에 Vue의 반응형 시스템과 이벤트 핸들러를 연결하는 과정이다.
|
||||
|
||||
```
|
||||
서버 HTML: <button>클릭</button> ← 이벤트 없음
|
||||
↓ Hydration
|
||||
클라이언트: <button @click="handleClick">클릭</button> ← 이벤트 연결됨
|
||||
```
|
||||
|
||||
### Hydration 불일치(Mismatch) 주의
|
||||
|
||||
서버와 클라이언트에서 다른 결과를 렌더링하면 경고가 발생한다.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ❌ 문제: 서버(undefined)와 클라이언트(실제값) 결과가 다름
|
||||
const isClient = ref(typeof window !== 'undefined')
|
||||
|
||||
// ✅ 해결: ClientOnly 컴포넌트 사용
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 브라우저에서만 렌더링해야 하는 컴포넌트 -->
|
||||
<ClientOnly>
|
||||
<BrowserOnlyComponent />
|
||||
<template #fallback>
|
||||
<p>로딩 중...</p>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nuxt 전용 훅 (useNuxtApp)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const nuxtApp = useNuxtApp()
|
||||
|
||||
// 페이지 전환 이벤트
|
||||
nuxtApp.hook('page:start', () => {
|
||||
console.log('페이지 전환 시작')
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
console.log('페이지 전환 완료')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 요약
|
||||
|
||||
```
|
||||
서버 실행: setup() → onServerPrefetch()
|
||||
↓
|
||||
HTML 생성 및 전송
|
||||
↓
|
||||
클라이언트 수신: HTML 즉시 표시
|
||||
↓
|
||||
Hydration (JS 연결)
|
||||
↓
|
||||
setup() → onBeforeMount() → onMounted()
|
||||
↓
|
||||
이후 SPA처럼 동작
|
||||
```
|
||||
212
docs/curriculum/03-server-routes.md
Normal file
212
docs/curriculum/03-server-routes.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# 3. 서버 라우트·엔드포인트 (server/api/* & server/routes/*)
|
||||
|
||||
## Nitro 엔진
|
||||
|
||||
Nuxt의 서버는 **Nitro**로 구동된다. `server/` 디렉토리에 파일을 만들면 자동으로 API 엔드포인트가 생성된다.
|
||||
|
||||
```
|
||||
server/
|
||||
├── api/ # /api/ 접두사가 붙음
|
||||
│ ├── hello.ts → GET /api/hello
|
||||
│ ├── users/
|
||||
│ │ ├── index.ts → GET /api/users
|
||||
│ │ └── [id].ts → GET /api/users/:id
|
||||
│ └── products.post.ts → POST /api/products (메서드 지정)
|
||||
└── routes/ # 접두사 없음
|
||||
└── sitemap.xml.ts → GET /sitemap.xml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 기본 사용법
|
||||
|
||||
### eventHandler
|
||||
|
||||
```ts
|
||||
// server/api/hello.ts
|
||||
export default defineEventHandler((event) => {
|
||||
return { message: 'Hello, Nuxt!' }
|
||||
})
|
||||
```
|
||||
|
||||
### getQuery — 쿼리 파라미터 읽기
|
||||
|
||||
```ts
|
||||
// server/api/products.ts
|
||||
// GET /api/products?category=tent&limit=10
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
// query.category → 'tent'
|
||||
// query.limit → '10'
|
||||
|
||||
return {
|
||||
category: query.category,
|
||||
limit: Number(query.limit) || 20
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### readBody — 요청 본문(Body) 읽기
|
||||
|
||||
```ts
|
||||
// server/api/products.post.ts
|
||||
// POST /api/products
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
// body = { name: '텐트', price: 150000 }
|
||||
|
||||
// DB에 저장하는 로직
|
||||
const product = await db.create(body)
|
||||
return product
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 라우트 파라미터
|
||||
|
||||
```ts
|
||||
// server/api/users/[id].ts
|
||||
// GET /api/users/123
|
||||
export default defineEventHandler((event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
// id → '123'
|
||||
|
||||
return { userId: id }
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP 메서드 지정
|
||||
|
||||
파일명에 메서드를 붙이면 해당 메서드만 처리한다.
|
||||
|
||||
```
|
||||
server/api/products.get.ts → GET만 처리
|
||||
server/api/products.post.ts → POST만 처리
|
||||
server/api/products.put.ts → PUT만 처리
|
||||
server/api/products.delete.ts → DELETE만 처리
|
||||
```
|
||||
|
||||
메서드 제한 없이 처리하려면 내부에서 분기한다:
|
||||
|
||||
```ts
|
||||
// server/api/products.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const method = getMethod(event)
|
||||
|
||||
if (method === 'GET') {
|
||||
return await getProducts()
|
||||
}
|
||||
|
||||
if (method === 'POST') {
|
||||
const body = await readBody(event)
|
||||
return await createProduct(body)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 유틸리티 함수 모음
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineEventHandler, // 핸들러 정의
|
||||
getQuery, // ?key=value 읽기
|
||||
readBody, // POST body 읽기
|
||||
getRouterParam, // URL 파라미터 읽기 (/users/:id)
|
||||
getMethod, // HTTP 메서드 확인
|
||||
getHeaders, // 요청 헤더 읽기
|
||||
getCookie, // 쿠키 읽기
|
||||
setCookie, // 쿠키 설정
|
||||
setResponseStatus, // 응답 상태코드 설정
|
||||
sendRedirect, // 리다이렉트
|
||||
createError, // 에러 생성
|
||||
} from 'h3' // Nuxt가 내부적으로 h3를 사용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 에러 처리
|
||||
|
||||
```ts
|
||||
// server/api/users/[id].ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
const user = await db.findUser(id)
|
||||
|
||||
if (!user) {
|
||||
// 404 에러를 던지면 Nuxt가 적절한 응답을 보냄
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '사용자를 찾을 수 없습니다'
|
||||
})
|
||||
}
|
||||
|
||||
return user
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 미들웨어 (서버 전용)
|
||||
|
||||
```ts
|
||||
// server/middleware/auth.ts
|
||||
// 모든 API 요청에 자동으로 실행됨
|
||||
export default defineEventHandler((event) => {
|
||||
const token = getHeader(event, 'authorization')
|
||||
|
||||
if (!token) {
|
||||
throw createError({ statusCode: 401, statusMessage: '인증 필요' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## server/routes/ vs server/api/
|
||||
|
||||
```ts
|
||||
// server/api/data.ts → /api/data
|
||||
// server/routes/data.ts → /data (접두사 없음)
|
||||
|
||||
// sitemap, robots.txt 같은 특수 엔드포인트에 유용
|
||||
// server/routes/robots.txt.ts
|
||||
export default defineEventHandler(() => {
|
||||
return `User-agent: *\nDisallow: /admin`
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 실전 예시: CRUD API
|
||||
|
||||
```ts
|
||||
// server/api/purchases/index.get.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const purchases = await db
|
||||
.from('purchases')
|
||||
.select('*')
|
||||
.limit(Number(query.limit) || 20)
|
||||
return purchases
|
||||
})
|
||||
|
||||
// server/api/purchases/index.post.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const purchase = await db.from('purchases').insert(body)
|
||||
setResponseStatus(event, 201)
|
||||
return purchase
|
||||
})
|
||||
|
||||
// server/api/purchases/[id].delete.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
await db.from('purchases').delete().eq('id', id)
|
||||
return { success: true }
|
||||
})
|
||||
```
|
||||
221
docs/curriculum/04-data-fetching.md
Normal file
221
docs/curriculum/04-data-fetching.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# 4. 데이터 패칭 (useAsyncData, useFetch 등)
|
||||
|
||||
## 핵심 개념
|
||||
|
||||
Nuxt의 데이터 패칭 컴포저블은 SSR과 CSR 양쪽을 처리한다.
|
||||
|
||||
- **서버에서 데이터를 가져와 HTML에 포함** → SEO 최적화, 빠른 초기 로딩
|
||||
- **클라이언트로 상태 전달(payload)** → Hydration 시 중복 요청 방지
|
||||
- **캐시·로딩·에러 상태**를 자동으로 관리
|
||||
|
||||
---
|
||||
|
||||
## useFetch vs useAsyncData
|
||||
|
||||
| | `useFetch` | `useAsyncData` |
|
||||
|--|-----------|----------------|
|
||||
| 용도 | URL 기반 데이터 패칭 | 모든 비동기 로직 |
|
||||
| 내부 구현 | `useAsyncData` + `$fetch` | 직접 사용 |
|
||||
| URL 변경 시 자동 재요청 | ✅ (watch 옵션) | 수동 설정 필요 |
|
||||
| 권장 상황 | API URL이 명확할 때 | DB 쿼리, 복합 로직 |
|
||||
|
||||
---
|
||||
|
||||
## useFetch 기본 사용법
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// GET /api/products 요청
|
||||
const { data, pending, error, refresh } = await useFetch('/api/products')
|
||||
// data: Ref<응답값>
|
||||
// pending: Ref<boolean> — 로딩 상태
|
||||
// error: Ref<Error | null>
|
||||
// refresh: () => Promise — 수동 재요청
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="pending">로딩 중...</div>
|
||||
<div v-else-if="error">에러: {{ error.message }}</div>
|
||||
<ul v-else>
|
||||
<li v-for="item in data" :key="item.id">{{ item.name }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 옵션 활용
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const { data } = await useFetch('/api/products', {
|
||||
// 요청 파라미터 (반응형 지원)
|
||||
query: { category: 'tent', limit: 10 },
|
||||
|
||||
// 요청 헤더
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
|
||||
// 응답 데이터 변환
|
||||
transform: (response) => response.items,
|
||||
|
||||
// 기본값 (data가 null일 때)
|
||||
default: () => [],
|
||||
|
||||
// 서버에서만 실행 (클라이언트에서는 캐시 사용)
|
||||
server: true,
|
||||
|
||||
// 클라이언트에서만 실행
|
||||
// server: false,
|
||||
|
||||
// 컴포넌트 마운트 후 자동 실행 안 함
|
||||
lazy: true,
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## useAsyncData 기본 사용법
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// 첫 번째 인자는 캐시 키 (고유해야 함)
|
||||
const { data, pending, error } = await useAsyncData(
|
||||
'products-list',
|
||||
() => $fetch('/api/products')
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
### Supabase와 함께 사용
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const client = useSupabaseClient()
|
||||
|
||||
const { data: purchases } = await useAsyncData(
|
||||
'purchases',
|
||||
() => client
|
||||
.from('purchases')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.then(({ data }) => data)
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 반응형 쿼리 (URL 파라미터 변경 시 자동 재요청)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
|
||||
// route.params.id가 바뀌면 자동으로 재요청
|
||||
const { data: product } = await useFetch(
|
||||
() => `/api/products/${route.params.id}`
|
||||
)
|
||||
|
||||
// 또는 watch 옵션 사용
|
||||
const category = ref('tent')
|
||||
|
||||
const { data } = await useFetch('/api/products', {
|
||||
query: { category }, // category가 바뀌면 자동 재요청
|
||||
watch: [category],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select v-model="category">
|
||||
<option value="tent">텐트</option>
|
||||
<option value="chair">의자</option>
|
||||
</select>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSR 동작 원리
|
||||
|
||||
```
|
||||
1. [서버] useFetch('/api/products') 실행
|
||||
2. [서버] 데이터 가져옴: [{ id: 1, name: '텐트' }, ...]
|
||||
3. [서버] 데이터를 HTML에 포함 + payload에 직렬화
|
||||
4. [클라이언트] HTML 즉시 표시 (데이터가 이미 있음)
|
||||
5. [클라이언트] Hydration 시 payload에서 데이터 복원
|
||||
6. [클라이언트] /api/products 중복 요청 안 함 ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 서버/클라이언트 실행 분기
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// server: false → 클라이언트에서만 실행 (SEO 불필요한 데이터)
|
||||
const { data: userProfile } = await useFetch('/api/me', {
|
||||
server: false, // 서버에서 실행 안 함
|
||||
})
|
||||
|
||||
// lazy: true → 비동기적으로 로딩 (페이지 전환을 막지 않음)
|
||||
const { data: recommendations } = await useFetch('/api/recommendations', {
|
||||
lazy: true,
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## $fetch — 이벤트 핸들러에서 직접 요청
|
||||
|
||||
`$fetch`는 컴포저블 없이 즉시 요청할 때 사용한다.
|
||||
(setup() 최상위에서는 `useFetch`를 쓸 것)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// 버튼 클릭 같은 이벤트에서 직접 호출
|
||||
async function handleSubmit() {
|
||||
const result = await $fetch('/api/purchases', {
|
||||
method: 'POST',
|
||||
body: { name: '텐트', price: 150000 }
|
||||
})
|
||||
console.log(result)
|
||||
}
|
||||
|
||||
// 삭제
|
||||
async function handleDelete(id) {
|
||||
await $fetch(`/api/purchases/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 로딩/에러 상태 처리 패턴
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const { data, pending, error, refresh } = await useFetch('/api/products', {
|
||||
default: () => []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 로딩 중 -->
|
||||
<USkeletonList v-if="pending" />
|
||||
|
||||
<!-- 에러 -->
|
||||
<UAlert
|
||||
v-else-if="error"
|
||||
color="red"
|
||||
title="데이터를 불러오지 못했습니다"
|
||||
:description="error.message"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton @click="refresh">다시 시도</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<!-- 데이터 -->
|
||||
<ProductList v-else :items="data" />
|
||||
</template>
|
||||
```
|
||||
234
docs/curriculum/05-state-management.md
Normal file
234
docs/curriculum/05-state-management.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# 5. 상태관리 전략
|
||||
|
||||
## 세 가지 선택지
|
||||
|
||||
| 방법 | 범위 | 언제 사용 |
|
||||
|------|------|----------|
|
||||
| **Local State** (`ref`, `reactive`) | 컴포넌트 내부 | 해당 컴포넌트만 사용하는 UI 상태 |
|
||||
| **Composable** (`useState`) | 여러 컴포넌트 공유 | 경량 전역 상태, 인증 정보 등 |
|
||||
| **Pinia** | 앱 전체 | 복잡한 비즈니스 로직, 대규모 앱 |
|
||||
|
||||
---
|
||||
|
||||
## Local State — 컴포넌트 내부 상태
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// 컴포넌트에서만 쓰는 상태
|
||||
const isOpen = ref(false)
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
price: 0
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**적합한 경우:**
|
||||
- 모달 열림/닫힘
|
||||
- 폼 입력값
|
||||
- 로컬 필터/정렬 상태
|
||||
|
||||
---
|
||||
|
||||
## Composable (useState) — 경량 전역 상태
|
||||
|
||||
`useState`는 Nuxt 전용으로, SSR에서 서버/클라이언트 간 상태를 안전하게 공유한다.
|
||||
`ref`와 달리 SSR payload에 포함되어 Hydration 시 상태가 유지된다.
|
||||
|
||||
```ts
|
||||
// composables/useAuth.ts
|
||||
export function useAuth() {
|
||||
// 첫 번째 인자는 고유 키 (SSR payload 직렬화에 사용됨)
|
||||
const user = useState('auth-user', () => null)
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const data = await $fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { email, password }
|
||||
})
|
||||
user.value = data.user
|
||||
}
|
||||
|
||||
function logout() {
|
||||
user.value = null
|
||||
}
|
||||
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
|
||||
return { user, isLoggedIn, login, logout }
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- 어떤 컴포넌트에서든 같은 상태를 공유 -->
|
||||
<script setup>
|
||||
const { user, isLoggedIn, logout } = useAuth()
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pinia — 대규모 앱의 전역 상태
|
||||
|
||||
Nuxt에서 Pinia는 `@pinia/nuxt` 모듈로 자동 연동된다.
|
||||
|
||||
### Store 정의
|
||||
|
||||
```ts
|
||||
// stores/cart.ts
|
||||
export const useCartStore = defineStore('cart', () => {
|
||||
// State
|
||||
const items = ref<CartItem[]>([])
|
||||
|
||||
// Getters (computed)
|
||||
const totalCount = computed(() =>
|
||||
items.value.reduce((sum, item) => sum + item.quantity, 0)
|
||||
)
|
||||
|
||||
const totalPrice = computed(() =>
|
||||
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
)
|
||||
|
||||
// Actions
|
||||
function addItem(product: Product) {
|
||||
const existing = items.value.find(i => i.id === product.id)
|
||||
if (existing) {
|
||||
existing.quantity++
|
||||
} else {
|
||||
items.value.push({ ...product, quantity: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
function removeItem(productId: string) {
|
||||
items.value = items.value.filter(i => i.id !== productId)
|
||||
}
|
||||
|
||||
async function checkout() {
|
||||
await $fetch('/api/orders', {
|
||||
method: 'POST',
|
||||
body: { items: items.value }
|
||||
})
|
||||
items.value = []
|
||||
}
|
||||
|
||||
return { items, totalCount, totalPrice, addItem, removeItem, checkout }
|
||||
})
|
||||
```
|
||||
|
||||
### Store 사용
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const cart = useCartStore()
|
||||
|
||||
// storeToRefs로 반응성을 유지하며 구조분해
|
||||
const { items, totalCount, totalPrice } = storeToRefs(cart)
|
||||
// 액션은 그냥 구조분해 가능
|
||||
const { addItem, removeItem } = cart
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="addItem(product)">
|
||||
장바구니 담기 ({{ totalCount }})
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 전역 UI 상태 — 모달/토스트 패턴
|
||||
|
||||
```ts
|
||||
// composables/useModal.ts
|
||||
export function useModal() {
|
||||
const isOpen = useState('modal-open', () => false)
|
||||
const modalContent = useState<string | null>('modal-content', () => null)
|
||||
|
||||
function open(content: string) {
|
||||
modalContent.value = content
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
modalContent.value = null
|
||||
}
|
||||
|
||||
return { isOpen, modalContent, open, close }
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// composables/useToast.ts — Nuxt UI의 useToast 활용
|
||||
export function useAppToast() {
|
||||
const toast = useToast()
|
||||
|
||||
function success(message: string) {
|
||||
toast.add({
|
||||
title: '성공',
|
||||
description: message,
|
||||
color: 'green'
|
||||
})
|
||||
}
|
||||
|
||||
function error(message: string) {
|
||||
toast.add({
|
||||
title: '오류',
|
||||
description: message,
|
||||
color: 'red'
|
||||
})
|
||||
}
|
||||
|
||||
return { success, error }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 인증 상태 관리
|
||||
|
||||
프로젝트에서 Supabase를 사용할 때의 패턴:
|
||||
|
||||
```ts
|
||||
// composables/useAuth.ts
|
||||
export function useAuth() {
|
||||
const client = useSupabaseClient()
|
||||
const user = useSupabaseUser() // @nuxtjs/supabase 제공
|
||||
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
|
||||
async function signInWithEmail(email: string) {
|
||||
await client.auth.signInWithOtp({ email })
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
await client.auth.signOut()
|
||||
navigateTo('/login')
|
||||
}
|
||||
|
||||
return { user, isLoggedIn, signInWithEmail, signOut }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 전략 선택 가이드
|
||||
|
||||
```
|
||||
이 상태를 다른 컴포넌트에서도 쓰나?
|
||||
├── 아니오 → Local State (ref, reactive)
|
||||
└── 예 →
|
||||
복잡한 로직이나 비동기 작업이 있나?
|
||||
├── 예 → Pinia Store
|
||||
└── 아니오 → Composable (useState)
|
||||
```
|
||||
|
||||
**흔한 패턴:**
|
||||
- 모달 열림/닫힘 → `Local State`
|
||||
- 사용자 정보, 인증 → `Composable` (useAuth)
|
||||
- 장바구니, 알림 목록 → `Pinia`
|
||||
- 테마, 언어 설정 → `Composable` (useState + localStorage)
|
||||
265
docs/curriculum/06-composables.md
Normal file
265
docs/curriculum/06-composables.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# 6. 커스텀 Composables & useXxx 패턴
|
||||
|
||||
## Composable이란?
|
||||
|
||||
반복되는 상태·로직을 `useXxx` 함수로 추상화하는 Vue/Nuxt의 패턴이다.
|
||||
`composables/` 디렉토리에 파일을 만들면 **자동으로 전역 import**된다.
|
||||
|
||||
```
|
||||
composables/
|
||||
├── useAuth.ts → useAuth() 자동 임포트
|
||||
├── useApi.ts → useApi() 자동 임포트
|
||||
└── usePurchases.ts → usePurchases() 자동 임포트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 기본 구조
|
||||
|
||||
```ts
|
||||
// composables/useCounter.ts
|
||||
export function useCounter(initialValue = 0) {
|
||||
const count = ref(initialValue)
|
||||
|
||||
function increment() { count.value++ }
|
||||
function decrement() { count.value-- }
|
||||
function reset() { count.value = initialValue }
|
||||
|
||||
return { count, increment, decrement, reset }
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// 자동 임포트 — import 문 불필요
|
||||
const { count, increment, reset } = useCounter(10)
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 규칙
|
||||
|
||||
### 1. setup() 최상위에서만 호출
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ 올바른 위치
|
||||
const { data } = usePurchases()
|
||||
|
||||
function handleClick() {
|
||||
// ❌ 함수 내부에서 호출 불가 (onMounted, watch 내부도 마찬가지)
|
||||
// const { data } = usePurchases()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. 서버/클라이언트 환경 분기 안전하게 처리
|
||||
|
||||
```ts
|
||||
// composables/useLocalStorage.ts
|
||||
export function useLocalStorage<T>(key: string, defaultValue: T) {
|
||||
const value = useState<T>(key, () => defaultValue)
|
||||
|
||||
// localStorage는 브라우저에만 존재
|
||||
if (import.meta.client) {
|
||||
const stored = localStorage.getItem(key)
|
||||
if (stored) {
|
||||
value.value = JSON.parse(stored)
|
||||
}
|
||||
}
|
||||
|
||||
watch(value, (newValue) => {
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(key, JSON.stringify(newValue))
|
||||
}
|
||||
})
|
||||
|
||||
return value
|
||||
}
|
||||
```
|
||||
|
||||
### 3. useState로 SSR 안전 전역 상태
|
||||
|
||||
```ts
|
||||
// ❌ ref는 컴포넌트 인스턴스마다 별개
|
||||
export function useBad() {
|
||||
const count = ref(0) // 각 컴포넌트가 자신만의 count를 가짐
|
||||
return { count }
|
||||
}
|
||||
|
||||
// ✅ useState는 앱 전체에서 하나의 상태 공유
|
||||
export function useGood() {
|
||||
const count = useState('shared-count', () => 0)
|
||||
return { count }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 실전 예시: useApi
|
||||
|
||||
```ts
|
||||
// composables/useApi.ts
|
||||
export function useApi() {
|
||||
const config = useRuntimeConfig()
|
||||
const { user } = useAuth()
|
||||
|
||||
async function get<T>(path: string, options?: object): Promise<T> {
|
||||
return $fetch<T>(path, {
|
||||
baseURL: config.public.apiBase,
|
||||
headers: {
|
||||
Authorization: user.value ? `Bearer ${user.value.token}` : undefined,
|
||||
},
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body: object): Promise<T> {
|
||||
return $fetch<T>(path, {
|
||||
method: 'POST',
|
||||
body,
|
||||
baseURL: config.public.apiBase,
|
||||
})
|
||||
}
|
||||
|
||||
return { get, post }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 실전 예시: usePurchases (이 프로젝트 패턴)
|
||||
|
||||
```ts
|
||||
// composables/usePurchases.ts
|
||||
export function usePurchases() {
|
||||
const client = useSupabaseClient()
|
||||
const toast = useToast()
|
||||
|
||||
const { data: purchases, pending, refresh } = useAsyncData(
|
||||
'purchases',
|
||||
() => client
|
||||
.from('purchases')
|
||||
.select('*')
|
||||
.order('purchased_at', { ascending: false })
|
||||
.then(({ data, error }) => {
|
||||
if (error) throw error
|
||||
return data
|
||||
})
|
||||
)
|
||||
|
||||
async function create(purchase: PurchaseInsert) {
|
||||
const { error } = await client.from('purchases').insert(purchase)
|
||||
|
||||
if (error) {
|
||||
toast.add({ title: '등록 실패', color: 'red', description: error.message })
|
||||
return false
|
||||
}
|
||||
|
||||
toast.add({ title: '등록 완료', color: 'green' })
|
||||
await refresh()
|
||||
return true
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
const { error } = await client.from('purchases').delete().eq('id', id)
|
||||
|
||||
if (error) {
|
||||
toast.add({ title: '삭제 실패', color: 'red' })
|
||||
return false
|
||||
}
|
||||
|
||||
await refresh()
|
||||
return true
|
||||
}
|
||||
|
||||
return { purchases, pending, create, remove, refresh }
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- pages/purchases/index.vue -->
|
||||
<script setup>
|
||||
const { purchases, pending, create } = usePurchases()
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 서버/클라이언트 환경 분기
|
||||
|
||||
```ts
|
||||
// composables/usePlatform.ts
|
||||
export function usePlatform() {
|
||||
const isServer = import.meta.server
|
||||
const isClient = import.meta.client
|
||||
|
||||
// 서버에서만 사용 가능한 기능
|
||||
function getServerData() {
|
||||
if (!isServer) return null
|
||||
return process.env.SECRET_DATA
|
||||
}
|
||||
|
||||
// 클라이언트에서만 사용 가능한 기능
|
||||
function getBrowserData() {
|
||||
if (!isClient) return null
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
}
|
||||
}
|
||||
|
||||
return { isServer, isClient, getServerData, getBrowserData }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hydration 안전 패턴
|
||||
|
||||
서버에서 생성된 값과 클라이언트에서 재생성된 값이 달라 생기는 불일치를 방지한다.
|
||||
|
||||
```ts
|
||||
// ❌ 위험: Math.random()은 서버와 클라이언트에서 다른 값을 생성
|
||||
export function useUnsafeId() {
|
||||
const id = ref(Math.random()) // Hydration mismatch!
|
||||
return { id }
|
||||
}
|
||||
|
||||
// ✅ 안전: useState로 서버에서 생성한 값을 클라이언트에 전달
|
||||
export function useSafeId() {
|
||||
const id = useState('unique-id', () => Math.random())
|
||||
return { id }
|
||||
}
|
||||
|
||||
// ✅ 안전: 브라우저 전용 값은 onMounted에서 설정
|
||||
export function useSafeWindowSize() {
|
||||
const width = ref(0) // 서버에서는 0
|
||||
|
||||
onMounted(() => {
|
||||
width.value = window.innerWidth // 클라이언트에서 업데이트
|
||||
})
|
||||
|
||||
return { width }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 정리: 언제 무엇을 쓸까
|
||||
|
||||
```
|
||||
반복되는 로직인가?
|
||||
└── 예 → Composable로 추출
|
||||
|
||||
상태를 컴포넌트 간 공유하나?
|
||||
├── 아니오 → 컴포넌트 내 ref/reactive
|
||||
└── 예 →
|
||||
SSR이 필요하거나 Hydration 안전이 필요한가?
|
||||
├── 예 → useState
|
||||
└── 아니오 → ref (composable 내부)
|
||||
|
||||
복잡한 비즈니스 로직이 많은가?
|
||||
└── 예 → Pinia Store 고려
|
||||
```
|
||||
266
docs/curriculum/07-caching-strategy.md
Normal file
266
docs/curriculum/07-caching-strategy.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# 7. 최적화 캐시 / 재호출 전략
|
||||
|
||||
## 캐시 레이어 구조
|
||||
|
||||
Nuxt의 캐시는 여러 레이어에서 작동한다.
|
||||
|
||||
```
|
||||
브라우저 캐시 (HTTP Cache-Control)
|
||||
↑
|
||||
CDN / Edge 캐시
|
||||
↑
|
||||
Nitro 서버 캐시 (cachedEventHandler, cachedFunction)
|
||||
↑
|
||||
useFetch / useAsyncData 캐시 (payload 기반)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. useFetch / useAsyncData 캐시
|
||||
|
||||
### 기본 캐시 동작 (payload)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// 서버에서 가져온 데이터는 payload에 저장됨
|
||||
// 클라이언트에서 같은 키로 요청하면 재요청 없이 payload 사용
|
||||
const { data } = await useFetch('/api/products', {
|
||||
key: 'products-list' // 명시적 캐시 키 (기본값: URL + params 조합)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### stale-while-revalidate 패턴
|
||||
|
||||
"캐시된 데이터를 즉시 보여주면서 백그라운드에서 최신 데이터를 가져오는" 전략이다.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const { data, refresh } = await useFetch('/api/products', {
|
||||
// 1. 캐시된 데이터로 즉시 렌더링
|
||||
// 2. 백그라운드에서 새 데이터 요청
|
||||
// 3. 새 데이터 도착 시 자동으로 UI 업데이트
|
||||
getCachedData(key, nuxtApp) {
|
||||
// nuxtApp.payload.data에 이미 캐시된 데이터가 있으면 사용
|
||||
return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 수동 refresh와 자동 watch
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const page = ref(1)
|
||||
const category = ref('tent')
|
||||
|
||||
const { data, pending, refresh } = await useFetch('/api/products', {
|
||||
query: { page, category },
|
||||
watch: [page, category], // 값이 바뀌면 자동으로 재요청
|
||||
})
|
||||
|
||||
// 수동으로 재요청
|
||||
async function handleRefresh() {
|
||||
await refresh()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### invalidation — 캐시 무효화 타이밍
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const { data: products, refresh } = await useFetch('/api/products')
|
||||
|
||||
// 상품 등록 후 목록 갱신
|
||||
async function createProduct(newProduct) {
|
||||
await $fetch('/api/products', {
|
||||
method: 'POST',
|
||||
body: newProduct
|
||||
})
|
||||
// 캐시 무효화: 수동 refresh
|
||||
await refresh()
|
||||
}
|
||||
|
||||
// 또는 clearNuxtData로 특정 키 캐시 삭제
|
||||
function invalidateCache() {
|
||||
clearNuxtData('products-list')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Nitro 서버 캐시
|
||||
|
||||
서버 API 응답 자체를 캐시한다. DB 부하를 크게 줄일 수 있다.
|
||||
|
||||
### cachedEventHandler
|
||||
|
||||
```ts
|
||||
// server/api/products.ts
|
||||
export default cachedEventHandler(async (event) => {
|
||||
// DB에서 데이터 조회
|
||||
const products = await db.from('products').select('*')
|
||||
return products
|
||||
}, {
|
||||
maxAge: 60 * 60, // 1시간 캐시
|
||||
name: 'products', // 캐시 키 이름
|
||||
getKey: (event) => {
|
||||
// 쿼리 파라미터에 따라 다른 캐시 키 사용
|
||||
const query = getQuery(event)
|
||||
return `products-${query.category}-${query.page}`
|
||||
},
|
||||
// 캐시 무효화 조건
|
||||
shouldBypassCache: (event) => {
|
||||
return getHeader(event, 'Cache-Control') === 'no-cache'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### cachedFunction
|
||||
|
||||
```ts
|
||||
// server/utils/db.ts
|
||||
export const getCachedProducts = cachedFunction(
|
||||
async (category: string) => {
|
||||
return await db.from('products').select('*').eq('category', category)
|
||||
},
|
||||
{
|
||||
maxAge: 60 * 10, // 10분 캐시
|
||||
name: 'get-products',
|
||||
getKey: (category) => category,
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. routeRules 캐시 (ISR)
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
routeRules: {
|
||||
// ISR: 첫 요청 후 60초간 캐시, 만료 시 백그라운드에서 재생성
|
||||
'/products/**': { isr: 60 },
|
||||
|
||||
// SSG: 빌드 시 생성, 변경 없음
|
||||
'/blog/**': { prerender: true },
|
||||
|
||||
// CDN 캐시 헤더 설정
|
||||
'/api/public/**': {
|
||||
headers: {
|
||||
'Cache-Control': 's-maxage=3600, stale-while-revalidate=86400'
|
||||
}
|
||||
},
|
||||
|
||||
// 캐시 완전 비활성화
|
||||
'/api/realtime/**': {
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트 레벨 캐시
|
||||
|
||||
### lazy 로딩 + 조건부 요청
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const isTabActive = ref(false)
|
||||
|
||||
// isTabActive가 true가 될 때만 데이터 요청
|
||||
const { data } = await useFetch('/api/analytics', {
|
||||
immediate: false, // 즉시 요청하지 않음
|
||||
})
|
||||
|
||||
watch(isTabActive, (active) => {
|
||||
if (active) refresh()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### keep-alive로 컴포넌트 캐시
|
||||
|
||||
```vue
|
||||
<!-- 페이지 전환해도 컴포넌트 상태 유지 -->
|
||||
<NuxtPage :keepalive="{ include: ['ProductList', 'Dashboard'] }" />
|
||||
```
|
||||
|
||||
```ts
|
||||
// pages/products/index.vue
|
||||
defineOptions({
|
||||
name: 'ProductList' // keepalive에서 참조하는 이름
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 실전 캐시 전략 예시
|
||||
|
||||
### 상품 목록 (자주 안 바뀜)
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts
|
||||
routeRules: {
|
||||
'/products': { isr: 3600 } // 1시간마다 재생성
|
||||
}
|
||||
|
||||
// server/api/products.ts
|
||||
export default cachedEventHandler(handler, {
|
||||
maxAge: 60 * 60, // Nitro도 1시간 캐시
|
||||
})
|
||||
```
|
||||
|
||||
### 사용자 전용 데이터 (캐시 불가)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// server: false로 서버 캐시 우회
|
||||
// 사용자별 데이터는 서버에서 캐시하면 안 됨
|
||||
const { data: myOrders } = await useFetch('/api/my/orders', {
|
||||
server: false, // 클라이언트에서만 요청
|
||||
headers: useRequestHeaders(['cookie']) // 인증 쿠키 전달
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 실시간 데이터 (폴링)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const { data, refresh } = await useFetch('/api/stock', {
|
||||
server: false
|
||||
})
|
||||
|
||||
// 30초마다 자동 갱신
|
||||
let interval: ReturnType<typeof setInterval>
|
||||
|
||||
onMounted(() => {
|
||||
interval = setInterval(refresh, 30_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 요약
|
||||
|
||||
| 전략 | 사용 시점 | 구현 방법 |
|
||||
|------|----------|----------|
|
||||
| **Payload 캐시** | SSR 초기 로딩 중복 방지 | useFetch 기본 동작 |
|
||||
| **수동 refresh** | 변경 후 최신 데이터 필요 | `refresh()` 호출 |
|
||||
| **watch 자동 재요청** | 필터/페이지 변경 시 | `watch` 옵션 |
|
||||
| **Nitro 서버 캐시** | DB 부하 절감 | `cachedEventHandler` |
|
||||
| **ISR** | 준정적 콘텐츠 | `routeRules: { isr: N }` |
|
||||
| **keep-alive** | 페이지 전환 시 상태 유지 | `<NuxtPage keepalive>` |
|
||||
| **폴링** | 실시간 데이터 | `setInterval + refresh` |
|
||||
24
docs/curriculum/curriculum.md
Normal file
24
docs/curriculum/curriculum.md
Normal file
@@ -0,0 +1,24 @@
|
||||
1. 설정&모듈 + 서버 렌더링 모드 (SSR / SSG / SPA / Edge)
|
||||
nuxt.config.ts에서의 핵심 옵션(app, runtimeConfig, nitro, routeRules 등).
|
||||
Nuxt 4에서 지원하는 렌더링 전략(SSR, SSG, SPA, Hybrid)과 nuxt.config에서의 설정 방식.
|
||||
언제 SSR/SSG를 써야 하는지, SEO·성능·캐싱 관점의 차이.
|
||||
|
||||
2. rendering 흐름 & lifecycle
|
||||
SSR → Hydration → CSR 전환 흐름서버/클라이언트별 lifecycle (setup, onMounted, onServerPrefetch 등)
|
||||
|
||||
3. 서버 엔드포인트 (server/api/_ & server/routes/_)
|
||||
Nitro 기반 서버: server/api/\*.ts로 API 라우트 만드는 법서버 유틸(eventHandler, getQuery, readBody) 사용.
|
||||
|
||||
4. 데이터 페칭 (useAsyncData, useFetch 등)
|
||||
컴포넌트에서 비동기 데이터 불러오는 패턴과 캐싱,
|
||||
에러/로딩 상태 관리.서버에서 먼저 데이터 로딩 후 HTML 렌더링되는 흐름(SSR 시) 이해.
|
||||
서버/클라 어디에서 어떻게 부를지, 에러·로딩 관리 패턴
|
||||
|
||||
5. 상태관리 기준
|
||||
언제 local state / composable / Pinia를 쓸지 기준 세우기auth, 공통 UI 상태(모달/토스트) 같은 예시 기반
|
||||
|
||||
6. 컴포저블(Composables) & useXxx 패턴
|
||||
composables/ 폴더에 공통 로직을 useAuth, useApi처럼 추상화하는 방식.
|
||||
상태 공유, 서버/클라이언트 환경 차이(hydration) 고려한 composable 설계.
|
||||
7. 간단한 캐시 / 재호출 전략 설계
|
||||
stale-while-revalidate 느낌의 패턴재호출 조건, invalidation 타이밍, 초간단 리스트/상세 캐싱 패턴
|
||||
18
nuxt.config.ts
Normal file
18
nuxt.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
|
||||
routeRules: {
|
||||
// SPA: 클라이언트에서만 렌더링 (dev/prod 공통)
|
||||
'/spa': { ssr: false },
|
||||
},
|
||||
|
||||
// SSG, ISR은 빌드/배포 환경에서만 적용
|
||||
$production: {
|
||||
routeRules: {
|
||||
'/ssg': { prerender: true },
|
||||
'/hybrid': { isr: 60 },
|
||||
},
|
||||
},
|
||||
})
|
||||
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "nuxt4-deep",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "^4.4.2",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^5.0.4"
|
||||
}
|
||||
}
|
||||
6640
pnpm-lock.yaml
generated
Normal file
6640
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
6
server/api/build-time.ts
Normal file
6
server/api/build-time.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default defineEventHandler(() => {
|
||||
return {
|
||||
time: new Date().toISOString(),
|
||||
message: '이 값은 nuxt generate 실행 시 1회 생성되어 HTML payload에 포함됩니다.',
|
||||
}
|
||||
})
|
||||
6
server/api/time.ts
Normal file
6
server/api/time.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default defineEventHandler(() => {
|
||||
return {
|
||||
time: new Date().toISOString(),
|
||||
message: '서버에서 요청마다 생성된 시간입니다.',
|
||||
}
|
||||
})
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user