0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js 15 × MongoDB でつくる Apple 製品DB「PommeIndex」の設計と実装

Last updated at Posted at 2025-08-11

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 は buildMetaJsonLd・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 経由、必要時に有効化)
  • ユーティリティ/設計補助
    • class-variance-authority, clsx, mustache
    • レイアウト/バーチャライズ: react-window, react-virtualized-auto-sizer
  • 品質管理
    • ESLint 9(eslint-config-next)/ Biome 2(整形)
    • パスエイリアス: @/*./src/*tsconfig.json

目次

  1. 全体アーキテクチャとディレクトリ構成
  2. データレイヤーとスキーマ設計(Zod)
  3. ルーティング戦略と URL 設計
  4. Server Actions とデータ取得フロー
  5. SEO/OG/サイトマップ/llms.txt の実装
  6. UI 構成とコンポーネント分割
  7. テスト戦略と実行方法
  8. 開発フロー・規約・運用 Tips
  9. クイックスタートと環境変数

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.tssrc/lib/routes/* の2系統が並存します(詳細は後述)。
  • スキーマは Zod による FlatDevice などの型を中心に整備しています。

2. データレイヤーとスキーマ設計(Zod)

非構造化データを整える: FlatDevice

MongoDB から取得する生データ(フィールド構造が不均一)を、UI で扱いやすい形に正規化します。

  • 変換先: src/lib/schemas/deviceFlat.zod.tsFlatDeviceZ
  • 変換器: src/app/(root)/_actions/device.tstransformRawToFlat

ポイント:

  • specsrecord<string, FlatValue> で受け、any は使いません。
  • 日付フィールドは ISO 文字列または null に統一します。
  • サブファミリー(例: iPhone 16 Pro)は appleSubfamilysubfamilySlug を抽出・生成します。
// 例: 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.tsbuildMeta()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 で静的生成に近い形で返却
  • routeTablegetModels() を使い、カテゴリ→ファミリー→モデルの 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.localMONGODB_URI, MONGODB_DB_NAME, MONGODB_COLLECTION_NAME
    • シークレットはコミット禁止。ログでは自動でマスキング処理
  • 運用メモ:
    • src/lib/routes.tssrc/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 パーツなど)にも転用しやすいはずです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?