내가 읽으려고 만든 Nuxt 입문하기

현우
  1. SSR
  2. 프론트엔드
Vue3 + Next.js App Router 경험자 기준으로 작성.

1. 핵심 구조 차이

Next.js App Router      Nuxt3
─────────────────────────────────────────────
app/                    pages/
app/layout.tsx          layouts/
app/page.tsx            pages/index.vue
app/api/route.ts        server/api/
middleware.ts           middleware/
'use client'            (없음 — 대신 <ClientOnly>)
Server Component        (없음 — 모든 컴포넌트는 Universal)
export const metadata   useSeoMeta() / useHead()
loading.tsx             <NuxtLoadingIndicator>
error.tsx               error.vue

2. 파일 기반 라우팅

개념은 같지만 파일명과 폴더 구조 방식이 다르다.

Next.js App Router

폴더가 라우트 세그먼트이고, 폴더 안의 page.tsx가 실제 페이지.
app/
├── page.tsx              →  /
├── about/
│   └── page.tsx          →  /about
└── users/
    ├── page.tsx          →  /users
    └── [id]/
        └── page.tsx      →  /users/:id

Nuxt3

.vue 파일 자체가 라우트. 폴더 구조도 동일하게 지원.
pages/
├── index.vue             →  /
├── about.vue             →  /about
└── users/
    ├── index.vue         →  /users
    └── [id].vue          →  /users/:id

동적 파라미터 접근

<!-- pages/users/[id].vue -->
<script setup>
const route = useRoute()
const id = route.params.id  // /users/123 → "123"
</script>

라우터 이동

<script setup>
const router = useRouter()
router.push('/about')
router.push({ name: 'users-id', params: { id: 1 } })
// 라우트 name은 파일 경로를 '-'로 연결 → users/[id].vue = 'users-id'
</script>

<template>
  <!-- Next.js의 <Link> = Nuxt3의 <NuxtLink> -->
  <NuxtLink to="/about">소개</NuxtLink>
  <NuxtLink :to="{ name: 'users-id', params: { id: 1 } }">유저 1</NuxtLink>
</template>

3. 컴포넌트 모델 — 가장 큰 차이점

이 부분이 Next.js App Router와 Nuxt3의 가장 근본적인 차이다.

Next.js App Router: Server Component vs Client Component

// Server Component (기본값) — 서버에서만 실행, JS 번들에 포함 안 됨
// useState, useEffect 등 React 훅 사용 불가
async function UserList() {
  const users = await db.getUsers()  // 직접 DB 접근 가능
  return <ul>{users.map(u => <li>{u.name}</li>)}</ul>
}

// Client Component — 'use client' 선언 필요
'use client'
function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

Nuxt3: Universal Component (단일 모델)

Nuxt3에는 Server Component / Client Component 구분이 없다.
모든 컴포넌트는 SSR 시 서버에서 실행되고, hydration 후 클라이언트에서도 실행된다.
<script setup> 하나로 모든 것을 처리한다.
<script setup>
// 이 코드는 SSR 시 서버에서 실행되고, 클라이언트에서도 실행됨
// → 브라우저 전용 API (window, localStorage 등)는 직접 쓰면 SSR에서 에러 남
const { data } = await useFetch('/api/users')  // SSR에서 실행
</script>

<template>
  <ul>
    <li v-for="user in data" :key="user.id">{{ user.name }}</li>
  </ul>
</template>

브라우저 전용 코드 처리

<script setup>
// ❌ SSR에서 에러 발생
const saved = localStorage.getItem('key')

// ✅ 클라이언트에서만 실행
onMounted(() => {
  const saved = localStorage.getItem('key')
})

// ✅ 또는 환경 분기
if (import.meta.client) {
  const saved = localStorage.getItem('key')
}
</script>

<template>
  <!-- ✅ 컴포넌트 단위로 클라이언트 전용 렌더링 -->
  <ClientOnly>
    <MyBrowserOnlyComponent />
  </ClientOnly>
</template>

4. Auto-imports

