はじめに

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();
};

このアプローチには以下の問題があります:

  1. process.cwd()がビルド環境で異なる: ローカル開発とVercel等のビルド環境では作業ディレクトリが異なる
  2. Nitroのプリレンダリング時にファイルが見つからない : SSG時、Nitroは独自のコンテキストで動作する
  3. 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

結果

この方法で生成された静的サイトでは:

  1. HTMLにデータが埋め込まれる : 初回表示が高速
  2. _payload.jsonにデータが保存される : クライアントサイドナビゲーション時に使用
  3. 元の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を組み合わせるには:

  1. Nitro Storage API を使ってServer APIルートでJSONを読み込む
  2. useAsyncData で必ずラップする
  3. computed で安全にデータにアクセスする
  4. クライアントサイドフォールバック を忘れずに実装する

この方法により、ビルド時にすべてのデータがプリフェッチされ、クライアントサイドでの追加のAPI呼び出しを防ぐことができます。

参考リンク