メインコンテンツまでスキップ

Nuxt の基本 — ディレクトリ規約とサーバー API

18 章useFetch$fetch を扱いましたが、それが動く土台には触れませんでした。この章はその土台 — ディレクトリの規約とファイル名から決まる仕組み — を扱います。

Nuxt は Vue に SSR・ルーティング・サーバー API を足したフレームワークです。設定ファイルに書く代わりにファイルをどこに置くかで挙動が決まるのが特徴で、規約を知らないと動かない部分が多くあります。

この章で学ぶこと

  • npm create nuxt@latest が何を聞き、何を作るか
  • app/ 配下のディレクトリと、server/ / public/ / shared/ の役割
  • ファイル名から URL が決まる仕組みと、動的セグメント・キャッチオールの優先順位
  • レイアウトの当て方と definePageMetaこの macro に書けるものと、書くとアプリ全体が落ちるもの
  • server/api のファイル名でメソッドを絞る書き方と、一致しなかったときに返るもの

プロジェクトを作る

npm create nuxt@latest my-app

テンプレート・パッケージマネージャ・依存のインストール・git の初期化・追加モジュールを指定できます。すべてコマンドラインから渡せます (--template / --packageManager / --install / --gitInit / --modules)。非対話の端末で回すと --template--gitInit を要求されるので、この 2 つだけは既定値を持ちません

テンプレートは create-nuxt 3.37.0 の時点で 5 つです。

テンプレート中身
minimalapp.vue だけの最小構成
contentNuxt Content を組み込んだコンテンツ主体のサイト向け
uiNuxt UI 入り
moduleNuxt モジュールを作るための雛形
v5-nightlyNuxt 5 の nightly 版

minimal が作るのはこれだけです。

my-app/
├── app/
│ └── app.vue
├── public/
│ ├── favicon.ico
│ └── robots.txt
├── nuxt.config.ts
├── package.json
├── tsconfig.json
├── README.md
└── .gitignore

app/ は作られますが、中身は app.vue だけです。pages/ などは必要になったときに自分で作ります。

tsconfig.json は自分では設定を持たず、.nuxt/ に生成される 4 つ (app / server / shared / node) を参照するだけです。生成は package.jsonpostinstall に入っている nuxt prepare が行います。node_modules を消したら npm install を通さないと型が壊れます。

ディレクトリの決まり

Nuxt 4 ではアプリのコードが app/ の下にまとまります。置ける場所は次の 8 つです。

ディレクトリ用途
pages/ファイル名から URL が決まるページ
components/自動で import されるコンポーネント
composables/自動で import される Composable
layouts/ページを包むレイアウト
middleware/遷移の前に走らせるコード
plugins/アプリ生成時に走らせるコード
utils/自動で import される関数
assets/ビルドツールが処理する資産

直下には app.vue (ルートコンポーネント) / error.vue (エラーページ) / app.config.ts (リアクティブな設定) を置きます。

app/ の外にも決まった場所があります。最初に覚えるのは次の 4 つで、ほかに modules/ (自作モジュール) / layers/ / content/ / test/ があります。

場所用途
server/api/ routes/ middleware/ plugins/ utils/ を持つサーバー側のコード
public/加工せずそのまま配信するファイル (robots.txt など)
shared/Vue 側とサーバー側の両方から使うコード
nuxt.config.ts設定 (root に置く)
Nuxt 3 の記事との差

Nuxt 3 では pages/components/リポジトリの root 直下に置いていました。Nuxt 4 ではこれらが app/ の下に移っています。古い記事のパスをそのまま使うと認識されません。

ページとルーティング

app/pages/ にファイルを置くと、その名前が URL になります。

app/pages/
├── index.vue → /
├── about.vue → /about
├── posts/
│ └── [id].vue → /posts/1
└── [...slug].vue → 上のどれにも一致しなかった経路

[id].vueidroute.params.id で読めます。[...slug].vue は残り全部を受け取り、route.params.slug に配列で入ります。具体的なルートのほうが優先されるので、/posts/1[...slug].vue ではなく posts/[id].vue が受け取ります。

ページを表示する枠は app.vue に書きます。

app/app.vue
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

<NuxtPage /> が URL に対応するページに置き換わり、<NuxtLayout> がその外側をレイアウトで包みます。