Nuxt3의 핵심 기능. import 없이 사용 가능한 것들:
components/ 폴더의 모든 컴포넌트
composables/ 폴더의 모든 composable
Vue3 Composition API (ref, computed, watch 등)
Nuxt3 내장 composable (useRoute, useRouter, useFetch 등)
<script setup>
// import 없이 사용 가능
const count = ref(0)
const route = useRoute()
const { data } = await useFetch('/api/users')
</script>

<template>
  <!-- components/MyButton.vue → <MyButton> 자동 등록 -->
  <MyButton @click="count++" />
</template>
단, composables/components/ 외부 파일에서는 직접 import 필요:
// 일반 .ts 파일
import { ref } from 'vue'

5. 데이터 페칭

Next.js App Router: Server Component에서 직접 fetch

// Server Component — async/await로 직접 데이터 페칭
export default async function Page() {
  // SSR (cache: 'no-store')
  const data = await fetch('https://api.example.com/users', {
    cache: 'no-store',
  }).then(r => r.json())

  return <UserList users={data} />
}

Nuxt3: useFetch

SSR/클라이언트 환경에 관계없이 동일한 API를 사용한다.
<script setup>
// 기본 사용 — SSR에서 실행됨
const { data, pending, error, refresh } = await useFetch('/api/users')

// 타입 지정
const { data } = await useFetch<User[]>('/api/users')

// POST 요청
const { data } = await useFetch('/api/users', {
  method: 'POST',
  body: { name: '김현우' },
})

// 동적 URL — ref가 바뀌면 자동으로 재요청
const userId = ref(1)
const { data } = await useFetch(() => `/api/users/${userId.value}`)
</script>

useFetch 주요 옵션

<script setup>
const { data } = await useFetch('/api/users', {
  // lazy: true → SSR에서 이 요청을 기다리지 않고 페이지를 먼저 반환
  //               클라이언트에서 완료됨. pending 상태로 시작.
  lazy: true,

  // server: false → 클라이언트에서만 실행 (SSR 완전히 스킵)
  server: false,

  // pick → 응답에서 필요한 필드만 추출 (payload 최적화)
  pick: ['id', 'name'],
})
</script>

useAsyncData — 외부 SDK나 복잡한 로직

<script setup>
// useFetch는 내부적으로 useAsyncData + $fetch의 조합
// 외부 SDK, 복잡한 변환 로직이 필요할 때 useAsyncData 직접 사용
const { data } = await useAsyncData('posts', async () => {
  const posts = await someExternalSDK.getPosts()
  return posts.filter(p => p.published)
})
</script>

$fetch — 이벤트 핸들러에서 사용

<script setup>
// useFetch/useAsyncData는 setup의 SSR 타이밍에 실행되도록 설계됨
// 버튼 클릭 등 이벤트 핸들러에서는 $fetch 사용
async function submit() {
  const result = await $fetch('/api/submit', {
    method: 'POST',
    body: formData.value,
  })
}
</script>

선택 기준 정리

상황
선택
페이지 로드 시 URL로 API 요청
useFetch
외부 SDK / DB 직접 호출
useAsyncData
클릭/폼 제출 등 이벤트
$fetch
URL이 ref에 의존해 동적으로 변함
useFetch(() => url)

6. 레이아웃

Next.js App Router: 폴더 내 layout.tsx (중첩 레이아웃 자동 적용)

// app/layout.tsx → 모든 페이지에 적용
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Header />
        {children}
        <Footer />
      </body>
    </html>
  )
}

Nuxt3: layouts/ 폴더 + 명시적 적용

<!-- layouts/default.vue → 기본 레이아웃 -->
<template>
  <div>
    <AppHeader />
    <slot />  <!-- 페이지 내용이 여기에 렌더링 -->
    <AppFooter />
  </div>
</template>
app.vue에서 <NuxtLayout>으로 활성화:
<!-- app.vue -->
<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

레이아웃 변경

