Claude Codeで地図を組み込む:APIキー漏れと課金事故を避けるGoogle Maps実装
「地図を入れて」で済ませると後でキー漏れと課金で痛い目を見ます。店舗検索を例に、APIキー分離・マーカー・料金対策をNext.js実装で解説。
「店舗検索ページに地図を入れて」。Claude Codeにそう頼んだら、5分で地図が出ました。マーカーも刺さってる。やった、と思った。
その月末、Google Cloudから「予算アラート」のメールが来ました。テスト中にAPIキーをそのままGitHubに上げていて、知らない誰かが叩いていたんです。幸い少額で済みましたが、背筋が凍りました。
地図は「表示」が一番カンタンで、「運用」が一番こわい。今日はその落とし穴を、僕がハマった順に潰していきます。
この記事の要点
- 地図は表示そのものより、APIキー・課金・住所検索・現在地・表示速度を先に設計したほうが事故らない
- ブラウザに出すキー(
NEXT_PUBLIC_...)とサーバー専用キーは必ず分ける。Geocodingはサーバールートに逃がす - マーカーは古い
Markerではなく**AdvancedMarkerElement+マップID**。Googleが今これを推奨している - Next.jsは
google.maps.Mapをトップレベルに書くとgoogle is not definedで死ぬ。"use client"+useEffectで読む - 独自データを大量に重ねる・地図スタイルを作り込むならMapbox GL JSも候補。役割で使い分ける
「地図を入れて」だけだと、何がまずいのか
Claude Codeに「Google Mapsを入れて」と頼むと、地図は本当に出ます。ここは速い。問題は、出た瞬間に「できた」と勘違いすることです。
本番アプリで差がつくのは、地図の見た目ではありません。APIキーをどこに置くか、誰がいくら払うのか、住所検索で同じ地名の候補が複数出たらどうするか、ユーザーが現在地の許可を拒否したら画面が壊れないか。このあたりを先に決めているかどうかで、3か月後の自分の機嫌が変わります。
僕が店舗検索の試作で最初につまずいたのは、実はマーカーじゃなくて住所検索でした。住所を緯度経度に変換する「ジオコーディング」は便利なんですが、「中央区」とだけ打つと候補がいくつも返ってくる。しかもこの処理に使うキーをブラウザ側に置くか、サーバー側に置くかを最初に分けておかないと、あとでセキュリティと費用の両方をまとめて直す羽目になります。これが地味にしんどい。
だからこの記事では、Claude Codeを「地図ウィジェットを貼る係」ではなく「地図機能の実装パートナー」として動かします。対象はNext.js + React。Google Maps JavaScript API、Advanced Marker、Geocoding API、そしてMapbox GL JSの使い分けまで扱います。
キーの守り方そのものはClaude Codeでセキュリティ監査を実務化する方法、地図が重いときの対処はClaude Codeでパフォーマンス最適化、地点データの見せ方はClaude Codeのデータ可視化が近いので、合わせて読むと早いです。
最初に渡すのは「ライブラリ名」じゃなく「運用条件」
Claude Codeに地図を頼むとき、いきなり「Google Maps使って」だけ渡すと、たいてい一番ラクなサンプル(古いMarker、キー直書き)が返ってきます。地図はユーザー体験と費用が直結するので、先に制約を言葉にして渡したほうがいい。
僕はこういうブリーフを最初に投げます。
Next.js App Routerで店舗検索ページを実装してください。
要件:
- Google Maps JavaScript APIを使う
- 従来のMarkerではなくAdvancedMarkerElementを使う
- APIキーはHTTP referrer制限を前提にNEXT_PUBLIC_GOOGLE_MAPS_API_KEYで読む
- Geocoding APIはサーバールートから呼び、GOOGLE_MAPS_SERVER_KEYをブラウザに出さない
- 住所検索、店舗リスト、マーカークリック、選択状態を同期する
- SSRでwindow/googleを参照しない
- エラー、ローディング、0件、権限拒否を画面状態として扱う
- 実装後にAPIキー制限、課金アラート、利用規約の確認項目を出す
ポイントは「やってほしいこと」だけでなく「やってはいけないこと」を入れること。safePathの門番と同じで、禁止を明示するとAIの暴走が減ります。
全体像も先に共有しておくと、Claude Codeの出力をレビューしやすくなります。誰がどのキーでどこを叩くのか、図にしておくとレビューの目線が揃います。
flowchart LR
User["ユーザー"]
Page["店舗検索ページ"]
Map["Google Maps JS API"]
Route["/api/geocode"]
Google["Geocoding API"]
Store["店舗DBまたはJSON"]
Alerts["課金アラートとログ"]
User --> Page
Page --> Map
Page --> Store
Page --> Route
Route --> Google
Route --> Alerts
地図まわりは用語がカタカナだらけになります。レビューに非エンジニアが入るなら、「ジオコーディング=住所を緯度経度に変換すること」「逆ジオコーディング=緯度経度から住所を推定すること」「マップID=Google Cloud Consoleで作る、地図スタイルとAdvanced Marker用の識別子」くらいは最初に言い換えておくと話が通じます。
Google MapsをNext.jsで読み込む
まず依存を入れます。型定義を入れておくと、Claude Codeが存在しないプロパティを使ったときにすぐ気づけます。
npm i @googlemaps/js-api-loader
npm i -D @types/google.maps
Maps JavaScript APIのAdvanced MarkerにはマップIDが必要です。開発中はDEMO_MAP_IDでも動きますが、本番ではGoogle Cloud Consoleで作ったIDを使います。APIキーはどうしてもフロントに出ます——これはMaps JavaScript APIの性質上、避けられません。だからこそGoogle Maps Platformのセキュリティガイダンスに従って、HTTP referrer制限とAPI制限を必ずかけます。ここを省くと、冒頭の僕みたいに知らない請求が来ます。
ローダーはこんな形です。Promiseを1回だけ生成して使い回すのがコツです。
// src/lib/google-maps-loader.ts
import { Loader } from "@googlemaps/js-api-loader";
let googleMapsPromise: Promise<typeof google> | null = null;
export function loadGoogleMaps() {
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
if (!apiKey) {
throw new Error("NEXT_PUBLIC_GOOGLE_MAPS_API_KEY is missing");
}
if (!googleMapsPromise) {
const loader = new Loader({
apiKey,
version: "weekly",
libraries: ["marker", "places"],
});
googleMapsPromise = loader.load();
}
return googleMapsPromise;
}
次が地図コンポーネント本体。覚えるべきは3つだけです。SSR中にwindowやgoogleを触らないこと、マーカーを描き直すときに古いマーカーを必ず消すこと、ユーザー由来のHTMLをInfoWindowに文字列連結しないこと(XSSになります)。
// src/components/GoogleBusinessMap.tsx
"use client";
import { useEffect, useRef } from "react";
import { loadGoogleMaps } from "@/lib/google-maps-loader";
export type MapPoint = {
id: string;
title: string;
lat: number;
lng: number;
category?: "store" | "warehouse" | "property";
};
type Props = {
points: MapPoint[];
center: google.maps.LatLngLiteral;
zoom?: number;
onSelect?: (point: MapPoint) => void;
};
export function GoogleBusinessMap({ points, center, zoom = 13, onSelect }: Props) {
const mapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
let markers: google.maps.marker.AdvancedMarkerElement[] = [];
async function renderMap() {
await loadGoogleMaps();
if (!mapRef.current || cancelled) return;
const { Map } = (await google.maps.importLibrary("maps")) as google.maps.MapsLibrary;
const { AdvancedMarkerElement, PinElement } = (await google.maps.importLibrary(
"marker",
)) as google.maps.MarkerLibrary;
const map = new Map(mapRef.current, {
center,
zoom,
mapId: process.env.NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID ?? "DEMO_MAP_ID",
fullscreenControl: false,
gestureHandling: "cooperative",
});
markers = points.map((point, index) => {
const pin = new PinElement({
glyph: String(index + 1),
background: point.category === "warehouse" ? "#0f766e" : "#2563eb",
borderColor: "#ffffff",
glyphColor: "#ffffff",
});
const marker = new AdvancedMarkerElement({
map,
position: { lat: point.lat, lng: point.lng },
title: point.title,
content: pin.element,
});
marker.addListener("click", () => onSelect?.(point));
return marker;
});
}
renderMap().catch((error) => {
console.error("Failed to render Google Map", error);
});
return () => {
cancelled = true;
markers.forEach((marker) => {
marker.map = null;
});
};
}, [center.lat, center.lng, points, zoom, onSelect]);
return <div ref={mapRef} className="h-[420px] w-full rounded-lg border" />;
}
クリーンアップでmarker.map = nullを回している部分、最初の僕は書いていませんでした。検索のたびにマーカーが残って二重三重に重なり、地図が一面ピンだらけになって初めて気づいた、というオチです。
住所検索はサーバールートに逃がす
ここが今日一番大事なところです。ブラウザ用のキーと、サーバーからGeocoding APIを呼ぶキーは分けます。前者はHTTP referrer制限、後者はIP制限や実行環境に合わせた制限をかける。同じキーを使い回すと、片方が漏れたときに全部巻き添えになります。
Geocodingはサーバールートに置きます。レスポンス形式やステータスはGeocoding APIの公式リファレンスで確認できます。
// src/app/api/geocode/route.ts
import { NextResponse } from "next/server";
type GeocodeResponse = {
status: string;
error_message?: string;
results: Array<{
formatted_address: string;
place_id: string;
geometry: {
location: { lat: number; lng: number };
};
}>;
};
const endpoint = "https://maps.googleapis.com/maps/api/geocode/json";
export async function GET(request: Request) {
const key = process.env.GOOGLE_MAPS_SERVER_KEY;
const { searchParams } = new URL(request.url);
const address = searchParams.get("address")?.trim();
if (!key) {
return NextResponse.json({ error: "Server key is missing" }, { status: 500 });
}
if (!address || address.length > 180) {
return NextResponse.json({ error: "Address is required" }, { status: 400 });
}
const params = new URLSearchParams({
address,
key,
language: "ja",
region: "jp",
});
const response = await fetch(`${endpoint}?${params}`, { cache: "no-store" });
const data = (await response.json()) as GeocodeResponse;
const first = data.results[0];
if (!response.ok || data.status !== "OK" || !first) {
return NextResponse.json(
{ error: data.error_message ?? data.status },
{ status: data.status === "ZERO_RESULTS" ? 404 : 502 },
);
}
return NextResponse.json({
formattedAddress: first.formatted_address,
placeId: first.place_id,
location: first.geometry.location,
});
}
落とし穴はキャッシュです。住所→緯度経度の変換結果をどこまで保存していいかは、Google Maps Platformの利用規約とプロダクトごとのポリシー次第。「料金が惜しいから」という理由だけで長期保存を入れるのは危ない。店舗マスタの座標は自社データとして持ち、ユーザーが打った住所検索は必要な範囲だけ扱う——この線引きを最初に決めておくと、あとで揉めません。
店舗検索UIをつなぐ
最後に、地図・店舗リスト・住所検索を1つにまとめます。Claude Codeにここまで作らせたら、こういう単位でコンポーネントを切るとレビューがラクです。fetchしてsetCenterするだけのシンプルな結線にしてあります。
// src/components/StoreLocator.tsx
"use client";
import { useMemo, useState } from "react";
import { GoogleBusinessMap, type MapPoint } from "@/components/GoogleBusinessMap";
type Store = {
id: string;
name: string;
address: string;
hours: string;
phone: string;
position: google.maps.LatLngLiteral;
};
type GeocodeResult = {
formattedAddress: string;
placeId: string;
location: google.maps.LatLngLiteral;
};
const tokyoStation = { lat: 35.681236, lng: 139.767125 };
export function StoreLocator({ stores }: { stores: Store[] }) {
const [query, setQuery] = useState("");
const [center, setCenter] = useState(stores[0]?.position ?? tokyoStation);
const [selectedId, setSelectedId] = useState(stores[0]?.id ?? "");
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
const points = useMemo<MapPoint[]>(
() =>
stores.map((store) => ({
id: store.id,
title: store.name,
lat: store.position.lat,
lng: store.position.lng,
category: "store",
})),
[stores],
);
async function searchAddress() {
if (!query.trim()) return;
setStatus("loading");
const response = await fetch(`/api/geocode?address=${encodeURIComponent(query)}`);
if (!response.ok) {
setStatus("error");
return;
}
const result = (await response.json()) as GeocodeResult;
setCenter(result.location);
setStatus("idle");
}
return (
<section className="grid gap-4 lg:grid-cols-[320px_1fr]">
<aside className="space-y-3">
<div className="flex gap-2">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && searchAddress()}
placeholder="駅名・住所で検索"
className="min-w-0 flex-1 rounded-md border px-3 py-2"
/>
<button onClick={searchAddress} className="rounded-md bg-blue-600 px-4 py-2 text-white">
検索
</button>
</div>
{status === "error" && <p className="text-sm text-red-600">住所を見つけられませんでした。</p>}
<div className="max-h-[420px] space-y-2 overflow-auto">
{stores.map((store) => (
<button
key={store.id}
onClick={() => {
setSelectedId(store.id);
setCenter(store.position);
}}
className={`w-full rounded-md border p-3 text-left ${
selectedId === store.id ? "border-blue-500 bg-blue-50" : "bg-white"
}`}
>
<span className="block font-medium">{store.name}</span>
<span className="block text-sm text-gray-600">{store.address}</span>
<span className="block text-sm text-gray-500">{store.hours}</span>
</button>
))}
</div>
</aside>
<GoogleBusinessMap
points={points}
center={center}
zoom={14}
onSelect={(point) => setSelectedId(point.id)}
/>
</section>
);
}
これはコピペの土台として動きます。ただ実プロジェクトでは、CSSクラス、店舗データの取得、フォームのアクセシビリティ、エラー表示をデザインシステムに寄せてください。土台のまま本番に出すと、だいたいスマホで崩れます(経験談)。
Mapboxを選んだほうがいいケース
Googleが万能、というわけではありません。店舗検索・経路案内・住所検索・プレイス情報ならGoogle Mapsが強い。でも、独自データを大量に重ねる、ブランドに合わせて地図スタイルを細かく作り込む、WebGLで地理データをぬるぬる動かす——こういう要件ならMapbox GL JSが候補に入ります。基本はMapbox GL JSのガイドが分かりやすいです。
迷ったら、この表で当ててみてください。
| 観点 | Google Maps | Mapbox GL JS |
|---|---|---|
| 店舗検索 | PlacesやGeocodingと組み合わせやすい | 自前データ中心なら柔軟 |
| 見た目の自由度 | Cloud Stylingで調整 | スタイル設計の自由度が高い |
| 学習コスト | 情報が多く導入しやすい | レイヤー・ソース・タイルの理解が必要 |
| 注意点 | APIキー制限と課金監視が必須 | トークン制限と帰属表示が必須 |
Mapboxの最小コードはこのくらい。トークンとmapbox-gl.cssの読み込みを忘れるとタイルが崩れます。
// src/components/MapboxPreview.tsx
"use client";
import { useEffect, useRef } from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
export function MapboxPreview() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN;
if (!containerRef.current || !token) return;
mapboxgl.accessToken = token;
const map = new mapboxgl.Map({
container: containerRef.current,
style: "mapbox://styles/mapbox/streets-v12",
center: [139.767125, 35.681236],
zoom: 12,
});
map.addControl(new mapboxgl.NavigationControl(), "top-right");
return () => map.remove();
}, []);
return <div ref={containerRef} className="h-[420px] w-full rounded-lg border" />;
}
Claude Codeには「Google版とMapbox版を両方作って」と丸投げするより、「住所検索はGoogle、独自レイヤーの可視化はMapbox」と役割を切って頼むほうが失敗しません。
効く場面:4つのユースケース
1. 店舗・施設検索 支店、クリニック、教室、イベント会場。住所入力、現在地、営業時間、電話、予約CTAが効きます。僕の試作では最初に地図を大きく出しすぎて、スマホで店舗リストが見えなくなりました。「地図5割・リスト5割」より、スマホはリストを先に出して、選んだら地図に寄せるほうが使いやすい場面が多いです。
2. 不動産・宿泊施設の検索 価格、駅距離、面積、空室と地図を同期します。ここはマーカーを全部出すより、ズームに応じてクラスタリングし、リストの並びと地図の表示範囲を一致させるのが肝。Claude Codeには「地図に見えている物件だけ一覧に出す」と明示します。
3. 配送・訪問・保守の管理画面 見た目より、更新頻度と権限が大事。ドライバーの現在地をリアルタイム表示するなら、位置情報の同意・保存期間・閲覧権限・異常時のログを先に決めます。ルート最適化までやるなら、Directions APIやRoutes APIの費用と制限も確認してから。
4. 記事・観光コンテンツの地図化 旅行記事や地域ガイドに地点を付けると回遊率が上がります。ただし地図だけ置くと本文が薄くなってSEOが死ぬので、現地での判断材料・行き方・注意点は本文に残します。
僕がハマった落とし穴
正直に並べます。地図は同じところで皆コケます。
制限なしのキーを公開した。冒頭の課金事故がこれです。ブラウザに出るMaps JS用キーでも、HTTP referrer制限とAPI制限をかければ被害はかなり抑えられます。サーバーから呼ぶキーは、そもそもブラウザに出さない。
古いMarkerをそのまま採用した。GoogleはAdvanced Markerを推奨していて、Advanced Markerの公式ガイドではAdvancedMarkerElementとマップIDを使う流れになっています。Claude Codeが古いサンプルを出したら、迷わず置き換えさせます。
SSRで死んだ。google.maps.Mapをトップレベルに書いてgoogle is not defined。地図は"use client"にしてuseEffect内でロードします。
住所検索が曖昧だった。「中央区」「銀座」だけでは複数候補が返る。regionやlanguage、都道府県の入力補助を入れ、曖昧ならユーザーに選ばせる。住所をDBの主キーにしないのも鉄則です。
全件描画で重くなった。マーカーを一度に大量に出すと地図もリストも固まります。100件を超えたらクラスタリング、表示範囲内だけ取得、ページングを検討。Claude Codeには「最初から全件描画しない」と書いておきます。
最後に、レビュー用のチェックリストです。実装が出てきたら、これをそのままClaude Codeに渡して「この観点でレビューして修正パッチを出して」と頼むと、表示されるだけの実装が一気に本番に近づきます。
- APIキーが
.env.localから読まれているか - ブラウザ用キーとサーバー用キーが分かれているか
- API制限とアプリケーション制限を設定する手順が残っているか
AdvancedMarkerElementを使っているかgoogleやwindowをSSR中に参照していないか- 住所検索の0件・曖昧・APIエラーを画面で扱っているか
- ユーザー入力をHTML文字列として
InfoWindowへ流していないか - モバイルで地図が画面を占有しすぎていないか
- 課金アラート・クォータ・ログ確認が運用手順に入っているか
よくある質問
Q. APIキーをブラウザに出すのは危なくないですか? Maps JavaScript APIの性質上、ブラウザ用キーは出ます。これは避けられません。代わりにHTTP referrer制限とAPI制限を必ずかけ、Geocodingなどサーバーから叩く処理は別キーにしてブラウザに出さない。この分離が守られていれば、漏れても被害を局所化できます。
Q. 結局Google MapsとMapbox、どっちを選べばいい? 住所検索・経路・プレイス情報が中心ならGoogle Maps。独自データを大量に重ねる、地図スタイルを作り込む、WebGL表現が欲しいならMapbox。両方入れる必要はなく、用途で1つに寄せるのが運用もコストもラクです。
Q. 料金が読めなくて怖いです。最初の一歩は? Google Cloud Consoleで予算アラートを先に設定し、店舗データ3件くらいで小さく動かすこと。検索・マーカークリック・スマホ表示・請求メトリクスを確認してから本番データに広げます。いきなり全件で始めない。
Q. マーカーが100件を超えると重いです。 全件を一度に描画しているのが原因です。ズームに応じたクラスタリング、地図の表示範囲内だけ取得、リストのページングを入れます。Claude Codeへの指示にも「最初から全件描画しない」と明記しておくと、最初からその前提で書いてくれます。
Q. ユーザーが現在地の許可を拒否したら? 拒否されても壊れないUIを用意します。現在地が取れない前提で、住所検索や代表地点(駅など)にフォールバックする。現在地を保存するなら、その目的と保存期間をきちんと説明します。
実際に試した結果
冒頭の課金事故以来、僕は地図を入れるとき真っ先に「キーの分離」と「予算アラート」をやるようになりました。順番を変えただけで、こわさが全然ちがう。
実キーを入れない状態でも、ここで載せたコードはNext.js側の責務分割、SSR回避、エラー分岐まで確認できます。実キーを使う段階では、Google Cloud ConsoleでMaps JavaScript API・Geocoding APIを有効化し、HTTP referrer制限・サーバーキー制限・予算アラートを設定してから接続するのが最短でした。まずは店舗データ3件で小さく動かし、検索・マーカークリック・スマホ表示・請求メトリクスを見てから本番に広げる。地味ですが、これが一番ケガしない進め方です。
地図機能を業務アプリにきちんと組み込みたいなら、Claude Codeの使い方をまとめた教材一覧も覗いてみてください。今日のチェックリストの先まで、手を動かしながら詰められます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。