Nuxt 4: Vue로 풀스택 웹 앱 만들기
Vue 3 글에서 할 일 목록 앱을 만들어 봤는데요.
그 앱은 데이터를 브라우저의 localStorage에 저장했기 때문에, 다른 기기에서 열면 목록이 텅 비어 있습니다.
데이터를 서버에 두려면 API 서버를 따로 만들고, 페이지가 늘어나면 라우터를 붙이고, 검색 엔진에 노출하려면 서버 렌더링까지 고민해야 하죠. 😅
Nuxt는 이 모든 걸 하나의 프로젝트에서 해결해 주는 Vue 기반의 풀스택 프레임워크입니다. React에 Next.js가 있다면 Vue에는 Nuxt가 있는 셈인데요. 2025년 7월 15일에는 디렉터리 구조와 데이터 가져오기 방식을 다듬은 Nuxt 4가 정식으로 출시됐습니다. 이번 글에서는 Nuxt 4로 할 일 목록 앱을 서버 API까지 갖춘 풀스택 앱으로 다시 만들어 보겠습니다.
Nuxt가 해 주는 일
Nuxt 같은 메타 프레임워크(meta framework)는 UI 프레임워크 위에서 라우팅, 데이터 가져오기, 서버 렌더링, 빌드 설정 같은 결정을 대신 내려 주는 프레임워크입니다. Nuxt는 Vue를 UI 계층으로 쓰고, 그 위에 다음과 같은 것들을 얹어 줍니다.
우선 pages 디렉터리에 파일을 만들면 그 경로가 그대로 URL이 되는 파일 기반 라우팅(file-based routing)이 있습니다.
또한 기본으로 서버 사이드 렌더링(server-side rendering, 이하 SSR)을 해서, 브라우저가 처음 받는 HTML에 이미 내용이 채워져 있습니다.
그리고 Nitro라는 서버 엔진이 들어 있어서, server 디렉터리에 파일 하나만 추가하면 API 엔드포인트가 생깁니다.
마지막으로 컴포넌트와 컴포저블(composable)을 자동으로 가져오기(auto-import) 때문에 import 문을 거의 쓸 일이 없어요.
렌더링 방식은 페이지마다 다르게 고를 수도 있습니다. SPA, SSG, SSR의 차이가 헷갈린다면 SPA, SSG, SSR 비교를 먼저 읽어 보셔도 좋습니다.
프로젝트 만들기
Nuxt 프로젝트는 공식 스캐폴딩 도구인 create-nuxt로 만듭니다. Bun을 쓴다면 다음과 같이 최소 템플릿으로 프로젝트를 만듭니다.
bun create nuxt@latest nuxt-app --template minimal --packageManager bun --gitInit=false
┌ Welcome to Nuxt!
◇ Creating project in nuxt-app
● Downloading minimal template...
◆ Downloaded minimal template
옵션 없이 실행하면 템플릿과 패키지 매니저, 추가할 공식 모듈을 대화형으로 물어봅니다. npm을 쓴다면 다음 명령어로 같은 프로젝트를 만듭니다.
npm create nuxt@latest nuxt-app
의존성을 설치하고 개발 서버를 띄워 보겠습니다.
cd nuxt-app
bun install
bun run dev
● Nuxt 4.6.2 (with Nitro 2.13.4, Vite 8.3.4 and Vue 3.5.44)
➜ Local: http://localhost:3000/
➜ Network: use --host to expose
✔ Vite client built in 28ms
✔ Vite server built in 5ms
[nitro] ✔ Nuxt Nitro server built in 373ms
◆ Ready in 1.58s
출력 첫 줄을 보면 Nuxt가 무엇으로 이루어져 있는지 드러납니다. 서버는 Nitro가, 번들링은 Vite가, 화면은 Vue가 맡고 있죠.
최소 템플릿의 package.json에는 nuxt, vue, vue-router 세 패키지만 들어 있습니다.
설정 파일인 nuxt.config.ts도 다음처럼 짧습니다.
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
});
compatibilityDate는 Nuxt와 Nitro가 배포 환경별 동작을 이 날짜 기준으로 고정하는 값입니다.
나중에 Nuxt를 업그레이드해도 이 날짜를 올리기 전까지는 기존 동작이 유지되기 때문에, 예상치 못한 변화를 막아 줍니다.
app 디렉터리
Nuxt 4에서 가장 눈에 띄는 변화는 디렉터리 구조입니다.
Nuxt 3에서는 pages, components, layouts가 프로젝트 루트에 바로 놓였는데, Nuxt 4부터는 Vue 앱 코드가 모두 app 디렉터리 아래로 들어갑니다.
이번 예제를 다 만들면 프로젝트는 다음과 같은 모습이 됩니다.
nuxt-app/
├── app/
│ ├── app.vue
│ ├── layouts/
│ │ └── default.vue
│ └── pages/
│ ├── about.vue
│ ├── index.vue
│ └── todos/
│ └── [id].vue
├── server/
│ └── api/
│ └── todos/
│ ├── index.get.ts
│ ├── index.post.ts
│ └── [id].get.ts
├── shared/
│ └── types/
│ └── todo.ts
├── public/
└── nuxt.config.ts
이렇게 나눈 이유는 브라우저에서 도는 코드와 서버에서 도는 코드를 분명히 구분하기 위해서입니다.
app은 Vue 앱, server는 Nitro 서버, shared는 양쪽에서 함께 쓰는 코드가 들어가는 자리예요.
개발 서버가 감시해야 할 파일도 줄어서 큰 프로젝트에서 변경 사항이 더 빨리 반영됩니다.
shared 디렉터리에는 Vue나 Nitro 코드를 가져올 수 없습니다.
순수한 타입이나 유틸리티 함수만 두는 곳이라, 할 일의 타입을 여기에 정의하겠습니다.
export interface Todo {
id: number;
text: string;
done: boolean;
}
shared/types와 shared/utils에 둔 코드는 자동으로 가져오기 때문에 앱과 서버 어디서든 import 없이 Todo 타입을 쓸 수 있습니다.
서버 API 만들기
server/api 디렉터리에 파일을 만들면 /api로 시작하는 엔드포인트가 생깁니다.
파일 이름 끝에 .get이나 .post를 붙이면 해당 HTTP 메서드로 들어온 요청만 처리해요.
먼저 할 일 목록을 돌려주는 GET /api/todos입니다.
export default defineEventHandler(async () => {
return (await useStorage("data").getItem<Todo[]>("todos")) ?? [];
});
defineEventHandler()는 요청 하나를 처리하는 핸들러를 정의하고, 돌려준 값은 JSON으로 응답됩니다.
useStorage()는 Nitro에 내장된 키-값 저장소인데요.
data 저장소는 Node.js 서버에서 프로젝트의 .data/kv 디렉터리에 파일로 저장되므로, 서버를 껐다 켜도 데이터가 남아 있습니다.
다음은 할 일을 추가하는 POST /api/todos입니다.
export default defineEventHandler(async (event) => {
const { text } = await readBody<{ text?: string }>(event);
if (!text?.trim()) {
throw createError({ statusCode: 400, statusMessage: "할 일을 입력하세요." });
}
const storage = useStorage("data");
const todos = (await storage.getItem<Todo[]>("todos")) ?? [];
const todo: Todo = { id: Date.now(), text: text.trim(), done: false };
await storage.setItem("todos", [...todos, todo]);
return todo;
});
readBody()로 요청 본문을 읽고, 내용이 비어 있으면 createError()로 400 응답을 보냅니다.
여기서 throw를 쓰는 이유는 핸들러 어디에서든 에러를 던지기만 하면 Nitro가 알맞은 상태 코드의 JSON 응답으로 바꿔 주기 때문이에요.
마지막으로 할 일 하나를 돌려주는 GET /api/todos/:id입니다.
파일 이름의 대괄호는 경로 매개변수(route parameter)를 뜻합니다.
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, "id"));
const todos = (await useStorage("data").getItem<Todo[]>("todos")) ?? [];
const todo = todos.find((todo) => todo.id === id);
if (!todo) {
throw createError({ statusCode: 404, statusMessage: "할 일을 찾을 수 없습니다." });
}
return todo;
});
화면을 만들기 전에 curl로 API부터 확인해 보겠습니다.
curl -X POST localhost:3000/api/todos -H 'content-type: application/json' -d '{"text":"Nuxt 4 문서 읽기"}'
curl -X POST localhost:3000/api/todos -H 'content-type: application/json' -d '{"text":"서버 라우트 만들기"}'
curl localhost:3000/api/todos
[
{
"id": 1792254462795,
"text": "Nuxt 4 문서 읽기",
"done": false
},
{
"id": 1792254462804,
"text": "서버 라우트 만들기",
"done": false
}
]
별도의 API 서버를 띄우지 않았는데도 같은 개발 서버에서 API가 동작합니다.
text를 빈 문자열로 보내면 "statusCode": 400과 함께 "할 일을 입력하세요."라는 메시지가 돌아옵니다.
파일 기반 라우팅과 레이아웃
이제 화면을 만들 차례입니다.
템플릿에 들어 있던 app/app.vue를 다음과 같이 바꿉니다.
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<style>
body {
font-family: sans-serif;
margin: 2rem;
}
</style>
<NuxtPage>는 현재 URL에 맞는 페이지 컴포넌트를 그리는 자리이고, <NuxtLayout>은 그 페이지를 레이아웃으로 감쌉니다.
Vue Router를 직접 설정하지 않아도 pages 디렉터리의 파일 구조가 그대로 라우팅 테이블이 돼요.
pages/index.vue는 /, pages/about.vue는 /about, pages/todos/[id].vue는 /todos/123 같은 경로를 맡습니다.
모든 페이지에 공통으로 들어갈 머리글은 layouts/default.vue에 둡니다.
이름이 default인 레이아웃은 따로 지정하지 않은 모든 페이지에 적용됩니다.
<template>
<header>
<NuxtLink to="/">할 일</NuxtLink>
<NuxtLink to="/about">소개</NuxtLink>
</header>
<main>
<slot />
</main>
</template>
<style scoped>
header {
display: flex;
gap: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #ddd;
}
.router-link-active {
font-weight: bold;
}
</style>
페이지 사이를 오갈 때는 <a> 대신 <NuxtLink>를 씁니다.
<NuxtLink>는 페이지를 새로 받지 않고 클라이언트에서 화면만 바꾸고, 링크가 화면에 보이면 다음 페이지의 코드를 미리 가져와 둡니다.
현재 경로와 일치하는 링크에는 router-link-active 클래스가 붙는데, 예제에서는 이 클래스로 현재 메뉴를 굵게 표시했어요.
useFetch로 데이터 가져오기
목록 페이지에서는 useFetch()로 서버 API를 호출합니다.
<script setup lang="ts">
const { data: todos, refresh } = await useFetch<Todo[]>("/api/todos");
const newTodo = ref("");
async function addTodo() {
await $fetch("/api/todos", { method: "POST", body: { text: newTodo.value } });
newTodo.value = "";
await refresh();
}
</script>
<template>
<h1>할 일 목록</h1>
<form @submit.prevent="addTodo">
<input v-model="newTodo" placeholder="할 일을 입력하세요" />
<button>추가</button>
</form>
<ul>
<li v-for="todo in todos" :key="todo.id">
<NuxtLink :to="`/todos/${todo.id}`">{{ todo.text }}</NuxtLink>
</li>
</ul>
</template>
ref, useFetch, $fetch 모두 import 없이 쓰고 있다는 점을 눈여겨보세요.
Nuxt가 Vue의 API와 자체 컴포저블을 자동으로 가져오기 때문입니다.
useFetch()와 $fetch는 비슷해 보이지만 쓰임새가 다릅니다.
$fetch는 fetch API를 감싼 단순한 함수라서 버튼 클릭처럼 사용자 동작에 반응해 요청을 보낼 때 씁니다.
반면 useFetch()는 페이지를 그리는 데 필요한 데이터를 가져올 때 쓰는데요.
SSR 중에 서버에서 한 번 데이터를 가져오고, 그 결과를 HTML과 함께 브라우저로 넘겨줍니다.
그래서 브라우저가 화면을 넘겨받아 하이드레이션(hydration)할 때 같은 요청을 다시 보내지 않아요.
페이지 컴포넌트에서 $fetch로 초기 데이터를 가져오면 서버와 브라우저에서 요청이 두 번 나가니 주의해야 합니다.
useFetch()가 돌려주는 data는 Nuxt 4부터 얕은 반응성을 가진 shallowRef입니다.
배열 자체를 새로 받으면 화면이 바뀌지만, todos.value[0].done = true처럼 안쪽을 직접 고치면 화면이 바뀌지 않습니다.
큰 응답 데이터를 깊게 감시하는 비용을 줄이려는 변화인데요.
서버에서 받은 데이터를 고치는 대신 예제처럼 refresh()로 다시 받는 방식이 이 기본값과 잘 맞습니다.
안쪽까지 반응해야 한다면 useFetch()에 { deep: true } 옵션을 넘기면 됩니다.
개발 서버를 열어 할 일을 하나 더 추가해 보면 다음과 같이 보입니다.