<!-- pages/admin/dashboard.vue -->
<script setup>
definePageMeta({
  layout: 'admin',  // layouts/admin.vue 사용
})
</script>
Next.js App Router는 폴더 계층으로 레이아웃이 자동 적용되고,
Nuxt3는 definePageMeta로 명시적으로 지정한다는 차이가 있다.

7. 미들웨어

Next.js App Router: 루트의 middleware.ts (Edge Runtime)

// middleware.ts — 모든 요청에 실행 (Edge Runtime)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

export const config = {
  matcher: ['/dashboard/:path*'],
}

Nuxt3: middleware/ 폴더 (라우트 미들웨어)

클라이언트 네비게이션 + 서버 최초 요청 양쪽에서 실행된다.
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const token = useCookie('auth-token')

  if (!token.value) {
    return navigateTo('/login')
  }
})
페이지에서 적용:
<script setup>
definePageMeta({
  middleware: 'auth',
  // middleware: ['auth', 'role'],  // 복수 지정 가능
})
</script>

글로벌 미들웨어 (모든 라우트에 자동 적용)

// middleware/logger.global.ts
// 파일명에 '.global' 붙이면 전체 라우트에 자동 적용
export default defineNuxtRouteMiddleware((to) => {
  console.log('이동:', to.path)
})

Next.js vs Nuxt3 미들웨어 차이

Next.js App Router
Nuxt3
실행 환경
Edge Runtime (서버만)
서버 최초 요청 + 클라이언트 네비게이션
파일 위치
루트 middleware.ts 1개
middleware/ 폴더, 파일별 분리
적용 범위
matcher 설정
definePageMeta 또는 .global.ts

8. 서버 라우트

Next.js App Router: app/api/route.ts

// app/api/users/route.ts
export async function GET() {
  const users = await db.getUsers()
  return Response.json(users)
}

export async function POST(request: Request) {
  const body = await request.json()
  const user = await db.createUser(body)
  return Response.json(user, { status: 201 })
}

Nuxt3: server/api/ (Nitro)

// server/api/users.get.ts  → GET /api/users
export default defineEventHandler(async (event) => {
  const users = await db.getUsers()
  return users  // 자동으로 JSON 직렬화
})

// server/api/users.post.ts  → POST /api/users
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const user = await db.createUser(body)
  return user
})

// server/api/users/[id].get.ts  → GET /api/users/:id
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  return await db.getUserById(id)
})

파일명 컨벤션

server/api/users.ts          →  /api/users (모든 메서드)
server/api/users.get.ts      →  GET /api/users
server/api/users.post.ts     →  POST /api/users
server/api/users/[id].get.ts →  GET /api/users/:id
server/routes/sitemap.xml.ts →  /sitemap.xml  (/api 접두사 없음)

유틸 함수

export default defineEventHandler(async (event) => {
  // 쿼리 파라미터: GET /api/search?q=nuxt
  const query = getQuery(event)   // { q: 'nuxt' }

  // 요청 바디 (POST)
  const body = await readBody(event)

  // 헤더
  const authHeader = getHeader(event, 'authorization')

  // 쿠키
  const token = getCookie(event, 'auth-token')

  // 상태 코드 설정
  setResponseStatus(event, 201)
})

9. 상태 관리

useState — SSR-safe 전역 상태

// ref()는 서버에서 생성된 값이 클라이언트로 전달되지 않음
// useState는 서버 → 클라이언트 hydration을 지원
const count = useState('global-count', () => 0)
// 두 번째 인자는 초기값 팩토리 (최초 1회만 실행)
같은 키를 사용하면 어디서 호출해도 같은 상태를 공유한다:
// composables/useCounter.ts
export const useCounter = () => useState('counter', () => 0)

Pinia — 복잡한 상태

npx nuxi module add pinia
// stores/user.ts
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/auth/login', {
      method: 'POST',
      body: credentials,
    })
  }

  return { user, isLoggedIn, login }
})
<script setup>
// stores/는 Auto-import 대상이 아님 → 직접 import 필요
import { useUserStore } from '~/stores/user'
const userStore = useUserStore()
</script>
@pinia/nuxt 모듈이 SSR hydration을 자동으로 처리하므로 별도 설정 불필요.

