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.vueapp/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
└── users/
├── page.tsx → /users
└── [id]/
└── page.tsx → /users/:idpages/
├── 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>// 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>
}<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><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>// 일반 .ts 파일
import { ref } from 'vue'// 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} />
}<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><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><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><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) |
// app/layout.tsx → 모든 페이지에 적용
export default function RootLayout({ children }) {
return (
<html>
<body>
<Header />
{children}
<Footer />
</body>
</html>
)
}<!-- layouts/default.vue → 기본 레이아웃 -->
<template>
<div>
<AppHeader />
<slot /> <!-- 페이지 내용이 여기에 렌더링 -->
<AppFooter />
</div>
</template><!-- app.vue -->
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template><!-- pages/admin/dashboard.vue -->
<script setup>
definePageMeta({
layout: 'admin', // layouts/admin.vue 사용
})
</script>// 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*'],
}// 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 App Router | Nuxt3 | |
실행 환경 | Edge Runtime (서버만) | 서버 최초 요청 + 클라이언트 네비게이션 |
파일 위치 | 루트 middleware.ts 1개 | middleware/ 폴더, 파일별 분리 |
적용 범위 | matcher 설정 | definePageMeta 또는 .global.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 })
}// 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)
})// ref()는 서버에서 생성된 값이 클라이언트로 전달되지 않음
// useState는 서버 → 클라이언트 hydration을 지원
const count = useState('global-count', () => 0)
// 두 번째 인자는 초기값 팩토리 (최초 1회만 실행)// composables/useCounter.ts
export const useCounter = () => useState('counter', () => 0)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>// 정적
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 }
}<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: '기본 설명' }],
},
},
})NEXT_PUBLIC_API_URL=... → 클라이언트 노출 (NEXT_PUBLIC_ 접두사)
API_SECRET=... → 서버에서만 접근 가능// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apiSecret: '', // 서버에서만 접근 가능
public: {
apiBase: '', // 클라이언트에도 노출
},
},
})# .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>// app/error.tsx
'use client'
export default function Error({ error, reset }) {
return (
<div>
<h2>{error.message}</h2>
<button onClick={reset}>재시도</button>
</div>
)
}<!-- 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 })// 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' }]
}// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true }, // SSG
'/blog/**': { isr: 3600 }, // ISR (3600초마다 재생성)
'/admin/**': { ssr: false }, // CSR (서버 렌더링 안 함)
'/api/**': { cors: true },
},
})npx nuxi generate<script setup>
// SSR에서 기다리지 않고 클라이언트에서 페칭 (lazy loading)
const { data, pending } = await useFetch('/api/users', { lazy: true })
// 완전히 클라이언트에서만 실행
const { data } = await useFetch('/api/users', { server: false })
</script>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,
},
})<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><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 → 서버에서만 실행pages/
└── users/
├── index.vue → /users
└── [id].vue → /users/:id<!-- 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