1. Nuxt.js란?
Nuxt.js는 Vue 3를 기반으로 한 풀스택 프레임워크입니다. SSR(서버 사이드 렌더링), SSG(정적 사이트 생성), SPA 모드를 하나의 프레임워크에서 지원하며, 파일 기반 라우팅과 자동 임포트로 개발 생산성을 극대화합니다.
| 렌더링 모드 | 설명 | 적합한 사용 사례 |
|---|---|---|
| SSR | 요청마다 서버에서 HTML 생성 | 동적 콘텐츠, 개인화 페이지 |
| SSG | 빌드 시 모든 페이지 사전 생성 | 블로그, 문서 사이트, 마케팅 페이지 |
| SPA | 클라이언트에서만 렌더링 | 관리자 대시보드, 인증 후 앱 |
| ISR | 빌드 후 필요 시 재생성 | 자주 업데이트되는 정적 콘텐츠 |
Nuxt 3 핵심 기능
- 자동 임포트: components/, composables/, utils/ 폴더의 파일이 자동으로 임포트됩니다.
- 파일 기반 라우팅: pages/ 폴더 구조가 그대로 URL이 됩니다.
- Nitro 서버: server/ 폴더로 풀스택 API를 바로 작성할 수 있습니다.
- TypeScript 우선: 설정 없이 TypeScript를 사용할 수 있습니다.
- Vite 기반: HMR이 빠르고 번들 크기가 최적화됩니다.
2. 프로젝트 생성
# Nuxt 3 프로젝트 생성
npx nuxi@latest init my-app
cd my-app
# 의존성 설치
npm install
# 개발 서버 시작 (http://localhost:3000)
npm run dev
# 프로덕션 빌드
npm run build
npm run previewbash
my-app/
├── .nuxt/ # 빌드 캐시 (자동 생성, git 제외)
├── assets/ # CSS, 이미지 등 정적 자산 (Vite가 처리)
├── components/ # Vue 컴포넌트 (자동 임포트)
├── composables/ # 컴포저블 함수 (자동 임포트)
├── layouts/ # 레이아웃 컴포넌트
├── middleware/ # 라우트 미들웨어
├── pages/ # 파일 기반 라우팅
├── plugins/ # Nuxt 플러그인
├── public/ # 정적 파일 (그대로 제공)
├── server/ # Nitro 서버 (API, 미들웨어)
│ ├── api/ # API 라우트
│ ├── middleware/ # 서버 미들웨어
│ └── utils/ # 서버 유틸리티
├── utils/ # 클라이언트/서버 공용 유틸
├── app.vue # 앱 루트 컴포넌트
├── nuxt.config.ts # Nuxt 설정 파일
└── package.jsontext
// nuxt.config.ts
export default defineNuxtConfig({
devtools: { enabled: true },
// SSR 모드 설정 (기본값: true)
ssr: true,
// 모듈
modules: [
'@pinia/nuxt',
'@nuxtjs/tailwindcss',
'@nuxt/content',
],
// 런타임 설정 (환경 변수)
runtimeConfig: {
// 서버에서만 접근 가능
dbUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
// 클라이언트에서도 접근 가능
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? '/api',
appName: 'MyApp',
},
},
// 실험적 기능
experimental: {
typedPages: true,
},
})typescript
3. 파일 기반 라우팅
pages/ 폴더의 .vue 파일이 자동으로 라우트가 됩니다. 특별한 설정 없이 파일을 만들기만 하면 됩니다.
pages/
├── index.vue → /
├── about.vue → /about
├── blog/
│ ├── index.vue → /blog
│ └── [slug].vue → /blog/:slug
├── users/
│ ├── index.vue → /users
│ ├── [id].vue → /users/:id
│ └── [id]/
│ └── posts.vue → /users/:id/posts
└── [...path].vue → /* (catch-all)text
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
// useRoute로 현재 라우트 정보 접근
const route = useRoute()
const slug = computed(() => route.params.slug as string)
// 타입이 있는 라우트 (experimental.typedPages: true 필요)
// const route = useRoute('/blog/[slug]')
const { data: post, error } = await useFetch(`/api/posts/${slug.value}`)
// SEO 설정 (이 페이지에서만 적용)
useSeoMeta({
title: () => post.value?.title ?? 'Loading...',
description: () => post.value?.excerpt,
ogImage: () => post.value?.coverImage,
})
</script>
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<p>{{ post.excerpt }}</p>
</article>
<div v-else-if="error">포스트를 찾을 수 없습니다.</div>
</template>vue
<!-- NuxtLink: Next.js의 Link와 동일, 프리패치 지원 -->
<script setup>
const router = useRouter()
// 프로그래매틱 네비게이션
function goToUser(id: number) {
router.push(`/users/${id}`)
// 또는: navigateTo(`/users/${id}`)
}
</script>
<template>
<nav>
<!-- 기본 링크 -->
<NuxtLink to="/">홈</NuxtLink>
<!-- 활성 클래스 커스터마이징 -->
<NuxtLink
to="/blog"
active-class="text-green-600 font-bold"
exact-active-class="underline"
>
블로그
</NuxtLink>
<!-- 동적 경로 -->
<NuxtLink :to="`/users/${userId}`">프로필</NuxtLink>
<!-- 외부 링크 -->
<NuxtLink to="https://nuxt.com" external>Nuxt 공식 문서</NuxtLink>
</nav>
</template>vue
4. 레이아웃 & 페이지
layouts/ 폴더에 레이아웃을 정의하고, definePageMeta로 각 페이지에서 사용할 레이아웃을 지정합니다.
<!-- layouts/default.vue -->
<template>
<div>
<header class="site-header">
<NuxtLink to="/">MyApp</NuxtLink>
<nav>
<NuxtLink to="/blog">블로그</NuxtLink>
<NuxtLink to="/about">소개</NuxtLink>
</nav>
</header>
<main class="container">
<!-- 페이지 컴포넌트가 이 자리에 렌더링됩니다 -->
<slot />
</main>
<footer>© 2026 MyApp</footer>
</div>
</template>vue
<!-- layouts/admin.vue -->
<template>
<div class="admin-layout">
<aside class="sidebar">
<AdminNav />
</aside>
<div class="admin-content">
<AdminHeader />
<slot />
</div>
</div>
</template>vue
<!-- pages/admin/dashboard.vue -->
<script setup lang="ts">
// 레이아웃, 미들웨어, 페이지 메타 설정
definePageMeta({
layout: 'admin', // layouts/admin.vue 사용
middleware: ['auth'], // middleware/auth.ts 실행
pageTransition: {
name: 'slide',
mode: 'out-in',
},
})
</script>
<template>
<div>
<h1>대시보드</h1>
</div>
</template>vue
app.vue와 NuxtLayout
app.vue에서
app.vue에서
<NuxtLayout>과 <NuxtPage>를 사용하면 레이아웃 전환 애니메이션을 전역으로 제어할 수 있습니다.5. useFetch / useAsyncData
Nuxt의 데이터 페칭 컴포저블은 SSR과 클라이언트 간의 데이터를 자동으로 하이드레이션합니다.
<!-- useFetch: 가장 간단한 데이터 페칭 -->
<script setup lang="ts">
interface User {
id: number
name: string
email: string
}
// 기본 사용
const { data: user, pending, error, refresh } = await useFetch<User>('/api/users/1')
// 반응형 파라미터로 동적 쿼리
const page = ref(1)
const search = ref('')
const { data: users } = await useFetch('/api/users', {
query: { page, search }, // 반응형 - page/search가 바뀌면 자동 재요청
pick: ['id', 'name'], // 특정 필드만 선택 (번들 크기 최적화)
default: () => [], // 초기값
})
// POST 요청
const { data, execute } = await useFetch('/api/users', {
method: 'POST',
body: { name: '홍길동', email: 'hong@example.com' },
immediate: false, // 즉시 실행하지 않음
})
async function createUser() {
await execute()
}
</script>
<template>
<div>
<div v-if="pending">로딩 중...</div>
<div v-else-if="error">에러: {{ error.message }}</div>
<div v-else>{{ user?.name }}</div>
<button @click="refresh()">새로고침</button>
</div>
</template>vue
<!-- useAsyncData: 더 세밀한 제어가 필요할 때 -->
<script setup lang="ts">
const route = useRoute()
// 고유 키로 캐싱 제어
const { data: post } = await useAsyncData(
`post-${route.params.slug}`, // 캐시 키
() => $fetch(`/api/posts/${route.params.slug}`),
{
// 서버에서만 실행 (클라이언트 재요청 없음)
server: true,
lazy: false,
// 데이터 변환
transform: (data) => ({
...data,
publishedAt: new Date(data.publishedAt).toLocaleDateString('ko-KR'),
}),
// 캐시 유효시간 (초)
getCachedData: (key, nuxtApp) =>
nuxtApp.payload.data[key] ?? nuxtApp.static.data[key],
}
)
// 여러 요청 병렬 처리
const [{ data: posts }, { data: tags }] = await Promise.all([
useAsyncData('posts', () => $fetch('/api/posts')),
useAsyncData('tags', () => $fetch('/api/tags')),
])
</script>vue
6. 서버 API Routes (Nitro)
server/api/ 폴더에 파일을 만들면 자동으로 API 엔드포인트가 생성됩니다. 파일명에 HTTP 메서드를 포함할 수 있습니다.
// server/api/users/index.get.ts → GET /api/users
import { defineEventHandler, getQuery, createError } from 'h3'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Number(query.page ?? 1)
const limit = Number(query.limit ?? 20)
const search = String(query.search ?? '')
// DB 조회 (예시: Prisma 사용)
const [users, total] = await Promise.all([
prisma.user.findMany({
where: search ? { name: { contains: search } } : undefined,
skip: (page - 1) * limit,
take: limit,
select: { id: true, name: true, email: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
prisma.user.count(),
])
return {
data: users,
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
}
})typescript
// server/api/users/[id].ts → GET/PUT/DELETE /api/users/:id
import {
defineEventHandler, getRouterParam, readBody,
createError, assertMethod
} from 'h3'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, message: 'ID가 필요합니다' })
// GET
if (event.method === 'GET') {
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
if (!user) throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다' })
return user
}
// PUT
if (event.method === 'PUT') {
const body = await readBody(event)
return prisma.user.update({
where: { id: Number(id) },
data: { name: body.name, email: body.email },
})
}
// DELETE
if (event.method === 'DELETE') {
await prisma.user.delete({ where: { id: Number(id) } })
return { success: true }
}
})typescript
// server/api/users/index.post.ts → POST /api/users
import { defineEventHandler, readBody, createError } from 'h3'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// 유효성 검사
if (!body.name || !body.email) {
throw createError({ statusCode: 422, message: 'name과 email이 필요합니다' })
}
// 런타임 설정 (nuxt.config.ts의 runtimeConfig)
const config = useRuntimeConfig()
// config.dbUrl, config.jwtSecret 등 서버 전용 설정 접근 가능
const user = await prisma.user.create({
data: { name: body.name, email: body.email },
})
// 201 Created
setResponseStatus(event, 201)
return user
})typescript
7. 컴포넌트 자동 임포트
Nuxt는 components/, composables/, utils/ 폴더의 파일을 자동으로 임포트합니다. import 구문을 직접 작성할 필요가 없습니다.
>// composables/useCounter.ts
// composables/ 폴더에 있으면 자동 임포트됩니다
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initialValue }
return { count, doubled, increment, decrement, reset }
}
// composables/useAuth.ts
export function useAuth() {
const user = useState<User | null>('auth.user', () => null)
const isLoggedIn = computed(() => user.value !== null)
async function login(email: string, password: string) {
const data = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password },
})
user.value = data.user
await navigateTo('/dashboard')
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
user.value = null
await navigateTo('/login')
}
return { user, isLoggedIn, login, logout }
}typescript
><!-- 사용하는 컴포넌트에서 import 없이 바로 사용 -->
<script setup lang="ts">
// useCounter, useAuth 모두 import 없이 사용 가능
const { count, increment, decrement } = useCounter(10)
const { user, isLoggedIn, logout } = useAuth()
// useHead: 페이지 <head> 제어
useHead({
title: '카운터 페이지',
link: [{ rel: 'stylesheet', href: '/custom.css' }],
script: [{ src: 'https://cdn.example.com/lib.js', defer: true }],
})
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<!-- components/UserAvatar.vue가 자동 임포트됨 -->
<UserAvatar v-if="isLoggedIn" :user="user" />
</div>
</template>vue
8. 상태 관리 (useState / Pinia)
useState (간단한 공유 상태)
>// SSR-safe 전역 상태 - 서버에서 클라이언트로 자동 하이드레이션
// composables/useTheme.ts
export function useTheme() {
// 첫 번째 인자는 고유 키 (SSR에서 상태 공유에 필요)
const theme = useState<'light' | 'dark'>('theme', () => 'light')
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
return { theme, toggleTheme }
}typescript
Pinia (복잡한 상태 관리)
>// nuxt.config.ts에 @pinia/nuxt 모듈 추가 후
// stores/cart.ts
import { defineStore } from 'pinia'
interface CartItem {
id: number
name: string
price: number
quantity: number
}
export const useCartStore = defineStore('cart', () => {
// State
const items = ref<CartItem[]>([])
// Getters
const totalItems = 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: Omit<CartItem, 'quantity'>) {
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
function removeItem(id: number) {
items.value = items.value.filter(i => i.id !== id)
}
function clearCart() {
items.value = []
}
return { items, totalItems, totalPrice, addItem, removeItem, clearCart }
}, {
// localStorage에 자동 저장 (pinia-plugin-persistedstate 필요)
persist: true,
})typescript
9. SEO & Meta 태그
><!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)
// useSeoMeta: 타입 안전한 SEO 메타 설정
useSeoMeta({
// 기본 메타
title: () => `${post.value?.title} | MyBlog`,
description: () => post.value?.excerpt,
// Open Graph (SNS 공유)
ogTitle: () => post.value?.title,
ogDescription: () => post.value?.excerpt,
ogImage: () => post.value?.coverImage,
ogType: 'article',
ogUrl: () => `https://myblog.com/blog/${route.params.slug}`,
// Twitter Card
twitterCard: 'summary_large_image',
twitterTitle: () => post.value?.title,
twitterImage: () => post.value?.coverImage,
// 검색 엔진
robots: 'index, follow',
})
// canonical URL
useHead({
link: [
{
rel: 'canonical',
href: `https://myblog.com/blog/${route.params.slug}`,
},
],
// JSON-LD 구조화 데이터
script: [
{
type: 'application/ld+json',
children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.value?.title,
author: { '@type': 'Person', name: post.value?.author },
datePublished: post.value?.publishedAt,
}),
},
],
})
</script>vue
10. 미들웨어
>// middleware/auth.ts
// 라우트 미들웨어 - 페이지 이동 전에 실행됩니다
export default defineNuxtRouteMiddleware((to, from) => {
const { isLoggedIn } = useAuth()
if (!isLoggedIn.value) {
// 로그인 페이지로 리다이렉트 (로그인 후 원래 페이지로 돌아갈 수 있도록 returnUrl 추가)
return navigateTo({
path: '/login',
query: { returnUrl: to.fullPath },
})
}
})typescript
>// middleware/admin.ts
export default defineNuxtRouteMiddleware(async (to) => {
const { user } = useAuth()
// 서버 사이드에서만 실행
if (import.meta.server) {
// 서버 사이드 세션 확인
const session = await useServerSession()
if (!session?.user) {
return navigateTo('/login')
}
}
// 관리자 권한 확인
if (user.value?.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: '관리자 권한이 필요합니다',
})
}
})
// plugins/error-handler.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('vue:error', (error, instance, info) => {
console.error('Vue Error:', error, info)
// Sentry 등에 오류 보고
})
})typescript
전역 미들웨어: 파일명을
middleware/auth.global.ts로 만들면 모든 라우트에 자동 적용됩니다. definePageMeta에 미들웨어를 지정하면 해당 페이지에서만 실행됩니다.11. 배포 (Vercel / Nitro)
Vercel 배포
># Vercel CLI로 배포
npm i -g vercel
vercel
# 또는 nuxt.config.ts에 preset 설정
# nitro.preset = 'vercel' 을 설정하면 vercel.json 없이도 동작bash
>// nuxt.config.ts - Vercel Edge Functions
export default defineNuxtConfig({
nitro: {
preset: 'vercel-edge', // Edge 런타임 (전 세계 엣지에서 실행)
// preset: 'vercel', // Node.js 런타임
},
})typescript
Cloudflare Workers
>// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: 'cloudflare-pages',
},
})
// wrangler.toml
// name = "my-nuxt-app"
// compatibility_date = "2024-01-01"
// pages_build_output_dir = ".output/public"typescript
Docker 배포
>FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.output ./
ENV NODE_ENV=production
ENV NITRO_PORT=3000
EXPOSE 3000
CMD ["node", "server/index.mjs"]dockerfile
>services:
nuxt:
build: .
ports: ["3000:3000"]
environment:
NUXT_PUBLIC_API_BASE: https://api.example.com
DATABASE_URL: postgresql://user:pass@db:5432/mydb
depends_on: [db]
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: mydb
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
pgdata:yaml
12. 다음 단계
Nuxt 심화 로드맵
• Nuxt Content: Markdown/MDX 기반 콘텐츠 관리 (블로그, 문서 사이트)
• Nuxt UI: 공식 UI 컴포넌트 라이브러리 (Tailwind 기반)
• Supabase 통합: @nuxtjs/supabase로 백엔드 없이 DB·인증 구현
• i18n: @nuxtjs/i18n으로 다국어 지원
• Nuxt DevTools: 브라우저에서 컴포넌트·라우트·컴포저블 탐색
• Nuxt Layers: 여러 Nuxt 앱 간 코드 공유
연계 가이드: Vue 가이드 · TypeScript 가이드
• Nuxt Content: Markdown/MDX 기반 콘텐츠 관리 (블로그, 문서 사이트)
• Nuxt UI: 공식 UI 컴포넌트 라이브러리 (Tailwind 기반)
• Supabase 통합: @nuxtjs/supabase로 백엔드 없이 DB·인증 구현
• i18n: @nuxtjs/i18n으로 다국어 지원
• Nuxt DevTools: 브라우저에서 컴포넌트·라우트·컴포저블 탐색
• Nuxt Layers: 여러 Nuxt 앱 간 코드 공유
연계 가이드: Vue 가이드 · TypeScript 가이드