10. SEO & Meta

Next.js App Router: export const metadata

// 정적
export const metadata: Metadata = {
  title: '페이지 제목',
  description: '페이지 설명',
  openGraph: {
    title: 'OG 제목',
    images: ['/og.png'],
  },
}

// 동적
export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await fetchPost(params.id)
  return { title: post.title }
}

Nuxt3: useHead / useSeoMeta

<script setup>
// 일반 메타
useHead({
  title: '페이지 제목',
  meta: [{ name: 'description', content: '설명' }],
})

// OG/SEO 전용 (타입 안전)
useSeoMeta({
  title: '페이지 제목',
  description: '설명',
  ogTitle: 'OG 제목',
  ogDescription: 'OG 설명',
  ogImage: 'https://example.com/og.png',
  twitterCard: 'summary_large_image',
})

// 동적 (ref 사용 가능)
const { data: post } = await useFetch(`/api/posts/${route.params.id}`)
useSeoMeta({
  title: () => post.value?.title,  // 데이터 로드 후 자동 업데이트
})
</script>
전역 기본값:
// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      titleTemplate: '%s | 사이트명',
      meta: [{ name: 'description', content: '기본 설명' }],
    },
  },
})

11. 환경변수

Next.js App Router

NEXT_PUBLIC_API_URL=...  → 클라이언트 노출 (NEXT_PUBLIC_ 접두사)
API_SECRET=...           → 서버에서만 접근 가능

Nuxt3: runtimeConfig

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    apiSecret: '',          // 서버에서만 접근 가능
    public: {
      apiBase: '',          // 클라이언트에도 노출
    },
  },
})
.env 파일의 NUXT_ 접두사 변수가 자동으로 매핑됨:
# .env
NUXT_API_SECRET=my-secret               → runtimeConfig.apiSecret
NUXT_PUBLIC_API_BASE=https://api.com    → runtimeConfig.public.apiBase
<script setup>
const config = useRuntimeConfig()
// 클라이언트: config.public.apiBase 접근 가능
// 서버: config.apiSecret도 접근 가능
</script>

12. 에러 처리

Next.js App Router: error.tsx (Client Boundary)

// app/error.tsx
'use client'
export default function Error({ error, reset }) {
  return (
    <div>
      <h2>{error.message}</h2>
      <button onClick={reset}>재시도</button>
    </div>
  )
}

Nuxt3: error.vue

<!-- error.vue (app.vue와 같은 레벨) -->
<script setup>
const props = defineProps<{ error: { statusCode: number; message: string } }>()

function handleError() {
  clearError({ redirect: '/' })
}
</script>

<template>
  <div>
    <h1>{{ error.statusCode }}</h1>
    <p>{{ error.message }}</p>
    <button @click="handleError">홈으로</button>
  </div>
</template>
에러 발생시키기:
// 페이지/컴포넌트에서
throw createError({ statusCode: 404, message: 'Not found' })

// fatal: true → error.vue로 이동 (fatal: false는 컴포넌트 내에서만 처리)
throw createError({ statusCode: 500, message: 'Server error', fatal: true })

13. 렌더링 전략

Next.js App Router

// SSR (매 요청마다)
const data = await fetch(url, { cache: 'no-store' })

// SSG (빌드 시 생성)
const data = await fetch(url)  // 기본값이 캐시됨 (Next.js 14 이하)
// Next.js 15부터는 기본값이 no-store로 변경됨

// ISR
const data = await fetch(url, { next: { revalidate: 60 } })

// 정적 경로 생성
export async function generateStaticParams() {
  return [{ id: '1' }, { id: '2' }]
}

