はじめに
Nuxt 4でStatic Site Generation (SSG) を行う際、ローカルのJSONファイルからデータを読み込んで静的ページを生成したいケースがあります。しかし、Next.jsのgetStaticPropsのようにシンプルにはいかず、いくつかのハマりポイントがあります。
本記事では、試行錯誤の末に見つけた正しいアプローチを紹介します。
問題:なぜ単純なfsの読み込みでは動かないのか
最初に試したアプローチ(失敗)
// ❌ これは動かない
const fetchLocalData = async (filePath: string) => {
if (import.meta.server) {
const fs = await import('fs');
const path = await import('path');
const fullPath = path.resolve(process.cwd(), 'public/data', filePath);
const data = fs.readFileSync(fullPath, 'utf-8');
return JSON.parse(data);
}
// クライアントサイド
const response = await fetch(`/data/${filePath}`);
return await response.json();
};
このアプローチには以下の問題があります:
process.cwd()がビルド環境で異なる: ローカル開発とVercel等のビルド環境では作業ディレクトリが異なる- Nitroのプリレンダリング時にファイルが見つからない : SSG時、Nitroは独自のコンテキストで動作する
- useAsyncDataなしでは、クライアントサイドでも実行される : SSGの意味がなくなる
解決策:Nitro Storage API + Server APIルート + useAsyncData
アーキテクチャ
[SSGビルド時]
Page Component
↓ useAsyncData
Server API Route (/api/local-data/[...path])
↓ useStorage (Nitro Storage API)
public/data/*.json
[生成されたHTML]
_payload.json にデータが埋め込まれる
→ クライアントサイドでのJSON読み込み不要
Step 1: nuxt.config.tsでserverAssetsを設定
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
prerender: {
crawlLinks: true,
failOnError: false,
},
// Nitro Storage APIでpublic/dataをサーバーアセットとしてマウント
serverAssets: [{
baseName: 'data',
dir: './public/data'
}],
},
routeRules: {
'/**': { prerender: true },
},
});
Step 2: Server APIルートを作成(Nitro Storage + fsフォールバック)
// server/api/local-data/[...path].ts
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
export default defineEventHandler(async (event) => {
const pathParam = getRouterParam(event, 'path');
if (!pathParam) {
throw createError({ statusCode: 400, message: 'Path is required' });
}
const filePath = Array.isArray(pathParam) ? pathParam.join('/') : pathParam;
// セキュリティ: パストラバーサル防止
if (filePath.includes('..')) {
throw createError({ statusCode: 400, message: 'Invalid path' });
}
// Nitro Storage APIを使用
// nuxt.config.tsのserverAssetsで定義した'data'ストレージにアクセス
const storage = useStorage('assets:data');
// ファイルパスをストレージキーに変換(/を:に置換)
const storageKey = filePath.replace(/\//g, ':');
// Storage APIで取得を試みる
if (await storage.hasItem(storageKey)) {
const data = await storage.getItem(storageKey);
return data;
}
// フォールバック: 開発環境向けにfsで直接読み込み
const fsPath = resolve(process.cwd(), 'public/data', filePath);
if (existsSync(fsPath)) {
const data = readFileSync(fsPath, 'utf-8');
return JSON.parse(data);
}
throw createError({ statusCode: 404, message: `File not found: ${filePath}` });
});
Nitro Storage APIを使うメリット:
- Vercel等のビルド環境で安定動作
- OSごとのパスセパレータ問題を回避
- Nitroが環境に応じてファイルアクセスを最適化
fsフォールバックが必要な理由:
開発モード(npm run dev)ではserverAssetsが完全に機能しないケースがあるため、fs直接読み込みをフォールバックとして追加しています。ビルド時にはStorage APIが優先されます。
Step 3: Composableでラップ
// composables/useLocalData.ts
export const useLocalData = () => {
const fetchLocalData = async (filePath: string) => {
try {
if (import.meta.server) {
// SSG/SSR時: Server APIを使用
return await $fetch(`/api/local-data/${filePath}`);
} else {
// クライアントサイド: publicから直接取得
// 静的ホスティング(S3、Netlify等)ではServer APIが存在しないため
const response = await fetch(`/data/${filePath}`);
if (!response.ok) return null;
return await response.json();
}
} catch (error) {
console.error(`Error loading: ${filePath}`, error);
return null;
}
};
return { fetchLocalData };
};
クライアントサイドフォールバックが重要な理由:
SSGで生成した静的サイトをS3やNetlify等にデプロイする場合、Server APIルートは存在しません。そのため、クライアントサイドナビゲーション時には/data/から直接JSONを取得する必要があります。
Step 4: ページコンポーネントでuseAsyncDataを使用
script setup lang="ts">
const { fetchLocalData } = useLocalData();
// 必ずuseAsyncDataでラップする
const { data: pageData } = await useAsyncData('my-page-data', async () => {
const result = await fetchLocalData('jsonapi/node/posts.json');
return result;
});
// computed で安全にアクセス
const posts = computed(() => pageData.value?.data || []);
script>
template>
div v-for="post in posts" :key="post.id">
{{ post.title }}
div>
template>
重要なポイント
1. useAsyncDataは必須
useAsyncDataなしでawaitすると、クライアントサイドでも実行されてしまいます。
// ❌ ダメな例
const data = await fetchLocalData('posts.json');
// ✅ 正しい例
const { data } = await useAsyncData('key', () => fetchLocalData('posts.json'));
2. キーはユニークに
useAsyncDataの第一引数のキーは、ページ/コンポーネント間でユニークにする必要があります。
// ページごとにユニークなキーを使う
const { data } = await useAsyncData(`post-${route.params.id}`, async () => {
return await fetchLocalData(`posts/${route.params.id}.json`);
});
3. computedで安全にアクセス
useAsyncDataの戻り値はRefなので、テンプレートやロジックで使う際はcomputedでラップすると安全です。
const { data: pageData } = await useAsyncData(...);
// ✅ computedでラップ
const title = computed(() => pageData.value?.title || '');
const items = computed(() => pageData.value?.items || []);
4. 変数スコープに注意
useAsyncDataのコールバック内で定義した変数は、コールバック外では使用できません。
// ❌ ダメな例
const { data } = await useAsyncData('key', async () => {
const types = ['a', 'b']; // コールバック内で定義
return await fetchData();
});
const items = ['x', 'y', ...types]; // エラー: types is not defined
// ✅ 正しい例
const types = ['a', 'b']; // コールバック外で定義
const { data } = await useAsyncData('key', async () => {
return await fetchData();
});
const items = ['x', 'y', ...types]; // OK
結果
この方法で生成された静的サイトでは:
- HTMLにデータが埋め込まれる : 初回表示が高速
- _payload.jsonにデータが保存される : クライアントサイドナビゲーション時に使用
- 元のJSONファイルへのリクエストは発生しない : ネットワーク負荷が軽減
.output/public/
├── posts/
│ ├── index.html # プリレンダリングされたHTML
│ └── _payload.json # シリアライズされたデータ (useAsyncDataの結果)
└── data/
└── jsonapi/
└── node/
└── posts.json # 元のJSONファイル(SSG後は不要)
別のアプローチ
小さなJSONファイルの場合: 直接import
ファイルサイズが小さく(50KB以下)、動的なパスが不要な場合は、直接importが最もシンプルです。
// ✅ 小さな静的JSONの場合
import config from '~/assets/data/config.json';
// TypeScriptの型推論も効く
console.log(config.title);
コンテンツ管理が主目的の場合: Nuxt Content
マークダウンやJSONコンテンツを扱うサイトの場合は、Nuxt Contentモジュールが適しています。
// Nuxt Contentを使う場合
const { data } = await useAsyncData('posts', () =>
queryContent('posts').find()
);
まとめ
Nuxt 4でSSGとローカルJSONを組み合わせるには:
- Nitro Storage API を使ってServer APIルートでJSONを読み込む
- useAsyncData で必ずラップする
- computed で安全にデータにアクセスする
- クライアントサイドフォールバック を忘れずに実装する
この方法により、ビルド時にすべてのデータがプリフェッチされ、クライアントサイドでの追加のAPI呼び出しを防ぐことができます。