一覧ページはこうなります。

app/pages/index.vue
<script setup lang="ts">
interface Post {
id: number
title: string
}

const { data: posts, status, error, refresh } = await useFetch<Post[]>('/api/posts')
</script>

<template>
<div>
<p v-if="status === 'pending'">読み込み中</p>
<p v-else-if="status === 'error'">{{ error?.message }}</p>
<ul v-else>
<li v-for="post in posts" :key="post.id">
<NuxtLink :to="`/posts/${post.id}`">{{ post.title }}</NuxtLink>
</li>
</ul>
<button type="button" @click="refresh()">再取得</button>
</div>
</template>

useFetchstatus'idle' / 'pending' / 'success' / 'error' の 4 つです。サーバー側の描画では解決を待つので、初回の HTML には結果が入った状態で届きます。 status を見るのはクライアント側の遷移や再取得のためです。

クライアント側の遷移を待たせたくないなら useLazyFetch を使います。useFetchlazy: true を付けたのと同じで、signature も変わりません。効くのはクライアント側だけで、サーバー側では同じく待ちます (初回の HTML には status: success とデータが入ります)。この名前はコンパイラが変換する予約語なので、自作の関数に同じ名前を付けないでください。

レイアウト

app/layouts/default.vue を置くと、レイアウトを指定していないページがこれで包まれます。中身は <slot /> の位置に入ります。

app/layouts/default.vue
<template>
<div>
<header>
<nav>
<NuxtLink to="/">Home</NuxtLink>
<NuxtLink to="/about">About</NuxtLink>
</nav>
</header>
<main>
<slot />
</main>
</div>
</template>

別のレイアウトを当てるには、ページ側で definePageMeta に名前を渡します。

app/pages/posts/[id].vue
<script setup lang="ts">
definePageMeta({ layout: 'alt' })