Nuxt3: routeRules + nuxt generate

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/':           { prerender: true },    // SSG
    '/blog/**':    { isr: 3600 },          // ISR (3600초마다 재생성)
    '/admin/**':   { ssr: false },         // CSR (서버 렌더링 안 함)
    '/api/**':     { cors: true },
  },
})
전체 SSG로 빌드:
npx nuxi generate
useFetch의 SSR 제어:
<script setup>
// SSR에서 기다리지 않고 클라이언트에서 페칭 (lazy loading)
const { data, pending } = await useFetch('/api/users', { lazy: true })

// 완전히 클라이언트에서만 실행
const { data } = await useFetch('/api/users', { server: false })
</script>

14. nuxt.config.ts 핵심 설정

export default defineNuxtConfig({
  // SSR 여부 (기본값 true)
  ssr: true,

  // 개발자 도구
  devtools: { enabled: true },

  // 모듈 등록
  modules: [
    '@pinia/nuxt',
    '@nuxt/image',
    '@nuxtjs/tailwindcss',
    '@vueuse/nuxt',
  ],

  // 런타임 환경변수
  runtimeConfig: {
    apiSecret: '',
    public: {
      apiBase: '',
    },
  },

  // 전역 CSS
  css: ['~/assets/css/main.css'],

  // 라우트별 렌더링 전략
  routeRules: {
    '/': { prerender: true },
    '/blog/**': { isr: 3600 },
    '/admin/**': { ssr: false },
  },

  // Vite 설정 확장
  vite: {
    css: {
      preprocessorOptions: {
        scss: {
          additionalData: '@use "~/assets/scss/variables" as *;',
        },
      },
    },
  },

  // TypeScript strict 모드
  typescript: {
    strict: true,
  },
})

15. 자주 쓰는 패턴 모음

definePageMeta

<script setup>
definePageMeta({
  layout: 'admin',
  middleware: ['auth'],
})
</script>

쿠키 (서버-클라이언트 모두 동작)

<script setup>
const token = useCookie('auth-token', {
  maxAge: 60 * 60 * 24 * 7,  // 7일
  httpOnly: true,
  secure: true,
})

token.value = 'my-token'  // 자동으로 쿠키에 저장됨
token.value = null        // 쿠키 삭제
</script>

NuxtLink 활성 클래스

<template>
  <NuxtLink
    to="/about"
    activeClass="text-blue-500"
    exactActiveClass="font-bold"
  >
    소개
  </NuxtLink>
</template>

플러그인 — 전역 함수/라이브러리 등록

// plugins/my-plugin.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.use(SomeVuePlugin)

  return {
    provide: {
      formatDate: (date: Date) => date.toLocaleDateString('ko-KR'),
    },
  }
})
<script setup>
const { $formatDate } = useNuxtApp()
</script>
클라이언트/서버 전용 플러그인:
plugins/analytics.client.ts  → 클라이언트에서만 실행
plugins/db.server.ts          → 서버에서만 실행

중첩 라우트 (Nested Routes)

pages/
└── users/
    ├── index.vue       →  /users
    └── [id].vue        →  /users/:id
부모 페이지 안에 <NuxtPage />를 넣으면 중첩 렌더링됨:
<!-- pages/users.vue -->
<template>
  <div>
    <UserSidebar />
    <NuxtPage />  <!-- /users/:id 내용이 여기에 렌더링 -->
  </div>
</template>

빠른 참조

composable                  역할
────────────────────────────────────────────────
useFetch(url)               SSR 데이터 페칭
useAsyncData(key, fn)       커스텀 로직 SSR 페칭
$fetch(url)                 이벤트 핸들러에서 페칭
useRoute()                  현재 라우트 정보
useRouter()                 라우터 이동
useCookie(name)             쿠키 읽기/쓰기
useRuntimeConfig()          환경변수
useState(key, init)         SSR-safe 전역 상태
useHead({})                 페이지 헤드 설정
useSeoMeta({})              SEO / OG 태그
useNuxtApp()                플러그인 ($xxx 접근)
navigateTo(path)            미들웨어에서 리다이렉트
createError({})             에러 생성/throw
悠悠自適
유유자적한 블로그 주인장 김현우입니다.
오늘도 안녕하신가요?
Subscribe
👍