はじめに
外部から定期的に提供されるCSVデータを表示するWebアプリケーションを開発する際、多くの開発者が「CSVを取得→DBに蓄積→表示」というアーキテクチャを最初に思い浮かべるのではないでしょうか。
本記事では、1日1回更新という要件において、
DBもBEサーバーも不要という結論に至った経緯と、
Next.js App Routerの実装で遭遇した制限とその解決策について実体験を交えて紹介します。
要件と初期検討
プロジェクト要件
- データ更新頻度: 1日1回
- 予算制約: 運用コストはできる限り抑えたい
- データソース: 外部提供のCSVファイル(約6MB)
検討したアーキテクチャパターン
パターンA: CSV → DB → 表示
外部CSV → 定期取得 → PostgreSQL → Web表示
課題:
- DBホスティング費用(月$15-30程度)
- DBの保守・運用工数
- データ移行処理の実装コスト
パターンB: BEサーバー + fs.readFile
外部CSV → cron取得 → BEサーバー保存 → fs.readFile → Web表示
課題:
- BEサーバーの運用費用
- サーバー保守・運用工数
パターンC: FEのみ(Next.js App Router)
外部CSV → Next.js直接取得・キャッシュ → Web表示
メリット:
- 追加のインフラコストなし
- サーバー保守不要
- シンプルな構成
判断の決め手: 1日1回更新という低頻度であれば、リアルタイム性よりもコスト効率を重視。
チーム内での検討で「DBは不要では?」という意見が出て、
最終的にパターンCを選択しました。
Next.js App Routerの基本機能
App Routerの特徴
Next.js 13以降のApp Routerには、以下のキャッシュ機能が標準で搭載されています:
-
fetchキャッシュ:
fetch()
の結果を自動キャッシュ - 静的生成(SSG): ビルド時またはオンデマンドでページを生成
- オンデマンドISR: APIを使った動的なキャッシュ更新
revalidateオプション
export const revalidate = false; // 完全な静的生成
// または
export const revalidate = 86400; // 24時間後に再生成
最初の実装: シンプルなfetch + キャッシュ
まずは最もシンプルな実装から始めました。
ディレクトリ構成
/app
└── page.tsx ← トップページ (CSVデータ表示)
/app/api
└── revalidate/route.ts ← 再生成API
/lib
└── fetchCsv.ts ← CSVダウンロード + パース関数
/lib/fetchCsv.ts
import Papa from 'papaparse';
export async function fetchCsv(): Promise<any[]> {
const res = await fetch('https://people.sc.fsu.edu/~jburkardt/data/csv/hw_200.csv');
const text = await res.text();
const { data } = Papa.parse(text, {
header: true,
skipEmptyLines: true,
});
return data;
}
/app/page.tsx – 静的ページ本体
import { fetchCsv } from '@/lib/fetchCsv';
export const revalidate = false; // 完全なSSG(静的生成)
export default async function Page() {
const data = await fetchCsv();
return (
<main>
<h1>CSVデータ表示</h1>
<p>データ件数: {data.length}</p>
<table border={1}>
<thead>
<tr>{Object.keys(data[0] || {}).map((key) => <th key={key}>{key}</th>)}</tr>
</thead>
<tbody>
{data.slice(0, 5).map((row, i) => (
<tr key={i}>
{Object.values(row).map((val, j) => <td key={j}>{val}</td>)}
</tr>
))}
</tbody>
</table>
</main>
);
}
/app/api/revalidate/route.ts
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const secret = searchParams.get('secret');
const path = searchParams.get('path') || '/';
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ revalidated: false, message: 'Invalid secret' }, { status: 401 });
}
await revalidatePath(path);
return NextResponse.json({ revalidated: true, path });
}
この実装により、1日1回のAPI呼び出しもしくは画面表示時にデータを更新できる仕組みが完成しました。
壁にぶつかった: fetchキャッシュの2MB制限
小さなサンプルCSVでのテストは順調でしたが、実際の6MBのCSVファイルでテストした際に衝撃的なエラーに遭遇しました。
Error: Failed to set Next.js data cache, items over 2MB can not be cached (6048765 bytes)
Next.jsのfetchキャッシュには2MBという制限があり、6MBのCSVファイルは直接キャッシュできませんでした。
この制限はドキュメントにも明記されていますが、実際に開発を進めて初めて気づく制約でした。
制限の詳細
- fetchキャッシュの上限: 2MB
- 実際のCSVファイル: 約6MB
- 制限超過時: キャッシュされず、毎回外部取得が発生
解決策: unstable_cacheによる整形後キャッシュ
unstable_cacheとは
Next.jsが提供するサーバーサイドキャッシュAPIで、fetchキャッシュとは異なる仕組みでデータをキャッシュできます。
重要な注意点:
- APIが
unstable_
プレフィックス付きのため、将来的な変更の可能性があります - プロダクション使用時は十分な検証が必要です
実装方法
import { unstable_cache } from 'next/cache';
export const fetchAndParseCsv = unstable_cache(
async (csvUrl: string, targetDate: Date): Promise<CsvRow[]> => {
try {
// 1. fetchによる取得
const response = await fetch(csvUrl);
if (!response.ok) {
throw new Error(`Failed to fetch CSV: ${response.status}`);
}
const buffer = await response.arrayBuffer();
const decodedText = iconv.decode(Buffer.from(buffer), 'Shift_JIS');
// 2. CSVパース
const { data, errors } = Papa.parse<CsvRow>(decodedText, {
header: true,
skipEmptyLines: true,
dynamicTyping: false,
});
if (errors.length > 0) {
console.error('CSV parsing errors:', errors);
return [];
}
// 3. 必要な列のみを抽出
const requiredColumns = [
'年月日',
'時刻コード',
// 他の必要な列...
];
// 4. データをフィルタリングして必要な列のみを抽出
const filteredData = data
.filter((row) => row['年月日'] && row['年月日'].trim() !== '')
.map((row) => {
const filteredRow: CsvRow = {};
requiredColumns.forEach((column) => {
filteredRow[column] = row[column] || '';
});
return filteredRow;
});
// 5. 対象月のデータのみをフィルタリング
const targetMonth = targetDate.getMonth();
const targetYear = targetDate.getFullYear();
const monthFilteredData = filteredData.filter((row) => {
const rowDate = new Date(row['年月日'].replace(/\//g, '-'));
return rowDate.getMonth() === targetMonth && rowDate.getFullYear() === targetYear;
});
return monthFilteredData;
} catch (error) {
console.error(`Error in fetchAndParseCsv:`, error);
return [];
}
},
['csv-data'], // キャッシュキーのプレフィックス
{
revalidate: false, // キャッシュの有効期限を無期限に
tags: ['csv-data'], // キャッシュタグ
}
);
ポイント
- 取得後に整形: 生データを取得してから必要な部分のみを抽出
- 列の絞り込み: 6MBの全データから必要な列のみを選択
- 期間の絞り込み: 1年分のデータから表示に必要な期間のみを抽出
- キャッシュタグ: 後からキャッシュクリアが可能
この対応により、キャッシュサイズを大幅に削減し、2MB制限を回避できました。
運用結果
成功実績
- 毎日正しくデータが表示されている
- オンデマンドISRによる1日1回の自動更新が正常動作
- ページ表示速度も良好(初回生成後はキャッシュから高速表示)
コスト効果
- DBホスティング費用: $0(不要)
- BEサーバー費用: $0(不要)
- Vercelの従量課金のみで運用可能
パフォーマンス
- 初回ビルド時: CSVダウンロード・整形が発生
- 通常アクセス: キャッシュから高速表示
- データ更新時: APIトリガーによる再生成
まとめ・学んだこと
アーキテクチャ選択の学び
- 要件に応じた適切な選択: 1日1回更新という低頻度であれば、リアルタイム性よりもシンプルさとコスト効率を重視する判断が有効
- 過剰設計の回避: DBやBEサーバーが「当然必要」と思い込まず、要件を見直すことで大幅なコスト削減が可能
- 段階的な実装: まずシンプルな構成から始めて、制約に応じて調整する開発アプローチの有効性
Next.js App Routerの実践的な制約
- fetchキャッシュの2MB制限: 大きなデータを扱う場合は事前に把握しておくべき重要な制限
- unstable_cacheの活用: 制限回避の有効な手段だが、APIの安定性には注意が必要
- データ整形戦略: キャッシュ前の適切なデータ整形により、パフォーマンスとコストの両立が可能
今後同様のケースに遭遇した場合
- データ更新頻度が低い(1日数回程度)
- コスト効率を重視
という要件であれば、同じアプローチを積極的に検討したいと思います。
一方で、リアルタイム性が重要な場合や、データ量が大幅に増加した場合は、従来のDB + BEサーバー構成への移行も念頭に置いています。
この記事が、同様のCSVデータ表示要件を持つ開発者や、アーキテクチャ選択で悩んでいる方の参考になれば幸いです。特にNext.js App Routerのキャッシュ制限については、事前に知っておくことで開発時の手戻りを防げるかもしれません。