브라우저의 페이지 소스 보기로 HTML을 열어 보면 <li> 요소가 이미 들어 있습니다.
자바스크립트가 실행되기 전에도 내용이 보이고, 검색 엔진도 이 내용을 그대로 읽어 갑니다.
동적 라우트
pages/todos/[id].vue처럼 파일 이름에 대괄호를 쓰면 동적 라우트가 됩니다.
URL의 해당 부분은 useRoute()의 params로 읽어요.
<script setup lang="ts">
const route = useRoute();
const { data: todo, error } = await useFetch<Todo>(
`/api/todos/${route.params.id}`,
);
useHead({ title: () => todo.value?.text ?? "할 일" });
</script>
<template>
<p v-if="error">{{ error.statusMessage }}</p>
<article v-else-if="todo">
<h1>{{ todo.text }}</h1>
<p>상태: {{ todo.done ? "완료" : "진행 중" }}</p>
</article>
<NuxtLink to="/">목록으로</NuxtLink>
</template>
서버 API에서 던진 404 에러는 useFetch()의 error로 들어오기 때문에, 템플릿에서 메시지를 그대로 보여 주면 됩니다.
useHead()는 페이지의 <title>과 메타 태그를 설정합니다.
SSR 중에 실행되기 때문에 HTML에 처음부터 할 일의 제목이 들어가는데요.
검색 엔진 최적화를 위한 메타 태그를 페이지마다 다르게 넣어야 할 때 이 컴포저블을 씁니다.
목록에서 할 일을 누르면 상세 페이지로 이동하고, 브라우저 탭의 제목도 할 일의 내용으로 바뀝니다.

