はじめに
個人開発でバイクポータルサイト MotoHub を運営しています。
前回の記事で「駅別バイク駐車場ランディングページ」の実装について書きました。
この記事では、その駅別LPを30駅→1,400駅に拡大した全記録を書きます。
- なぜ拡大したのか
- どのようにsitemapを設計したか
- 一覧ページのUIをどう工夫したか
- 実装上の判断ポイント
「個人開発でSEOのカバレッジを増やしたい」「UXを崩さず内部リンクを増やしたい」という方の参考になれば幸いです。
拡大前の状態
サイトの構成
/parking/station/ 一覧ページ(主要30駅のみ表示)
/parking/station/tokyo 東京駅の詳細ページ
/parking/station/shinjuku 新宿駅の詳細ページ
...
全国9,032駅のマスタデータはDBにあり、個別URLは全駅でアクセス可能。しかし:
- 一覧ページには主要30駅のみ表示
- sitemapにも主要30駅のみ登録(31 URL)
つまり、9,032駅のマスタデータがあるのに、Google には31URL しか認識させていない状態でした。
なぜ最初は30駅に絞ったのか
実装初期は、以下の理由で意図的に主要駅のみに絞りました。
-
低品質ページ量産によるペナルティ回避
- 駐車場が0件 or 1件の駅を大量公開すると「低品質コンテンツ」と判定されるリスク
-
クロールバジェットの有効活用
- 主要URLにクロールを集中させたい
-
効果検証の段階的実施
- まず30駅で仕組みが機能するか確認
しかし30駅だと、カバレッジが狭すぎるという問題が見えてきました。
課題:9,000駅あるのに31URLだけ?
ユーザー視点の違和感
- 「自分の最寄り駅の情報が見られない」
- 「地方の駅はサポート対象外?」
- 「サイト内回遊で他の駅を発見できない」
SEO視点の機会損失
- 「○○駅 バイク 駐車場」のロングテールクエリを取りこぼしている
- 地方駅からの流入ゼロ
- 内部リンクが少なく、個別ページのページランクが上がりにくい
本番データで計算してみた
駐車場件数ごとに、sitemapに登録できる駅数を集計:
docker compose exec app php artisan tinker --execute="
use App\Models\Station;
foreach ([1, 3, 5, 10, 20] as \$min) {
echo sprintf('%d件以上: %d駅', \$min, Station::has('bikeParkings', '>=', \$min)->count()) . PHP_EOL;
}
"
結果(本番データ):
| 駐車場件数 | 駅数 |
|---|---|
| 1件以上 | 3,783駅 |
| 3件以上 | 2,049駅 |
| 5件以上 | 1,370駅 ← 採用 |
| 10件以上 | 624駅 |
| 20件以上 | 220駅 |
5件以上の駅 = 駐車場を比較検討できる「意味のあるページ」の閾値と判断し、これを採用。
31 URL → 約1,400 URL(主要30駅 + 非主要1,370駅 + 一覧ページ)、44倍のカバレッジ拡大を目指しました。
設計判断①:sitemapに何を入れるか
考慮したポイント
sitemap登録の判断は、以下の要素のバランスで決まります。
| 要素 | 重視すべき時 |
|---|---|
| カバレッジ | 検索流入を最大化したい |
| 品質 | ペナルティリスクを避けたい |
| クロール効率 | クロールバジェットを有効活用したい |
「5件以上」を選んだ理由
カバレッジと品質のバランスが最適だから。
- 5件以上 = ユーザーが駐車場を比較検討できる最低ライン
- 3件だと「選択肢が少ない」と感じさせる
- 10件だとカバレッジが狭すぎる
Phase分けで段階的に拡大
いきなり全駅を公開せず、段階的拡大の設計に。
Phase 1(今回):5件以上(1,370駅)
Phase 2(1ヶ月後):3件以上(2,049駅)
Phase 3(2ヶ月後):1件以上(3,783駅)
各Phaseで GSCのデータを見て、問題なければ次へ。
設計判断②:一覧ページのUIをどうするか
sitemap登録は簡単ですが、一覧ページに1,400駅を表示する方法は一工夫必要です。
候補1: 全駅リスト表示
シンプルに全駅を縦スクロールで表示。
問題点:
- 1,400駅の縦スクロール地獄
- スマホで使い物にならない
- 主要駅がその他駅に埋もれる
候補2: ページネーション
ページ分割して表示。
問題点:
- 内部リンクの効率が悪い(1ページ1クリック先にある)
- ユーザーが1ページ目で離脱
- Google の内部リンク評価も分散
候補3: 二階層 + アコーディオン(採用)
主要30駅は常時表示、その他駅は都道府県別にアコーディオン。
メリット:
- 主要駅の目立たせが可能
- その他駅も発見可能(クリックで展開)
- スマホでも使いやすい
- 全駅への内部リンクが1ページに存在
デメリット:
- JavaScript必須
- 初期表示の駅数は抑制
採用したUI構造
【主要駅セクション】
[東京] [新宿] [渋谷] [池袋] [上野] ...(30駅、常時表示)
【その他の駅セクション】
駐車場5件以上の駅が1,370駅あります。都道府県別に見る:
▼ 東京都(450駅) ← クリックで展開
[初台] [新宿御苑前] [四谷三丁目] ...
▼ 神奈川県(120駅) ← デフォルト折りたたみ
...
▼ 埼玉県(90駅)
...
実装
ステップ1: StationParkingService の拡張
既存のサービスクラスに、駅一覧取得メソッドを追加。
// app/Services/Parking/StationParkingService.php
public function getAllStationsForIndex(): array
{
return Cache::remember('stations_index', 86400, function () {
$allStations = $this->getSitemapStations(5); // 既存メソッドを再利用
return [
'major' => $allStations->where('is_major', true)
->groupBy('prefecture'),
'others' => $allStations->where('is_major', false)
->groupBy('prefecture'),
];
});
}
ポイント: 既に実装済みの getSitemapStations(5) を再利用。DRY原則を守りつつ、sitemapと一覧ページでデータの整合性を担保。
ステップ2: Controller の修正
// app/Http/Controllers/Parking/StationParkingController.php
public function index(StationParkingService $service)
{
$stations = $service->getAllStationsForIndex();
return view('parking.station-index', compact('stations'));
}
ステップ3: Blade テンプレート
{{-- resources/views/parking/station-index.blade.php --}}
<section class="major-stations">
<h2>主要30駅(すぐ見れる)</h2>
@foreach($stations['major'] as $prefecture => $list)
<div class="prefecture-group">
<h3>{{ $prefecture }}</h3>
@foreach($list as $station)
<a href="{{ route('parking.station.show', $station->slug) }}"
class="station-chip">
{{ $station->name }}駅
</a>
@endforeach
</div>
@endforeach
</section>
<section class="other-stations">
<h2>その他の駅(駐車場5件以上)</h2>
<p>下記の駅にも駐車場情報が充実しています。</p>
@foreach($stations['others'] as $prefecture => $list)
<details class="prefecture-accordion">
<summary>
▼ {{ $prefecture }}({{ $list->count() }}駅)
</summary>
<div class="stations-grid">
@foreach($list as $station)
<a href="{{ route('parking.station.show', $station->slug) }}">
{{ $station->name }}駅
</a>
@endforeach
</div>
</details>
@endforeach
</section>
ポイント: HTML5 の <details> / <summary> タグを使えば、JavaScriptなしで折りたたみUI が実現できる。余計なライブラリを入れなくて済む。
ステップ4: sitemap の拡張
GenerateSitemap.php を修正し、5件以上の駅を sitemap に含める。
// app/Console/Commands/GenerateSitemap.php
private function getStationsForSitemap()
{
return app(StationParkingService::class)->getSitemapStations(5);
}
パフォーマンス対策
課題:1,400駅×空間インデックス計算は重い
駅と駐車場の紐付けを毎回計算すると、本番で 0.5〜1秒かかる。アクセスが集中するとDB負荷が跳ね上がる。
対策:24時間キャッシュ
Cache::remember('stations_index', 86400, function () {
// 重い処理
});
- 初回アクセス:0.53秒
- 2回目以降:キャッシュから0.01秒
駅と駐車場の紐付けは頻繁に変わらないので、24時間キャッシュで十分。
補助:グリッドベース空間インデックス
1,400駅 × 40,000駐車場の総当たりは現実的じゃないので、緯度経度をグリッドに分割して検索範囲を絞る方式を採用。
// 対象駅の500m圏の駐車場だけ取得
$parkings = BikeParking::inBoundingBox(
$station->latitude,
$station->longitude,
0.5 // km
)->get();
これで計算量が O(N*M) → O(N+k) に削減。
SEO効果の測定(予測)
期待される変化
| 指標 | Before | 予測 After |
|---|---|---|
| sitemap URL数 | 31 | 約1,400 |
| 内部リンク数 | 約30 | 約1,400 |
| インデックス登録URL | ~20 | ~1,000 |
| 「○○駅 バイク 駐車場」系流入 | 主要都市のみ | 全国対応 |
想定する流入増
1〜2ヶ月後に、GSC データを見ることで実測値を取得予定です。
個人開発としての学び
① 「量」と「質」のバランス設計
最初から全駅を公開するのは簡単。でも品質を担保する閾値を設けることで、Google に信頼されるサイトを維持できます。
「駐車場5件以上」というゲートは、ユーザーにもGoogleにも「意味のあるページだけ」という信頼を与えます。
② 既存コードを再利用
sitemapと一覧ページで、同じデータソース(getSitemapStations(5)) を使用。データの整合性を保ちつつ、メンテナンスコストを削減。
③ HTML5 ネイティブ機能の活用
<details> / <summary> でアコーディオンUIを実現。JavaScriptのライブラリを増やさない判断。
④ Phase 分けで段階的拡大
一度に全部公開するのではなく、Phase 1 → GSC観察 → Phase 2 とステップを踏むことで、リスクを最小化。
まとめ
個人開発でも、設計の工夫次第でSEOは劇的に改善できます。
今回の実装では:
- sitemapの品質ゲート(駐車場5件以上)
- UI の情報階層設計(主要駅+アコーディオン)
- 既存コードの再利用(DRY原則)
- パフォーマンス対策(24時間キャッシュ)
- 段階的Phase展開(Phase 1→2→3)
この5つのポイントで、31URL → 1,400URL の拡大を実現しました。
個人開発者でも、大規模なSEO施策は可能です。データと設計があれば。
参考リンク
- MotoHub - バイクポータルサイト
- 駅別LPの例: 東京駅周辺のバイク駐車場
- 一覧ページ: 駅から探すバイク駐車場
出典
本記事で紹介したサービスの駅情報は、国土交通省「国土数値情報(鉄道駅データ)」(https://nlftp.mlit.go.jp/)を加工して利用しています。






