💚
Vue 프레임워크 가이드

Nuxt.js 완전 가이드

👥 방문자 수

Vue 3 기반 풀스택 SSR 프레임워크. 파일 기반 라우팅, Nitro 서버, useFetch, SEO 최적화, Vercel 배포까지 한국어로 배우세요.

Nuxt.js 3 App 라우터 Nitro 서버 useFetch SSR/SSG

1. Nuxt.js란?

Nuxt.js는 Vue 3를 기반으로 한 풀스택 프레임워크입니다. SSR(서버 사이드 렌더링), SSG(정적 사이트 생성), SPA 모드를 하나의 프레임워크에서 지원하며, 파일 기반 라우팅과 자동 임포트로 개발 생산성을 극대화합니다.

렌더링 모드설명적합한 사용 사례
SSR요청마다 서버에서 HTML 생성동적 콘텐츠, 개인화 페이지
SSG빌드 시 모든 페이지 사전 생성블로그, 문서 사이트, 마케팅 페이지
SPA클라이언트에서만 렌더링관리자 대시보드, 인증 후 앱
ISR빌드 후 필요 시 재생성자주 업데이트되는 정적 콘텐츠

Nuxt 3 핵심 기능

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에서 <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 가이드