const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.id}`)
</script>

<template>
<article>
<h1>{{ post?.title }}</h1>
</article>
</template>

definePageMeta に書けるもの

definePageMetaapp/pages/ のページ専用のコンパイラマクロです。layout のほかに middleware / key / keepalive / validate / name / path / alias / redirect / scrollToTop / 各種トランジション、それに好きな名前のカスタムキーを渡せます。

definePageMeta は、引数と、それが参照しているローカルの宣言をまとめてモジュールのトップレベルへ持ち上げます。マクロが一律にこうするわけではありません — defineProps はローカルの参照をコンパイル時に弾きます。defineModel も原則そうですが、10 章で見たとおり spread を挟むと検査を素通りして実行時の ReferenceError になります。

持ち上がるのは変数宣言と関数宣言だけです。この 2 つなら参照して構いません。逆に class 宣言は運ばれないので、参照すると ReferenceError: ... is not defined でアプリ全体が 500 になります (診断コードは付きません)。

function pickLayout() {
return 'alt'
}
const chosen = pickLayout()
definePageMeta({ layout: chosen })

持ち上げても解決できないものは、止まる場所が 2 通りに分かれます

1 つは Nuxt のコンテキストを要求するものです。持ち上げ先にはそのコンテキストが無いので、useRoute() のような Composable は NUXT_E1001 を投げます。

// これを書くとアプリ全体が動かなくなる
const route = useRoute()
definePageMeta({ layout: route.query.l === 'alt' ? 'alt' : 'default' })

ビルドは通ります。 落ちるのは実行時で、しかもそのページだけでなくアプリ全体が 500 になります。ページを 1 つ足しただけでトップページまで開けなくなるので、原因を追いにくい種類の壊れ方です。

もう 1 つは await を含む変数です。こちらはビルドの時点で止まります

[NUXT_B4002] An `await` expression is used in a variable referenced by `definePageMeta`, which runs synchronously.
╰▶ fix: Move the `await` outside of variables referenced in `definePageMeta`, or use a static value instead

理由はコンテキストではなく、definePageMeta が同期に走ることです。診断の文面どおりで、await Promise.resolve('alt') のように Nuxt と無関係な待ちでも同じく落ちます。useFetch の結果でレイアウトを決めようとするとこれに当たります。

条件でレイアウトを変えたいなら、definePageMeta には固定値を書き、切り替えは <NuxtLayout name="..."> を使うか、レイアウト側で分岐します。

サーバー API

server/api/ にファイルを置くと、そのままエンドポイントになります。defineEventHandler の戻り値が JSON として返ります。

server/api/posts.get.ts
export default defineEventHandler(() => {
return [
{ id: 1, title: '最初の記事' },
{ id: 2, title: '次の記事' },
]
})

ファイル名の .get / .post などの suffix で HTTP メソッドを絞れます。 suffix を付けなければすべてのメソッドに応えます。同じパスでメソッドごとに別ファイルを置けます。

server/api/posts.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody<{ title?: string }>(event)
if (!body?.title) {
throw createError({ statusCode: 400, message: 'title が要ります' })
}
return { id: 3, title: body.title }
})

動的セグメントはページと同じ [id] の形で、getRouterParam で読みます。

server/api/posts/[id].get.ts
export default defineEventHandler((event) => {
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({ statusCode: 400, message: 'id が要ります' })
}
return { id: Number(id), title: `記事 ${id}` }
})

メソッドが一致しないと何が返るか

405 は返りません。 一致するハンドラが無いので、そのリクエストは通常のページ解決へ落ちます。つまり返るものはキャッチオールページがあるかどうかで変わります

キャッチオールPOST /api/posts (GET 専用のとき)
無い404
[...slug].vue があるそのページの HTML が 200 で返る (route.params.slugapi/posts)

後者が厄介です。$fetch は HTML を文字列としてそのまま返し、例外を投げません。 200 なのでエラー処理にも入らず、data に HTML が入ったまま処理が進みます (素の fetch(...).json() なら例外になります)。キャッチオールページを置くなら、/api/ 以下を弾く分岐を入れるか、メソッドの取り違えを疑ってください。

まとめ

  • npm create nuxt@latest <name> はテンプレート・パッケージマネージャ・インストール・git の初期化・モジュールを受け取ります。コマンドラインで渡さなかったものは対話で補われ、既定値を持たないのは --template--gitInit です。テンプレートは create-nuxt 3.37.0 の時点で minimal / content / ui / module / v5-nightly の 5 つ
  • minimal が作るのは app/app.vue / public/ / nuxt.config.ts / package.json / tsconfig.json / README.md / .gitignore だけです。tsconfig.json.nuxt/ の 4 つを参照するだけなので、postinstallnuxt prepare が走っていないと型が壊れます
  • アプリのコードは app/ の下に置きます (pages / components / composables / layouts / middleware / plugins / utils / assets の 8 つ)。直下に app.vue / error.vue / app.config.tsapp/ の外は server/ / public/ / shared/ / nuxt.config.ts が中心です。Nuxt 3 は root 直下だったので、古い記事のパスは通りません
  • ファイル名から URL が決まります。[id].vue が動的セグメント、[...slug].vue がキャッチオールで、具体的なルートのほうが優先されます
  • useFetchstatus'idle' / 'pending' / 'success' / 'error' の 4 つです。サーバー側の描画では解決を待つので、初回の HTML には結果が入っています。useLazyFetch が待たせないのはクライアント側の遷移だけで、サーバー側では同じく待ちます (予約語なので自作関数に同じ名前を付けないこと)
  • レイアウトは app/layouts/ に置き、<slot /> の位置に中身が入ります。切り替えは definePageMeta({ layout: '...' }) です
  • definePageMeta は引数と、それが参照するローカルの宣言をまとめてトップレベルへ持ち上げます (マクロ一般の性質ではありません)。持ち上がるのは変数宣言と関数宣言だけで、class 宣言は運ばれず ReferenceError になります
  • 持ち上げても解決できないものは止まる場所が分かれます。Nuxt のコンテキストを要求する Composable は実行時に NUXT_E1001 でアプリ全体を 500 にします (ビルドは通ります)。await を含む変数は NUXT_B4002 でビルドを止めます — 同期に走るためで、Nuxt と無関係な await でも同じです
  • server/api/ のファイル名の .get / .post suffix でメソッドを絞れます。suffix 無しは全メソッド。動的セグメントは [id]getRouterParam から読みます
  • メソッドが一致しないと 405 ではありません。 キャッチオールページが無ければ 404、[...slug].vue があるとそのページの HTML が 200 で返ります$fetch はその HTML を文字列として返して例外を投げないので、静かに壊れます
関連リファレンス

次に読む