routeRules로 페이지별 렌더링 정하기
모든 페이지를 요청마다 서버에서 그릴 필요는 없습니다.
소개 페이지처럼 내용이 바뀌지 않는 페이지는 빌드할 때 HTML을 미리 만들어 두는 편이 빠르고 서버 부담도 적은데요.
nuxt.config.ts의 routeRules로 경로마다 렌더링 방식을 정할 수 있습니다.
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
routeRules: {
"/about": { prerender: true },
},
});
<template>
<h1>소개</h1>
<p>Nuxt 4로 만든 할 일 목록 앱입니다.</p>
</template>
prerender: true 외에도 ssr: false로 특정 경로만 SPA로 돌리거나, swr로 서버에서 그린 결과를 일정 시간 캐시할 수 있습니다.
한 앱 안에서 렌더링 방식을 섞어 쓰는 하이브리드 렌더링(hybrid rendering)이라고 부르는 기능이에요.
빌드와 배포
프로덕션용으로 빌드해 보겠습니다.
bun run build
● Nuxt 4.6.2 (with Nitro 2.13.4, Vite 8.3.4 and Vue 3.5.44)
● Nitro preset: node-server
✓ 183 modules transformed.
[nitro] ℹ Initializing prerenderer
[nitro] ℹ Prerendering 1 routes
[nitro] ├─ /about (23ms)
[nitro] ├─ /about/_payload.json (0ms)
[nitro] ℹ Prerendered 2 routes in 0.424 seconds
[nitro] ✔ Generated public .output/public
[nitro] ℹ Building Nuxt Nitro server (preset: node-server, compatibility date: 2025-07-07)
Σ Total size: 2.17 MB (555 kB gzip)
[nitro] ✔ You can preview this build using node .output/server/index.mjs
◆ ✨ Build complete in 3.17s!
routeRules에 지정한 /about만 빌드 중에 HTML로 미리 만들어졌습니다.
빌드 결과는 .output 디렉터리에 모이는데, 이 디렉터리에는 실행에 필요한 의존성이 모두 들어 있어서 node_modules 없이 바로 실행할 수 있어요.
Bun으로 실행하려면 다음과 같이 합니다.
bun .output/server/index.mjs
Listening on http://[::]:3000
Nitro의 강점은 프리셋(preset)만 바꾸면 배포 환경에 맞는 결과물을 만들어 준다는 점입니다.
위 출력의 node-server는 Node.js 호환 서버용 기본 프리셋인데요.
NITRO_PRESET=cloudflare_module처럼 환경 변수를 주고 빌드하면 Cloudflare Workers에서 도는 결과물이 나옵니다.
다만 서버리스 환경에서는 .data 디렉터리처럼 로컬 파일 시스템에 쓴 데이터가 남지 않습니다.
이럴 때는 nuxt.config.ts의 nitro.storage 설정으로 data 저장소를 Cloudflare KV 같은 외부 저장소에 연결하면, 서버 API 코드는 그대로 둔 채 저장 위치만 바꿀 수 있습니다.
마치며
지금까지 Nuxt 4로 할 일 목록 앱을 서버 API까지 갖춘 풀스택 앱으로 만들어 봤습니다.
app 디렉터리에는 Vue 앱을, server 디렉터리에는 API를, shared 디렉터리에는 공통 타입을 두고, 파일 이름만으로 라우팅과 HTTP 메서드를 정했죠.
화면에 필요한 데이터는 useFetch()로 가져오고 사용자 동작은 $fetch로 처리한다는 구분만 기억해도 Nuxt 앱의 뼈대는 충분히 세울 수 있습니다.
다른 메타 프레임워크와 비교해 보고 싶다면 Svelte 진영의 SvelteKit이나 React 진영의 TanStack Start를 함께 살펴보세요. 같은 문제를 각자 어떻게 푸는지 비교해 보면 Nuxt의 선택이 더 잘 보입니다.
더 자세한 내용은 Nuxt 공식 문서를 참고하세요.
This work is licensed under CC BY 4.0