Nuxt.js 3完全ガイド:Vue.js製フルスタックフレームワーク
Nuxt.js 3はVue.jsをベースとしたフルスタックフレームワークであり、現代のWebアプリケーション開発における強力な選択肢のひとつとなっている。サーバーサイドレンダリング(SSR)・静的サイト生成(SSG)・APIルート・Composablesなど、生産性を高める機能が標準で備わっている。本記事では、Nuxt.js 3の全体像から実践的な実装パターンまでを体系的に解説する。
目次
- Nuxt.js 3とは・Next.jsとの比較
- セットアップとプロジェクト作成
- ファイルベースルーティング
- レンダリングモード(SSR・SSG・ISR・SPA)
- Composables(useAsyncData・useFetch・useState)
- Nitroサーバー(APIルート)
- Pinia状態管理
- Nuxt UIとモジュールエコシステム
- データフェッチング戦略
- ミドルウェアとルートガード
- Nuxt Content(マークダウン駆動CMS)
- SEO最適化
- テスト(Vitest・Nuxt Testing Utils)
- デプロイ(Vercel・Netlify・Cloudflare)
1. Nuxt.js 3とは・Next.jsとの比較
Nuxt.js 3の概要
Nuxt.js 3(以下「Nuxt 3」)は、Vue.js 3をコアに据えたオープンソースのフルスタックフレームワークである。2022年11月にリリースされた正式版は、Vue 3のComposition APIとTypeScriptを全面採用し、開発者体験(DX)を大幅に向上させた。
Nuxt 3の主な特徴を以下に挙げる。
- Nitroエンジン: ユニバーサルなサーバーエンジンにより、Vercel・Netlify・Cloudflare Workersなどあらゆるデプロイターゲットへのデプロイが可能
- 自動インポート: コンポーネント・Composables・ユーティリティが自動インポートされ、import文の記述が不要
- TypeScriptファースト: 設定ファイルを含めてTypeScriptで記述可能
- Viteベースのビルド: 高速なHot Module Replacement(HMR)を実現
- ハイブリッドレンダリング: ページごとに異なるレンダリングモードを設定可能
Next.jsとの技術的比較
ReactベースのNext.jsとVueベースのNuxt 3は、設計思想が似ているが、いくつかの重要な違いがある。
| 機能 | Nuxt 3 | Next.js 14/15 |
|---|---|---|
| ベースフレームワーク | Vue 3 | React 18/19 |
| サーバーエンジン | Nitro | Node.js / Edge Runtime |
| 状態管理 | Pinia(公式推奨) | Zustand / Redux Toolkit |
| スタイリング | CSS Modules / Tailwind CSS | CSS Modules / Tailwind CSS |
| データフェッチング | useFetch / useAsyncData | Server Components / fetch |
| ルーティング | ファイルベース(pages/) | ファイルベース(app/ or pages/) |
| API Routes | server/api/ | app/api/ or pages/api/ |
| 自動インポート | 標準搭載 | 手動import |
| TypeScript | フルサポート | フルサポート |
Nuxt 3の大きな優位性のひとつは自動インポートシステムである。コンポーネントやComposablesを明示的にimportする必要がなく、コードの冗長性が大幅に削減される。
Nuxt 3のアーキテクチャ
Nuxt 3は大きく3つの層から構成される。
1. アプリケーション層
pages/: ファイルベースルーティングcomponents/: Vueコンポーネント(自動インポート)composables/: Composition APIロジック(自動インポート)layouts/: ページレイアウトplugins/: アプリケーションプラグイン
2. サーバー層(Nitro)
server/api/: APIエンドポイントserver/middleware/: サーバーミドルウェアserver/routes/: カスタムサーバールート
3. 設定・ビルド層
nuxt.config.ts: フレームワーク設定.nuxt/: 自動生成ファイルpublic/: 静的アセット
2. セットアップとプロジェクト作成
必要な環境
Nuxt 3を使用するには以下の環境が必要である。
- Node.js 18.x 以上(推奨:20.x LTS)
- npm / pnpm / yarn / bun のいずれか
新規プロジェクトの作成
公式CLIツールnuxiを使ってプロジェクトを作成する。
# nuxi を使ったプロジェクト作成
npx nuxi@latest init my-nuxt-app
# または pnpm を使う場合
pnpm dlx nuxi@latest init my-nuxt-app
# プロジェクトディレクトリに移動
cd my-nuxt-app
# 依存関係のインストール
npm install
# 開発サーバーの起動
npm run dev
開発サーバーは通常http://localhost:3000で起動する。
プロジェクト構造の全体像
作成直後のプロジェクト構造は以下のようになっている。
my-nuxt-app/
├── .nuxt/ # 自動生成(gitignore対象)
├── node_modules/
├── public/ # 静的ファイル
│ └── favicon.ico
├── server/ # Nitroサーバー
│ └── tsconfig.json
├── app.vue # ルートコンポーネント
├── nuxt.config.ts # Nuxt設定
├── package.json
├── tsconfig.json
└── .gitignore
実際の開発では以下のディレクトリを追加していく。
my-nuxt-app/
├── assets/ # CSSや画像(ビルド時に処理)
├── components/ # Vueコンポーネント(自動インポート)
├── composables/ # Composables(自動インポート)
├── layouts/ # レイアウトコンポーネント
├── middleware/ # クライアントサイドミドルウェア
├── pages/ # ファイルベースルーティング
├── plugins/ # プラグイン
├── server/
│ ├── api/ # APIルート
│ ├── middleware/ # サーバーミドルウェア
│ └── utils/ # サーバーユーティリティ
├── stores/ # Piniaストア
└── utils/ # ユーティリティ関数(自動インポート)
nuxt.config.ts の基本設定
// nuxt.config.ts
export default defineNuxtConfig({
// 開発ツール(開発時のみ有効)
devtools: { enabled: true },
// TypeScript設定
typescript: {
strict: true,
typeCheck: true,
},
// CSSフレームワークの設定
css: ['~/assets/css/main.css'],
// モジュール
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'@nuxt/content',
'@nuxt/image',
],
// ランタイム設定(環境変数)
runtimeConfig: {
// サーバーサイドのみ利用可能(秘密情報)
apiSecret: process.env.API_SECRET,
// クライアントサイドでも利用可能
public: {
apiBase: process.env.API_BASE_URL || 'https://api.example.com',
appName: 'My Nuxt App',
},
},
// アプリケーション設定
app: {
head: {
title: 'My Nuxt App',
meta: [
{ name: 'description', content: 'Nuxt 3 Application' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
],
},
},
// ルートごとのレンダリング設定(ハイブリッドレンダリング)
routeRules: {
'/': { prerender: true },
'/blog/**': { swr: 3600 },
'/admin/**': { ssr: false },
'/api/**': { cors: true },
},
})
app.vue の構成
<!-- app.vue -->
<template>
<div>
<!-- NuxtLayout はレイアウトシステムを有効化する -->
<NuxtLayout>
<!-- NuxtPage はページコンポーネントをレンダリングする -->
<NuxtPage />
</NuxtLayout>
</div>
</template>
3. ファイルベースルーティング
pages/ ディレクトリによるルーティング
Nuxt 3ではpages/ディレクトリのファイル構造が自動的にルーティングに変換される。
pages/
├── index.vue → /
├── about.vue → /about
├── contact.vue → /contact
├── blog/
│ ├── index.vue → /blog
│ └── [slug].vue → /blog/:slug
├── users/
│ ├── index.vue → /users
│ └── [id]/
│ ├── index.vue → /users/:id
│ └── posts.vue → /users/:id/posts
└── [...slug].vue → /* (キャッチオール)
基本的なページコンポーネント
<!-- pages/index.vue -->
<template>
<main>
<h1>ホームページ</h1>
<p>Nuxt 3へようこそ</p>
<!-- NuxtLink は vue-router の RouterLink の代替 -->
<NuxtLink to="/about">Aboutページへ</NuxtLink>
<NuxtLink to="/blog">ブログ一覧へ</NuxtLink>
</main>
</template>
<script setup lang="ts">
// definePageMeta でページのメタ情報を設定
definePageMeta({
title: 'ホーム',
layout: 'default',
})
</script>
動的ルート
<!-- pages/blog/[slug].vue -->
<template>
<article>
<h1>{{ post?.title }}</h1>
<p>{{ post?.body }}</p>
</article>
</template>
<script setup lang="ts">
// useRoute でルートパラメータにアクセス
const route = useRoute()
const slug = computed(() => route.params.slug as string)
// useFetch でAPIからデータを取得
const { data: post, error } = await useFetch(`/api/posts/${slug.value}`)
// エラーハンドリング
if (error.value) {
throw createError({
statusCode: 404,
statusMessage: '記事が見つかりません',
})
}
// SEOメタ情報の設定
useSeoMeta({
title: post.value?.title,
description: post.value?.excerpt,
})
</script>
ネストされたルート(Nested Routes)
<!-- pages/users/[id].vue - 親ルート -->
<template>
<div>
<h1>ユーザープロフィール: {{ user?.name }}</h1>
<nav>
<NuxtLink :to="`/users/${userId}`">プロフィール</NuxtLink>
<NuxtLink :to="`/users/${userId}/posts`">投稿一覧</NuxtLink>
</nav>
<!-- 子ルートがここにレンダリングされる -->
<NuxtPage />
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const userId = computed(() => route.params.id as string)
const { data: user } = await useFetch(`/api/users/${userId.value}`)
</script>
キャッチオールルートと404ページ
<!-- pages/[...slug].vue - キャッチオール -->
<template>
<div>
<h1>ページが見つかりません</h1>
<p>リクエストされたパス: {{ $route.params.slug }}</p>
<NuxtLink to="/">ホームへ戻る</NuxtLink>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'error',
})
</script>
<!-- error.vue - グローバルエラーページ -->
<template>
<div>
<h1>{{ error.statusCode }}: {{ error.statusMessage }}</h1>
<button @click="handleError">ホームへ戻る</button>
</div>
</template>
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps<{
error: NuxtError
}>()
const handleError = () => clearError({ redirect: '/' })
</script>
プログラムによるナビゲーション
// composables/useNavigation.ts
export const useNavigation = () => {
const router = useRouter()
const route = useRoute()
// 前のページへ戻る
const goBack = () => router.back()
// クエリパラメータを更新
const updateQuery = (params: Record<string, string>) => {
router.push({
query: {
...route.query,
...params,
},
})
}
// ページ遷移
const navigateTo = async (path: string) => {
await router.push(path)
}
return {
goBack,
updateQuery,
navigateTo,
}
}
4. レンダリングモード(SSR・SSG・ISR・SPA)
サーバーサイドレンダリング(SSR)
SSRはリクエストのたびにサーバーでHTMLを生成する方式である。SEOが重要なページや、リアルタイムデータを表示するページに適している。
// nuxt.config.ts - SSRを有効化(デフォルト)
export default defineNuxtConfig({
ssr: true, // デフォルトはtrue
})
<!-- pages/news/[id].vue - SSRページの例 -->
<template>
<article>
<h1>{{ article.title }}</h1>
<time>{{ formattedDate }}</time>
<div v-html="article.content" />
</article>
</template>
<script setup lang="ts">
const route = useRoute()
// サーバーサイドでデータを取得
const { data: article } = await useAsyncData(
`article-${route.params.id}`,
() => $fetch(`/api/articles/${route.params.id}`)
)
const formattedDate = computed(() => {
if (!article.value?.publishedAt) return ''
return new Intl.DateTimeFormat('ja-JP', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(article.value.publishedAt))
})
</script>
静的サイト生成(SSG)
SSGはビルド時に全ページのHTMLを事前生成する方式である。高いパフォーマンスとCDNによるキャッシュが可能で、ブログや企業サイトに適している。
// nuxt.config.ts - SSG設定
export default defineNuxtConfig({
// 全体をSSGにする場合
// nitro: {
// prerender: {
// crawlLinks: true,
// routes: ['/'],
// },
// },
// ページごとの設定(推奨)
routeRules: {
'/': { prerender: true },
'/about': { prerender: true },
'/blog/**': { prerender: true },
},
})
<!-- pages/blog/index.vue - SSGブログ一覧 -->
<template>
<div>
<h1>ブログ記事一覧</h1>
<ul>
<li v-for="post in posts" :key="post.id">
<NuxtLink :to="`/blog/${post.slug}`">
{{ post.title }}
</NuxtLink>
<span>{{ post.publishedAt }}</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
// ビルド時に実行される
const { data: posts } = await useAsyncData('blog-posts', () =>
$fetch('/api/posts')
)
</script>
インクリメンタル静的再生成(ISR/SWR)
ISRはSSGとSSRのハイブリッドアプローチで、設定した時間ごとにバックグラウンドでページを再生成する。
// nuxt.config.ts - ISR/SWR設定
export default defineNuxtConfig({
routeRules: {
// 1時間ごとに再生成(SWR: Stale-While-Revalidate)
'/blog/**': { swr: 3600 },
// 24時間のキャッシュ(ISR的な動作)
'/products/**': { isr: 86400 },
// 完全なSSR(キャッシュなし)
'/dashboard': { ssr: true },
// CSR(クライアントサイドレンダリング)
'/admin/**': { ssr: false },
// 静的事前生成
'/': { prerender: true },
},
})
SPAモード
認証が必要な管理画面などには、クライアントサイドのみでレンダリングするSPAモードが適している。
// nuxt.config.ts - SPAモードの設定
export default defineNuxtConfig({
routeRules: {
'/admin/**': {
ssr: false,
},
},
})
<!-- pages/admin/dashboard.vue - SPAページ -->
<template>
<div>
<ClientOnly>
<AdminDashboard :data="dashboardData" />
<template #fallback>
<div>読み込み中...</div>
</template>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
// クライアントサイドでのみ実行
const { data: dashboardData } = await useFetch('/api/admin/dashboard', {
// SSRを無効化
server: false,
})
</script>
5. Composables(useAsyncData・useFetch・useState)
Composablesの自動インポート
Nuxt 3ではcomposables/ディレクトリに配置したファイルが自動インポートされる。
composables/
├── useAuth.ts
├── useApi.ts
├── usePagination.ts
└── useToast.ts
useFetch
useFetchはNuxt 3の基本的なデータフェッチングComposableである。
// composables/useApi.ts
export const useApi = () => {
const config = useRuntimeConfig()
const baseURL = config.public.apiBase
// GET リクエスト
const get = <T>(path: string, options?: Parameters<typeof useFetch>[1]) => {
return useFetch<T>(`${baseURL}${path}`, {
...options,
method: 'GET',
})
}
// POST リクエスト
const post = <T>(
path: string,
body: Record<string, unknown>,
options?: Parameters<typeof useFetch>[1]
) => {
return useFetch<T>(`${baseURL}${path}`, {
...options,
method: 'POST',
body,
})
}
return { get, post }
}
<!-- pages/products/index.vue -->
<template>
<div>
<!-- ローディング状態 -->
<div v-if="pending">読み込み中...</div>
<!-- エラー状態 -->
<div v-else-if="error">
エラーが発生しました: {{ error.message }}
</div>
<!-- データ表示 -->
<ul v-else>
<li v-for="product in products" :key="product.id">
{{ product.name }} - ¥{{ product.price.toLocaleString() }}
</li>
</ul>
<!-- 再フェッチボタン -->
<button @click="refresh">更新</button>
</div>
</template>
<script setup lang="ts">
interface Product {
id: number
name: string
price: number
category: string
}
// useFetch の基本的な使い方
const {
data: products,
pending,
error,
refresh,
} = await useFetch<Product[]>('/api/products', {
// キャッシュキー
key: 'products-list',
// デフォルト値
default: () => [],
// ウォッチするreactive値(変化時に再フェッチ)
// watch: [selectedCategory],
// レスポンスの変換
transform: (data) => data.filter((p) => p.price > 0),
// ヘッダー
headers: {
'Content-Type': 'application/json',
},
})
</script>
useAsyncData
useAsyncDataはuseFetchより低レベルなAPIで、任意の非同期処理に使用できる。
// composables/usePosts.ts
export interface Post {
id: number
title: string
slug: string
excerpt: string
content: string
publishedAt: string
author: {
name: string
avatar: string
}
tags: string[]
}
export const usePosts = () => {
// 記事一覧の取得
const fetchPosts = async (params?: {
page?: number
limit?: number
tag?: string
}) => {
return useAsyncData(
`posts-${JSON.stringify(params)}`,
async () => {
const queryParams = new URLSearchParams()
if (params?.page) queryParams.set('page', String(params.page))
if (params?.limit) queryParams.set('limit', String(params.limit))
if (params?.tag) queryParams.set('tag', params.tag)
return $fetch<{
posts: Post[]
total: number
page: number
totalPages: number
}>(`/api/posts?${queryParams.toString()}`)
},
{
default: () => ({
posts: [],
total: 0,
page: 1,
totalPages: 0,
}),
}
)
}
// 特定記事の取得
const fetchPost = (slug: string) => {
return useAsyncData(`post-${slug}`, () =>
$fetch<Post>(`/api/posts/${slug}`)
)
}
return { fetchPosts, fetchPost }
}
useState
useStateはSSRと互換性のあるreactive状態管理Composableである。
// composables/useTheme.ts
export type Theme = 'light' | 'dark' | 'system'
export const useTheme = () => {
// useState はサーバーとクライアントで状態が共有される
const theme = useState<Theme>('theme', () => 'system')
const setTheme = (newTheme: Theme) => {
theme.value = newTheme
if (process.client) {
localStorage.setItem('theme', newTheme)
document.documentElement.setAttribute('data-theme', newTheme)
}
}
const resolvedTheme = computed(() => {
if (theme.value === 'system') {
if (process.client) {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
return 'light'
}
return theme.value
})
return {
theme: readonly(theme),
resolvedTheme,
setTheme,
}
}
// composables/useAuth.ts
export interface User {
id: string
name: string
email: string
role: 'admin' | 'user' | 'editor'
avatar?: string
}
export const useAuth = () => {
const user = useState<User | null>('auth-user', () => null)
const isLoggedIn = computed(() => user.value !== null)
const isAdmin = computed(() => user.value?.role === 'admin')
const login = async (email: string, password: string) => {
const { data, error } = await useFetch<{ user: User; token: string }>(
'/api/auth/login',
{
method: 'POST',
body: { email, password },
}
)
if (error.value) throw error.value
if (data.value) {
user.value = data.value.user
// cookieにトークンを保存
const token = useCookie('auth-token', {
maxAge: 60 * 60 * 24 * 7, // 7日間
secure: true,
sameSite: 'strict',
})
token.value = data.value.token
}
}
const logout = async () => {
await $fetch('/api/auth/logout', { method: 'POST' })
user.value = null
const token = useCookie('auth-token')
token.value = null
await navigateTo('/login')
}
const fetchCurrentUser = async () => {
const { data } = await useFetch<User>('/api/auth/me')
if (data.value) {
user.value = data.value
}
}
return {
user: readonly(user),
isLoggedIn,
isAdmin,
login,
logout,
fetchCurrentUser,
}
}
カスタムComposablesの実践例
// composables/usePagination.ts
export interface PaginationOptions {
initialPage?: number
pageSize?: number
totalItems?: number
}
export const usePagination = (options: PaginationOptions = {}) => {
const { initialPage = 1, pageSize = 10, totalItems = 0 } = options
const currentPage = ref(initialPage)
const itemsPerPage = ref(pageSize)
const total = ref(totalItems)
const totalPages = computed(() =>
Math.ceil(total.value / itemsPerPage.value)
)
const hasPreviousPage = computed(() => currentPage.value > 1)
const hasNextPage = computed(() => currentPage.value < totalPages.value)
const previousPage = () => {
if (hasPreviousPage.value) {
currentPage.value--
}
}
const nextPage = () => {
if (hasNextPage.value) {
currentPage.value++
}
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
}
}
const offset = computed(
() => (currentPage.value - 1) * itemsPerPage.value
)
return {
currentPage,
itemsPerPage,
total,
totalPages,
hasPreviousPage,
hasNextPage,
offset,
previousPage,
nextPage,
goToPage,
}
}
6. Nitroサーバー(APIルート)
Nitroエンジンの概要
Nitroは、Nuxt 3に組み込まれたサーバーエンジンである。以下の特徴を持つ。
- ユニバーサルデプロイ: Node.js・Deno・Cloudflare Workers・AWS Lambda等に対応
- ファイルベースルーティング:
server/api/のファイル構造がAPIルートになる - 自動コード分割: 必要な部分だけをバンドル
- Hot Module Replacement: 開発時の高速な更新
APIルートの作成
// server/api/hello.get.ts
// GET /api/hello
export default defineEventHandler((event) => {
return {
message: 'Hello from Nuxt 3!',
timestamp: new Date().toISOString(),
}
})
// server/api/posts/index.get.ts
// GET /api/posts
import { z } from 'zod'
const querySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(10),
tag: z.string().optional(),
})
export default defineEventHandler(async (event) => {
// クエリパラメータのバリデーション
const query = await getValidatedQuery(event, querySchema.parse)
// データベースやCMSからデータを取得(例)
const allPosts = await fetchPostsFromDB({
skip: (query.page - 1) * query.limit,
take: query.limit,
tag: query.tag,
})
return {
posts: allPosts.items,
total: allPosts.total,
page: query.page,
totalPages: Math.ceil(allPosts.total / query.limit),
}
})
// server/api/posts/[id].get.ts
// GET /api/posts/:id
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'ID は必須です',
})
}
const post = await fetchPostById(id)
if (!post) {
throw createError({
statusCode: 404,
statusMessage: '記事が見つかりません',
})
}
return post
})
// server/api/posts/index.post.ts
// POST /api/posts
import { z } from 'zod'
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
excerpt: z.string().max(500).optional(),
tags: z.array(z.string()).default([]),
publishedAt: z.string().datetime().optional(),
})
export default defineEventHandler(async (event) => {
// 認証チェック
const authHeader = getHeader(event, 'authorization')
if (!authHeader?.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
statusMessage: '認証が必要です',
})
}
// リクエストボディのバリデーション
const body = await readValidatedBody(event, createPostSchema.parse)
const newPost = await createPost({
...body,
publishedAt: body.publishedAt ? new Date(body.publishedAt) : new Date(),
})
setResponseStatus(event, 201)
return newPost
})
サーバーミドルウェア
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
// /api/admin パスのみ認証チェック
if (!event.path.startsWith('/api/admin')) {
return
}
const token = getCookie(event, 'auth-token')
|| getHeader(event, 'authorization')?.replace('Bearer ', '')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: '認証が必要です',
})
}
try {
// JWTトークンを検証
const payload = await verifyJWT(token)
// イベントコンテキストにユーザー情報を設定
event.context.user = payload
} catch {
throw createError({
statusCode: 401,
statusMessage: '無効なトークンです',
})
}
})
// server/middleware/rate-limit.ts
// シンプルなレートリミットの実装
const requestCounts = new Map<string, { count: number; resetAt: number }>()
export default defineEventHandler((event) => {
// APIルートのみ対象
if (!event.path.startsWith('/api')) return
const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
const now = Date.now()
const windowMs = 60 * 1000 // 1分
const maxRequests = 100
const current = requestCounts.get(ip)
if (!current || current.resetAt < now) {
requestCounts.set(ip, { count: 1, resetAt: now + windowMs })
return
}
if (current.count >= maxRequests) {
throw createError({
statusCode: 429,
statusMessage: 'リクエストが多すぎます。しばらく待ってから再試行してください',
})
}
current.count++
})
サーバーユーティリティ
// server/utils/database.ts
// データベース接続ユーティリティ
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
let db: ReturnType<typeof drizzle> | null = null
export const getDatabase = () => {
if (!db) {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
db = drizzle(pool)
}
return db
}
// server/utils/jwt.ts
import { SignJWT, jwtVerify } from 'jose'
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || 'default-secret-change-in-production'
)
export const signJWT = async (payload: Record<string, unknown>) => {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(JWT_SECRET)
}
export const verifyJWT = async (token: string) => {
const { payload } = await jwtVerify(token, JWT_SECRET)
return payload
}
7. Pinia状態管理
Piniaの概要
PiniaはVue 3公式の状態管理ライブラリである。Vuexの後継として位置づけられており、TypeScriptとの親和性が高く、シンプルなAPIを提供する。
インストールと設定
npm install pinia @pinia/nuxt
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@pinia/nuxt',
],
// Piniaプラグインを自動インポートする場合
pinia: {
storesDirs: ['./stores/**'],
},
})
ストアの作成
// stores/useCartStore.ts
import { defineStore } from 'pinia'
export interface CartItem {
id: number
name: string
price: number
quantity: number
imageUrl: string
}
export const useCartStore = defineStore('cart', () => {
// State
const items = ref<CartItem[]>([])
const couponCode = ref<string | null>(null)
const discountRate = ref(0)
// Getters
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const subtotal = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const discount = computed(() => subtotal.value * discountRate.value)
const total = computed(() => subtotal.value - discount.value)
const isEmpty = computed(() => items.value.length === 0)
// Actions
const addItem = (product: Omit<CartItem, 'quantity'>) => {
const existingItem = items.value.find((item) => item.id === product.id)
if (existingItem) {
existingItem.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
const removeItem = (productId: number) => {
const index = items.value.findIndex((item) => item.id === productId)
if (index !== -1) {
items.value.splice(index, 1)
}
}
const updateQuantity = (productId: number, quantity: number) => {
const item = items.value.find((item) => item.id === productId)
if (item) {
if (quantity <= 0) {
removeItem(productId)
} else {
item.quantity = quantity
}
}
}
const applyCoupon = async (code: string) => {
const { data, error } = await useFetch<{ discountRate: number }>(
`/api/coupons/${code}`
)
if (error.value) {
throw new Error('無効なクーポンコードです')
}
couponCode.value = code
discountRate.value = data.value?.discountRate ?? 0
}
const clearCart = () => {
items.value = []
couponCode.value = null
discountRate.value = 0
}
return {
items,
couponCode,
itemCount,
subtotal,
discount,
total,
isEmpty,
addItem,
removeItem,
updateQuantity,
applyCoupon,
clearCart,
}
}, {
// ブラウザのlocalStorageに状態を永続化
persist: true,
})
ストアをコンポーネントで使用する
<!-- components/CartSidebar.vue -->
<template>
<aside>
<h2>ショッピングカート ({{ cartStore.itemCount }})</h2>
<div v-if="cartStore.isEmpty">
カートに商品がありません
</div>
<ul v-else>
<li v-for="item in cartStore.items" :key="item.id">
<img :src="item.imageUrl" :alt="item.name" />
<div>
<p>{{ item.name }}</p>
<p>¥{{ item.price.toLocaleString() }}</p>
</div>
<div>
<button @click="cartStore.updateQuantity(item.id, item.quantity - 1)">
-
</button>
<span>{{ item.quantity }}</span>
<button @click="cartStore.updateQuantity(item.id, item.quantity + 1)">
+
</button>
</div>
<button @click="cartStore.removeItem(item.id)">削除</button>
</li>
</ul>
<div>
<p>小計: ¥{{ cartStore.subtotal.toLocaleString() }}</p>
<p v-if="cartStore.discount > 0">
割引: -¥{{ cartStore.discount.toLocaleString() }}
</p>
<p>合計: ¥{{ cartStore.total.toLocaleString() }}</p>
</div>
<button :disabled="cartStore.isEmpty" @click="proceedToCheckout">
レジへ進む
</button>
</aside>
</template>
<script setup lang="ts">
const cartStore = useCartStore()
const proceedToCheckout = async () => {
await navigateTo('/checkout')
}
</script>
Piniaストアの永続化(pinia-plugin-persistedstate)
npm install pinia-plugin-persistedstate
// plugins/persistedstate.ts
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.$pinia.use(piniaPluginPersistedstate)
})
8. Nuxt UIとモジュールエコシステム
Nuxt UIの概要
Nuxt UIは、Tailwind CSSをベースにした公式UIコンポーネントライブラリである。
npm install @nuxt/ui
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
})
Nuxt UIコンポーネントの使用
<!-- pages/contact.vue -->
<template>
<UContainer>
<UCard>
<template #header>
<h2>お問い合わせ</h2>
</template>
<UForm :schema="schema" :state="formState" @submit="onSubmit">
<UFormGroup label="お名前" name="name" required>
<UInput
v-model="formState.name"
placeholder="山田 太郎"
/>
</UFormGroup>
<UFormGroup label="メールアドレス" name="email" required>
<UInput
v-model="formState.email"
type="email"
placeholder="taro@example.com"
/>
</UFormGroup>
<UFormGroup label="メッセージ" name="message" required>
<UTextarea
v-model="formState.message"
rows="5"
placeholder="お問い合わせ内容を入力してください"
/>
</UFormGroup>
<UButton type="submit" :loading="isSubmitting">
送信する
</UButton>
</UForm>
</UCard>
</UContainer>
</template>
<script setup lang="ts">
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1, '名前を入力してください'),
email: z.string().email('正しいメールアドレスを入力してください'),
message: z.string().min(10, '10文字以上入力してください'),
})
type FormState = z.infer<typeof schema>
const formState = reactive<FormState>({
name: '',
email: '',
message: '',
})
const isSubmitting = ref(false)
const toast = useToast()
const onSubmit = async () => {
isSubmitting.value = true
try {
await $fetch('/api/contact', {
method: 'POST',
body: formState,
})
toast.add({
title: '送信完了',
description: 'お問い合わせを受け付けました',
color: 'green',
})
// フォームをリセット
Object.assign(formState, { name: '', email: '', message: '' })
} catch (error) {
toast.add({
title: 'エラー',
description: '送信に失敗しました。再試行してください',
color: 'red',
})
} finally {
isSubmitting.value = false
}
}
</script>
主要なNuxtモジュール一覧
| モジュール | パッケージ | 用途 |
|---|---|---|
| @nuxt/ui | @nuxt/ui | UIコンポーネントライブラリ |
| @nuxtjs/tailwindcss | @nuxtjs/tailwindcss | Tailwind CSS統合 |
| @pinia/nuxt | @pinia/nuxt | 状態管理 |
| @nuxt/content | @nuxt/content | マークダウン駆動CMS |
| @nuxt/image | @nuxt/image | 画像最適化 |
| @nuxtjs/i18n | @nuxtjs/i18n | 国際化 |
| @nuxt/icon | @nuxt/icon | アイコンシステム |
| @sentry/nuxt | @sentry/nuxt | エラー監視 |
| nuxt-security | nuxt-security | セキュリティヘッダー |
@nuxt/image の使用
<!-- components/ProductCard.vue -->
<template>
<div>
<!-- NuxtImg は自動的に画像を最適化する -->
<NuxtImg
:src="product.imageUrl"
:alt="product.name"
width="400"
height="300"
format="webp"
quality="80"
loading="lazy"
sizes="sm:100vw md:50vw lg:400px"
/>
<h3>{{ product.name }}</h3>
<p>¥{{ product.price.toLocaleString() }}</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
product: {
id: number
name: string
price: number
imageUrl: string
}
}>()
</script>
@nuxtjs/i18n の設定
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
locales: [
{ code: 'ja', name: '日本語', file: 'ja.json' },
{ code: 'en', name: 'English', file: 'en.json' },
],
defaultLocale: 'ja',
langDir: 'locales/',
strategy: 'prefix_except_default',
},
})
// locales/ja.json
{
"nav": {
"home": "ホーム",
"about": "会社情報",
"products": "商品",
"contact": "お問い合わせ"
},
"common": {
"loading": "読み込み中...",
"error": "エラーが発生しました",
"retry": "再試行"
}
}
9. データフェッチング戦略
$fetch の使用
$fetchはNuxt 3に組み込まれたofetchライブラリのラッパーで、APIリクエストを行う際の基本ツールである。
// composables/useProducts.ts
export interface Product {
id: number
name: string
price: number
stock: number
category: string
}
export const useProducts = () => {
const products = useState<Product[]>('products', () => [])
const isLoading = ref(false)
const error = ref<Error | null>(null)
const loadProducts = async (category?: string) => {
isLoading.value = true
error.value = null
try {
const url = category ? `/api/products?category=${category}` : '/api/products'
products.value = await $fetch<Product[]>(url)
} catch (e) {
error.value = e instanceof Error ? e : new Error('Unknown error')
} finally {
isLoading.value = false
}
}
const createProduct = async (data: Omit<Product, 'id'>) => {
const newProduct = await $fetch<Product>('/api/products', {
method: 'POST',
body: data,
})
products.value = [...products.value, newProduct]
return newProduct
}
const updateProduct = async (id: number, data: Partial<Product>) => {
const updated = await $fetch<Product>(`/api/products/${id}`, {
method: 'PATCH',
body: data,
})
products.value = products.value.map((p) =>
p.id === id ? updated : p
)
return updated
}
const deleteProduct = async (id: number) => {
await $fetch(`/api/products/${id}`, { method: 'DELETE' })
products.value = products.value.filter((p) => p.id !== id)
}
return {
products: readonly(products),
isLoading: readonly(isLoading),
error: readonly(error),
loadProducts,
createProduct,
updateProduct,
deleteProduct,
}
}
データフェッチングのベストプラクティス
<!-- pages/blog/index.vue - ページネーション付きブログ一覧 -->
<template>
<div>
<h1>ブログ</h1>
<!-- カテゴリフィルター -->
<div>
<button
v-for="cat in categories"
:key="cat"
:class="{ active: selectedCategory === cat }"
@click="selectedCategory = cat"
>
{{ cat }}
</button>
</div>
<!-- 記事一覧 -->
<div v-if="pending">読み込み中...</div>
<div v-else-if="error">エラー: {{ error.message }}</div>
<div v-else>
<PostCard
v-for="post in data?.posts"
:key="post.id"
:post="post"
/>
</div>
<!-- ページネーション -->
<div v-if="data">
<button :disabled="page <= 1" @click="page--">前へ</button>
<span>{{ page }} / {{ data.totalPages }}</span>
<button :disabled="page >= data.totalPages" @click="page++">次へ</button>
</div>
</div>
</template>
<script setup lang="ts">
const selectedCategory = ref<string>('all')
const page = ref(1)
const categories = ['all', 'tech', 'design', 'business']
// selectedCategory または page が変わると自動的に再フェッチ
const { data, pending, error } = await useAsyncData(
'blog-posts',
() =>
$fetch('/api/posts', {
query: {
category: selectedCategory.value !== 'all'
? selectedCategory.value
: undefined,
page: page.value,
limit: 10,
},
}),
{
// ウォッチするreactive値
watch: [selectedCategory, page],
// カテゴリが変わったらページを1にリセット
onRequest() {
if (selectedCategory.value) page.value = 1
},
}
)
</script>
キャッシュ戦略
// composables/useCache.ts
export const useCache = <T>(
key: string,
fetcher: () => Promise<T>,
options: {
ttl?: number // キャッシュ有効期間(秒)
staleWhileRevalidate?: boolean
} = {}
) => {
const { ttl = 300, staleWhileRevalidate = true } = options
return useAsyncData<T>(key, fetcher, {
getCachedData: (key, nuxtApp) => {
// キャッシュからデータを取得
const cached = nuxtApp.payload.data[key]
if (!cached) return undefined
// TTLチェック
const cacheTime = nuxtApp.payload.data[`${key}_time`] as number
if (cacheTime && Date.now() - cacheTime > ttl * 1000) {
if (!staleWhileRevalidate) return undefined
// SWR: 古いデータを返しつつバックグラウンドで更新
}
return cached
},
})
}
10. ミドルウェアとルートガード
クライアントサイドミドルウェア
// middleware/auth.ts - 認証ミドルウェア
export default defineNuxtRouteMiddleware((to, from) => {
const { isLoggedIn } = useAuth()
// 未認証ユーザーをログインページへリダイレクト
if (!isLoggedIn.value) {
return navigateTo({
path: '/login',
query: {
redirect: to.fullPath,
},
})
}
})
// middleware/admin.ts - 管理者ミドルウェア
export default defineNuxtRouteMiddleware(() => {
const { isAdmin, isLoggedIn } = useAuth()
if (!isLoggedIn.value) {
return navigateTo('/login')
}
if (!isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'このページへのアクセス権限がありません',
})
}
})
// middleware/guest.ts - ゲストミドルウェア(ログイン済みユーザーをリダイレクト)
export default defineNuxtRouteMiddleware(() => {
const { isLoggedIn } = useAuth()
if (isLoggedIn.value) {
return navigateTo('/dashboard')
}
})
ミドルウェアの適用
<!-- pages/admin/index.vue -->
<script setup lang="ts">
// ページ固有のミドルウェアを設定
definePageMeta({
middleware: ['auth', 'admin'],
})
</script>
<!-- pages/login.vue -->
<script setup lang="ts">
definePageMeta({
middleware: 'guest',
layout: 'auth',
})
</script>
グローバルミドルウェア
// middleware/analytics.global.ts
// .global がつくとすべてのルートで実行される
export default defineNuxtRouteMiddleware((to) => {
// ページビューのトラッキング
if (process.client) {
// Google Analytics などへの送信
window.gtag?.('event', 'page_view', {
page_path: to.fullPath,
page_title: to.meta.title as string,
})
}
})
サーバーサイドミドルウェアとの連携
// middleware/session.ts - セッション確認
export default defineNuxtRouteMiddleware(async () => {
const { user, fetchCurrentUser } = useAuth()
// サーバーサイドの場合のみ実行
if (process.server && !user.value) {
try {
await fetchCurrentUser()
} catch {
// セッションなし - ログインページではない場合はリダイレクト
}
}
})
11. Nuxt Content(マークダウン駆動CMS)
Nuxt Contentの概要
@nuxt/contentはマークダウンファイルをデータソースとして使えるCMSモジュールである。
npm install @nuxt/content
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/content'],
content: {
// 全文検索の設定
highlight: {
theme: 'github-dark',
langs: ['javascript', 'typescript', 'vue', 'bash', 'json'],
},
markdown: {
toc: {
depth: 3,
searchDepth: 3,
},
},
},
})
コンテンツの作成
content/
├── index.md
├── about.md
└── blog/
├── first-post.md
├── second-post.md
└── third-post.md
---
title: Nuxt 3を使ったブログの作り方
description: Nuxt ContentとNuxt 3を使ってブログを作成する方法を解説します
date: 2026-02-20
tags: [nuxt, vue, blog]
image: /images/blog/nuxt-blog.png
draft: false
---
# Nuxt 3を使ったブログの作り方
本記事では、Nuxt ContentとNuxt 3を使ってブログを構築する方法を解説します。
## セットアップ
まずはプロジェクトを作成します。
```bash
npx nuxi@latest init my-blog
### コンテンツの取得と表示
```vue
<!-- pages/blog/index.vue -->
<template>
<div>
<h1>ブログ</h1>
<ul>
<li v-for="article in articles" :key="article._path">
<NuxtLink :to="article._path">
<h2>{{ article.title }}</h2>
<p>{{ article.description }}</p>
<time>{{ formatDate(article.date) }}</time>
<div>
<span v-for="tag in article.tags" :key="tag">
{{ tag }}
</span>
</div>
</NuxtLink>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
// queryContent でコンテンツを取得
const { data: articles } = await useAsyncData('blog-list', () =>
queryContent('/blog')
.where({ draft: { $ne: true } }) // 下書きを除外
.sort({ date: -1 }) // 最新順
.only(['title', 'description', 'date', 'tags', '_path']) // 必要なフィールドのみ
.find()
)
const formatDate = (date: string) =>
new Intl.DateTimeFormat('ja-JP').format(new Date(date))
</script>
<!-- pages/blog/[...slug].vue -->
<template>
<article>
<header>
<h1>{{ page?.title }}</h1>
<time>{{ formatDate(page?.date) }}</time>
</header>
<!-- ContentDoc でマークダウンをHTMLとして表示 -->
<ContentDoc />
<!-- 目次の表示 -->
<nav v-if="page?.body?.toc?.links?.length">
<h2>目次</h2>
<ul>
<li
v-for="link in page.body.toc.links"
:key="link.id"
>
<a :href="`#${link.id}`">{{ link.text }}</a>
</li>
</ul>
</nav>
</article>
</template>
<script setup lang="ts">
const { page } = useContent()
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: '記事が見つかりません' })
}
// SEO設定
useSeoMeta({
title: page.value.title,
description: page.value.description,
ogImage: page.value.image,
})
const formatDate = (date?: string) => {
if (!date) return ''
return new Intl.DateTimeFormat('ja-JP').format(new Date(date))
}
</script>
12. SEO最適化
useSeoMeta
// composables/useSeo.ts
export interface SeoOptions {
title: string
description: string
image?: string
url?: string
type?: 'website' | 'article'
publishedTime?: string
author?: string
}
export const useSeo = (options: SeoOptions) => {
const config = useRuntimeConfig()
const route = useRoute()
const fullTitle = `${options.title} | MyApp`
const canonicalUrl = `${config.public.siteUrl}${route.path}`
useSeoMeta({
title: fullTitle,
description: options.description,
// Open Graph
ogTitle: fullTitle,
ogDescription: options.description,
ogImage: options.image || `${config.public.siteUrl}/og-image.png`,
ogUrl: canonicalUrl,
ogType: options.type || 'website',
// Twitter Card
twitterCard: 'summary_large_image',
twitterTitle: fullTitle,
twitterDescription: options.description,
twitterImage: options.image,
// 記事の場合
articlePublishedTime: options.publishedTime,
articleAuthor: options.author,
})
// canonical URL
useHead({
link: [
{ rel: 'canonical', href: canonicalUrl },
],
})
}
JSON-LD構造化データ
// composables/useStructuredData.ts
export const useArticleSchema = (article: {
title: string
description: string
publishedAt: string
updatedAt?: string
author: { name: string; url?: string }
image?: string
url: string
}) => {
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.description,
datePublished: article.publishedAt,
dateModified: article.updatedAt || article.publishedAt,
author: {
'@type': 'Person',
name: article.author.name,
url: article.author.url,
},
image: article.image,
url: article.url,
publisher: {
'@type': 'Organization',
name: 'MyApp',
logo: {
'@type': 'ImageObject',
url: 'https://myapp.com/logo.png',
},
},
}),
},
],
})
}
サイトマップの生成
npm install nuxt-simple-sitemap
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-simple-sitemap'],
sitemap: {
hostname: 'https://myapp.com',
// 動的ルートを含める
sources: ['/api/__sitemap__/urls'],
},
})
// server/api/__sitemap__/urls.ts
export default defineEventHandler(async () => {
const posts = await fetchAllPosts()
return posts.map((post) => ({
loc: `/blog/${post.slug}`,
lastmod: post.updatedAt,
changefreq: 'weekly',
priority: 0.8,
}))
})
13. テスト(Vitest・Nuxt Testing Utils)
テスト環境のセットアップ
npm install -D @nuxt/test-utils vitest @vue/test-utils happy-dom
// vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
environmentOptions: {
nuxt: {
// テスト用Nuxt設定
rootDir: '.',
},
},
},
})
コンポーネントテスト
// tests/components/ProductCard.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import ProductCard from '~/components/ProductCard.vue'
describe('ProductCard', () => {
const mockProduct = {
id: 1,
name: 'テスト商品',
price: 1980,
imageUrl: '/images/test.jpg',
stock: 10,
}
it('商品名と価格が正しく表示される', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: mockProduct },
})
expect(wrapper.text()).toContain('テスト商品')
expect(wrapper.text()).toContain('¥1,980')
})
it('在庫切れの場合は購入ボタンが無効化される', async () => {
const outOfStockProduct = { ...mockProduct, stock: 0 }
const wrapper = await mountSuspended(ProductCard, {
props: { product: outOfStockProduct },
})
const button = wrapper.find('[data-testid="buy-button"]')
expect(button.attributes('disabled')).toBeDefined()
})
it('カートに追加ボタンがクリックされたときイベントが発行される', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: mockProduct },
})
await wrapper.find('[data-testid="buy-button"]').trigger('click')
expect(wrapper.emitted('add-to-cart')).toBeTruthy()
expect(wrapper.emitted('add-to-cart')?.[0]).toEqual([mockProduct])
})
})
Composablesのテスト
// tests/composables/useCart.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from '~/stores/useCartStore'
describe('useCartStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
const mockProduct = {
id: 1,
name: 'テスト商品',
price: 1000,
imageUrl: '/test.jpg',
}
it('商品をカートに追加できる', () => {
const store = useCartStore()
store.addItem(mockProduct)
expect(store.items).toHaveLength(1)
expect(store.items[0].quantity).toBe(1)
expect(store.itemCount).toBe(1)
})
it('同じ商品を追加すると数量が増える', () => {
const store = useCartStore()
store.addItem(mockProduct)
store.addItem(mockProduct)
expect(store.items).toHaveLength(1)
expect(store.items[0].quantity).toBe(2)
expect(store.itemCount).toBe(2)
})
it('合計金額が正しく計算される', () => {
const store = useCartStore()
store.addItem(mockProduct)
store.addItem({ ...mockProduct, id: 2, price: 2000 })
expect(store.subtotal).toBe(3000)
expect(store.total).toBe(3000)
})
it('商品を削除できる', () => {
const store = useCartStore()
store.addItem(mockProduct)
store.removeItem(mockProduct.id)
expect(store.items).toHaveLength(0)
expect(store.isEmpty).toBe(true)
})
})
APIルートのテスト
// tests/server/api/posts.test.ts
import { describe, it, expect, vi } from 'vitest'
import { setup, $fetch } from '@nuxt/test-utils/e2e'
describe('Posts API', async () => {
await setup({
server: true,
})
it('GET /api/posts が記事一覧を返す', async () => {
const response = await $fetch('/api/posts')
expect(response).toHaveProperty('posts')
expect(response).toHaveProperty('total')
expect(Array.isArray(response.posts)).toBe(true)
})
it('GET /api/posts/:id が正しい記事を返す', async () => {
const response = await $fetch('/api/posts/1')
expect(response).toHaveProperty('id', 1)
expect(response).toHaveProperty('title')
expect(response).toHaveProperty('content')
})
it('存在しない記事IDは404を返す', async () => {
await expect($fetch('/api/posts/999999')).rejects.toMatchObject({
status: 404,
})
})
})
14. デプロイ(Vercel・Netlify・Cloudflare)
Vercelへのデプロイ
VercelはNuxt 3と最も相性の良いホスティングサービスのひとつである。
# Vercel CLIのインストール
npm install -g vercel
# デプロイ
vercel
# 本番環境へのデプロイ
vercel --prod
// vercel.json(オプション設定)
{
"buildCommand": "npm run build",
"devCommand": "npm run dev",
"installCommand": "npm install",
"framework": "nuxtjs",
"regions": ["nrt1"],
"env": {
"API_BASE_URL": "@api-base-url"
}
}
// nuxt.config.ts - Vercel最適化設定
export default defineNuxtConfig({
nitro: {
preset: 'vercel',
},
routeRules: {
// Vercel Edge Networkでキャッシュ
'/api/**': {
headers: {
'cache-control': 's-maxage=60, stale-while-revalidate=3600',
},
},
},
})
Netlifyへのデプロイ
# Netlify CLIのインストール
npm install -g netlify-cli
# ビルドとデプロイ
netlify deploy --build --prod
# netlify.toml
[build]
command = "npm run build"
publish = ".output/public"
[build.environment]
NODE_VERSION = "20"
NITRO_PRESET = "netlify"
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Cloudflare Pagesへのデプロイ
// nuxt.config.ts - Cloudflare設定
export default defineNuxtConfig({
nitro: {
preset: 'cloudflare-pages',
},
})
# wrangler.toml(Cloudflare Workers設定)
name = "my-nuxt-app"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]
[build]
command = "npm run build"
[site]
bucket = ".output/public"
Docker を使ったセルフホスティング
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.output ./.output
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
# docker-compose.yml
version: '3.8'
services:
nuxt-app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=${DATABASE_URL}
- API_SECRET=${API_SECRET}
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
CI/CD パイプライン(GitHub Actions)
# .github/workflows/deploy.yml
name: Deploy to Vercel
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run type check
run: npm run typecheck
- name: Run tests
run: npm run test
- name: Build
run: npm run build
env:
API_BASE_URL: ${{ secrets.API_BASE_URL }}
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
パフォーマンス最適化
レイジーローディングとコード分割
<!-- components/HeavyComponent.vue の遅延読み込み -->
<template>
<div>
<!-- ClientOnly でクライアントサイドのみレンダリング -->
<ClientOnly>
<LazyHeavyChart :data="chartData" />
</ClientOnly>
</div>
</template>
<script setup lang="ts">
// Lazy プレフィックスで自動的に遅延読み込み
// コンポーネントを動的にインポートする場合
const HeavyEditor = defineAsyncComponent({
loader: () => import('~/components/HeavyEditor.vue'),
loadingComponent: () => h('div', '読み込み中...'),
errorComponent: () => h('div', '読み込みに失敗しました'),
delay: 200,
timeout: 3000,
})
</script>
キャッシュの設定
// server/api/products/index.get.ts
export default defineEventHandler(async (event) => {
// レスポンスキャッシュを設定
setResponseHeaders(event, {
'cache-control': 'public, max-age=60, stale-while-revalidate=3600',
'cdn-cache-control': 'public, max-age=3600',
})
return fetchProducts()
})
まとめ
本記事では、Nuxt.js 3の以下の機能について解説した。
- アーキテクチャ: Nitroエンジンを中心としたフルスタック設計
- ルーティング:
pages/ディレクトリによる直感的なファイルベースルーティング - レンダリング: SSR・SSG・ISR・SPAのハイブリッドレンダリング
- Composables:
useFetch・useAsyncData・useStateによる状態管理 - Nitroサーバー:
server/api/によるフルスタックAPI実装 - Pinia: Vue 3公式の状態管理ライブラリとの統合
- Nuxt UI: Tailwind CSS製のUIコンポーネントライブラリ
- データフェッチング:
$fetchを中心とした効率的なAPIアクセス - ミドルウェア: 認証・認可・アナリティクスのルートガード
- Nuxt Content: マークダウンを使ったCMSシステム
- SEO:
useSeoMeta・useHead・JSON-LDによる最適化 - テスト: Vitest・Nuxt Testing Utilsによる品質保証
- デプロイ: Vercel・Netlify・Cloudflareへの展開
Nuxt 3は、Vue.js開発者にとって最も生産性の高いフレームワークである。自動インポート・TypeScriptファースト・Nitroエンジンの組み合わせにより、フロントエンドからバックエンドまで一貫した開発体験を提供する。
次のステップ
Nuxt 3の実力をさらに引き出すために、以下のリソースを活用することを推奨する。
また、日々のフロントエンド開発では、様々なツールとのインテグレーションが欠かせない。開発効率を高めるためのツール群をまとめて確認したい場合は、DevToolBox(usedevtools.com) が役立つ。JSON整形・Base64エンコード・正規表現テスト・カラーパレット生成など、Web開発者が日常的に必要とするツールが一か所に集約されており、ブックマークしておくと開発作業がスムーズになる。