PommeIndex は、Apple ハードウェアの仕様データを横断的に集約・表示する Web アプリケーションです。この記事では、全体像からデータモデル、ルーティング、SEO、Server Actions、テスト戦略まで、実装内容をご紹介します。
- 技術スタック: Next.js 15 (App Router), TypeScript, React, MongoDB, Tailwind CSS, Zod
- 特徴: カテゴリ/ファミリー/モデルの3層ルーティング、JSON-LD/OG対応、動的サイトマップ、LLMs 用
llms.txt
、Server Actions での DB 連携
公開ページ:https://apple.gadgetdb.win/
この記事の概要
- Next.js 15 の App Router と Server Actions を核に、MongoDB の非構造化データを Zod で整形・検証して UI に渡します
- ルーティングはカテゴリごとに family/model の規則を切り替え(iPod は専用ロジック)、
routeTable
で一元管理します - SEO は
buildMeta
・JsonLd
・OG 画像・sitemap.ts
/sitemapv2.xml
まで実装し、運用可能な品質に整えています - 検索や絞り込みのユーティリティは
src/components/*
とsrc/lib/*
に整理し、ユニットテストも付けています
技術スタック(詳細)
- フレームワーク/言語/ランタイム
- Next.js 15(App Router)/ React 19 / TypeScript 5.8
- Node.js(LTS)/ ts-node(テスト実行に使用)
-
server-only
によるサーバ境界の明示
- データアクセス/サーバ
- MongoDB Node.js Driver 6.17 / Server Actions
- HMR 耐性のある Mongo クライアント(
src/lib/mongo.ts
,src/lib/mongodb-client.ts
) - Zod(スキーマ/型安全):
FlatDevice
ほかschemas/*
- UI/スタイル/アクセシビリティ
- Tailwind CSS 3.4 / PostCSS / Autoprefixer
-
@tailwindcss/typography
,tailwind-merge
,tailwindcss-animate
- Radix UI(Alert/Dialog/Dropdown/Select/Separator/NavigationMenu 等)
- アイコン:
lucide-react
- コンテンツ/Markdown
-
react-markdown
,remark-gfm
,remark-breaks
,rehype-raw
,rehype-highlight
-
- データ取得/状態管理
- SWR 2.x / Zustand 5.x
- SEO/計測
- JSON-LD(
schema-dts
型付け), Open Graph 画像(Edge Runtime) - サイトマップ(
MetadataRoute
版と XML 直生成版) - GA(
@next/third-parties/google
経由、必要時に有効化)
- JSON-LD(
- ユーティリティ/設計補助
-
class-variance-authority
,clsx
,mustache
- レイアウト/バーチャライズ:
react-window
,react-virtualized-auto-sizer
-
- 品質管理
- ESLint 9(
eslint-config-next
)/ Biome 2(整形) - パスエイリアス:
@/*
→./src/*
(tsconfig.json
)
- ESLint 9(
目次
- 全体アーキテクチャとディレクトリ構成
- データレイヤーとスキーマ設計(Zod)
- ルーティング戦略と URL 設計
- Server Actions とデータ取得フロー
- SEO/OG/サイトマップ/
llms.txt
の実装 - UI 構成とコンポーネント分割
- テスト戦略と実行方法
- 開発フロー・規約・運用 Tips
- クイックスタートと環境変数
1. 全体アーキテクチャとディレクトリ構成
Next.js 15 の App Router を採用しています。ページは src/app
配下に置き、UI は src/components/*
にまとめ、データアクセスやドメインロジックは src/lib/*
に整理しています。記事や画像などのコンテンツは src/article/*
と public/
に分けています。
.
├─ src/
│ ├─ app/ # App Router ルート/メタ/静的ルート
│ │ ├─ (root)/ # トップ領域(page.tsx, _actions/*, OG画像など)
│ │ ├─ api/ # API ルート(※本リポジトリでは最小限)
│ │ ├─ llms.txt/route.ts # LLMs用テキスト生成
│ │ ├─ sitemap.ts # MetadataRouteベースのサイトマップ
│ │ └─ sitemapv2.xml/ # XML直生成のサイトマップ
│ ├─ components/ # UI(ui/ atoms/ layouts/ products/ mac/ ...)
│ ├─ lib/ # ルーティング/スキーマ/SEO/DB/ユーティリティ
│ ├─ article/ # MDXコンテンツ(en/ ja/)
│ ├─ config/ # カテゴリ設定/記事マッピングなど
│ ├─ services/ # サービス層(ファサード)
│ ├─ types/ # 型定義
│ └─ utils/ # 各種補助関数
├─ public/ # 画像などの静的アセット
├─ tests/ # Node+ts-nodeで走るユニットテスト
├─ docs/ # 設計/SEO/検索/仕様のドキュメント
└─ schema/ # RDBスキーマの試作など
ポイント:
- App Router の Server Actions を積極的に活用しています(
src/app/(root)/_actions/*
)。 - Mongo クライアントは HMR(ホットリロード)対応のシングルトンです(
src/lib/mongo.ts
,src/lib/mongodb-client.ts
)。 - ルーティングは
src/lib/routes.ts
とsrc/lib/routes/*
の2系統が並存します(詳細は後述)。 - スキーマは Zod による
FlatDevice
などの型を中心に整備しています。
2. データレイヤーとスキーマ設計(Zod)
非構造化データを整える: FlatDevice
MongoDB から取得する生データ(フィールド構造が不均一)を、UI で扱いやすい形に正規化します。
- 変換先:
src/lib/schemas/deviceFlat.zod.ts
のFlatDeviceZ
- 変換器:
src/app/(root)/_actions/device.ts
のtransformRawToFlat
ポイント:
-
specs
はrecord<string, FlatValue>
で受け、any
は使いません。 - 日付フィールドは ISO 文字列または null に統一します。
- サブファミリー(例: iPhone 16 Pro)は
appleSubfamily
とsubfamilySlug
を抽出・生成します。
// 例: FlatDeviceZ 抜粋
export const FlatDeviceZ = z.object({
_id: z.string(),
source_id: z.string(),
productFamily: z.string(),
modelName: z.string(),
releaseDate: z.string().nullable().optional(),
specs: z.record(z.string(), FlatValueZ),
// ...
}).passthrough()
コレクション定義とカテゴリ対応
src/lib/schemas.ts
に MongoDB コレクション名の対応表を集約しています。
-
COLLECTION_MAPPING
: 製品ファミリー → コレクション -
CATEGORY_COLLECTIONS
: カテゴリ → コレクション配列
これにより、たとえば getModels('iPad')
で iPad 系 4 コレクションを合算取得する、といった集約がシンプルに書けます。
コードサンプル: iPad 集約取得の実際
まず、iPad 系のコレクション一覧はスキーマでこう定義されています。
// src/lib/schemas.ts(抜粋)
export const CATEGORY_COLLECTIONS = {
// ...
ipad: ['ipads', 'ipad_pros', 'ipad_airs', 'ipad_minis'],
// ...
} as const
getModels('iPad')
を呼ぶと、上記 4 コレクションからまとめて取得→フラット化→FlatDevice
へ整形、という流れになります。
// src/app/(root)/_actions/device.ts(抜粋)
export const getModels = cache(async (productLine: string): Promise<FlatDevice[]> => {
const db = await getDb()
// ② iPad トップページ用
if (productLine === 'iPad') {
const ipadCollections = CATEGORY_COLLECTIONS.ipad
const nested = await Promise.all(
ipadCollections.map(c => db.collection<RawDevice>(c).find({}).toArray()),
)
const flat = nested.flat().map(transformRawToFlat)
return flat
}
// ③ 通常 1 コレクション(例: 'iPad Pro' なら 'ipad_pros')
const col = COLLECTION_MAPPING[productLine as ProductFamily]
const docs = await db.collection<RawDevice>(col).find({}).toArray()
return docs.map(transformRawToFlat)
})
利用側は単純に getModels('iPad')
を await するだけです。返り値は iPad / iPad Pro / iPad Air / iPad mini の全モデルが合算された配列になります。
import { getModels } from '@/app/(root)/_actions/device'
export async function loadIpadModels() {
const allIpad = await getModels('iPad')
// 例: 件数や系統別の把握
const byFamily = allIpad.reduce<Record<string, number>>((acc, d) => {
const key = d.productFamily
acc[key] = (acc[key] ?? 0) + 1
return acc
}, {})
return { total: allIpad.length, byFamily }
}
ポイント:
-
'iPad'
は「カテゴリ内の代表キー」として 4 コレクションを合算します。 -
'iPad Pro'
のように「特定の製品ファミリー名」を渡すと、COLLECTION_MAPPING
から 1 コレクションのみを取得します。
3. ルーティング戦略と URL 設計
このプロジェクトの URL は「カテゴリ → ファミリー → モデル」の3層が基本で、カテゴリごとにルールを切り替えます。ルールは routeTable
で一元管理します。
- 実装ファイル:
src/lib/routes.ts
- 役割:
routeTable[category].family(device)
とmodel(device)
で slug を生成 - 例外: iPod は
src/lib/routes/index.ts
側にある専用ロジックでファミリー判定(Form Factor
など)
// 例: 共通ルール(抜粋)
export const routeTable = {
mac: { family: d => kebabCase(d.productFamily ?? 'other'), model: kebabOrSource },
iphone: { family: d => d.subfamilySlug ?? slugify(d.productFamily ?? 'other'), model: kebabOrSource },
ipad: { family: d => kebabCase(d.productFamily ?? 'other'), model: kebabOrSource },
applewatch:{ family: d => d.subfamilySlug ?? slugify(d.productFamily ?? 'other'), model: kebabOrSource },
// ...
}
4. Server Actions とデータ取得フロー
Next.js の Server Actions を使って、DB フェッチと整形をサーバー側で実施し、Page/Server Components にデータを渡します。
-
src/app/(root)/_actions/device.ts
-
getModels(productLine: string)
: カテゴリごとの合算も考慮してドキュメントをFlatDevice
に整形 -
getModel(productLine: string, slug: string)
: 個別モデル取得 -
listSubfamilies(productLine: string)
: サブファミリー一覧(年の集合つき)
-
-
src/app/_actions/homeActions.ts
-
getFeaturedProducts()
: 発売日の降順で代表16件を取得
-
-
src/app/(root)/_actions/db.ts
-
getDatabaseStats()
: 総件数/現行/ディスコンの集計 -
createSearchIndexes()
: 主要フィールドの全文検索インデックスを作成
-
// 使用例: トップページ(抜粋)
export default async function HomePage() {
const [stats, featured] = await Promise.all([
getDatabaseStats().catch(() => ({ total_models: 0 })),
getFeaturedProducts().catch(() => []),
])
// ...
}
Mongo クライアントは HMR 対応のシングルトンとして実装しています。
// src/lib/mongo.ts(抜粋)
const uri = process.env.MONGODB_URI
const client = new MongoClient(uri, { maxPoolSize: 10 })
// dev は global に Promise を保持
補助スクリプト: npm run db:test
で .env.local
を使い Mongo 接続を確認できます(test-mongodb-connection.js
)。URI はログ上で自動マスキングされます。
5. SEO/OG/サイトマップ/llms.txt の実装
メタデータと JSON-LD
-
src/lib/seo/core.ts
のbuildMeta()
でMetadata
を組み立て -
src/lib/seo/json-ld-provider.tsx
の<JsonLd />
で構造化データを埋め込み - トップは
buildHomeJsonLd()
を使用
// src/app/(root)/page.tsx(抜粋)
<JsonLd data={buildHomeJsonLd({ totalModels: stats.total_models })} />
OG 画像(Edge Runtime)
src/app/(root)/opengraph-image.tsx
-
export const runtime = 'edge'
、export const dynamic = 'force-dynamic'
- 背景画像は
public/og-back.png
を絶対URLで参照
サイトマップ
-
src/app/sitemap.ts
:MetadataRoute.Sitemap
で静的生成に近い形で返却 -
routeTable
とgetModels()
を使い、カテゴリ→ファミリー→モデルの URL を列挙
llms.txt
-
src/app/llms.txt/route.ts
: LLMs クローラ向けの方針を出力
6. UI 構成とコンポーネント分割
UI は責務ごとに分割して用意しています。
-
src/components/ui/*
: Button/Card/Input などのプリミティブ -
src/components/layouts/*
:SiteLayout
,ProductCategoryLayout
など -
src/components/products/*
: 製品ページ用の断片(Hero/Specs/Filter など) -
src/components/mac/*
: Mac 系の詳細 UI(YearSection, ModelToolbar 等) -
src/components/category/*
: カテゴリトップのカード/グリッドを構築
Tailwind と Radix UI を適宜活用し、tailwind-merge
でクラス競合を抑えています。レイアウトは SiteLayout
で全体を統制します。
7. テスト戦略と実行方法
ユニットテストは Node で実行し、TypeScript は ts-node/register
でロードします。
-
tests/applyBasicFilters.test.js
: 検索/年/状態のフィルタロジックを検証 -
tests/marketingName.test.js
: マーケティング名の生成ルールを検証
npm test
# => node tests/applyBasicFilters.test.js && node tests/marketingName.test.js
テストは短めですが、データ形状のばらつきに耐えられるユーティリティを重点的に検証しています。
8. 開発フロー・規約・運用 Tips
- フォーマット: Biome(タブ/シングルクォート/幅100)
- 実行例:
npx @biomejs/biome check --apply .
- 実行例:
- Lint:
npm run lint
(Next core-web-vitals) - 命名/配置規約:
- コンポーネント:
PascalCase.tsx
(例:Button.tsx
) - ライブラリ:
camelCase.ts
-
@/*
パスエイリアスでsrc/*
を参照(tsconfig.json
)
- コンポーネント:
- セキュリティ/構成:
-
.env.local
にMONGODB_URI
,MONGODB_DB_NAME
,MONGODB_COLLECTION_NAME
- シークレットはコミット禁止。ログでは自動でマスキング処理
-
- 運用メモ:
-
src/lib/routes.ts
とsrc/lib/routes/*
が並存。import
解決に注意 - 一部
.bk
/.bak
バックアップファイルはビルド対象外
-
付録: ユーティリティ例
マーケティング名生成(marketingName)
src/lib/marketingName.ts
は、Apple Subfamily
・チップ・年を加味して、人が読んでわかりやすいモデル名を組み立てます。iPhone/Watch 系はサブファミリーを優先します。
import { buildMarketingName } from '@/lib/marketingName'
const name = buildMarketingName({
productFamily: 'MacBook Air',
Platform: { 'Processor Type': 'Apple M3' },
releaseDate: '2024-06-01',
})
// => "MacBook Air M3 2024"
まとめ
- 非構造化データを Zod で「使える形」に整えることで、UI 実装をぐっと簡単にできました
- ルーティング規則を
routeTable
に寄せると、カテゴリ固有のロジックも破綻なく拡張できます - Server Actions でのサーバサイド整形と SEO 実装により、運用品質まで意識した構成になっています
PommeIndexの設計や分割は、ほかの「仕様系」データベース(家電/カメラ/PC パーツなど)にも転用しやすいはずです。