MENU

問い合わせ


    【NuxtとGoogle Mapsで作る情報集約マップ 第6回】住所を入力して緯度経度に変換する(Geocoding API v4をサーバー経由で)

    Google Maps API とNuxtで社内向けの情報集約マップを作る連載の第6回です。前回(第5回)では、地図をクリックして仮のピンを立て、フォームから地点を登録できるようにしました。今回は、住所を入力して緯度経度(座標)に変換し、その位置に仮ピンを立てて登録する機能を作ります。住所の変換には Google の Geocoding API(ジオコーディング=住所を座標に変換するAPI)の v4 を使い、Nuxt のサーバーから呼ぶ構成にします。

    「住所から地図の位置を出すなら、ブラウザから直接 Google に問い合わせれば早いのでは?わざわざサーバーを通すのはなぜ?」

    結論から言うと、サーバーを通すのはAPIキーを利用者の目に触れさせないためです。ブラウザで使うキーは、画面を開いた人なら誰でも見られます。住所変換のように1件ごとに課金されるAPIのキーは、ブラウザ用とは別に発行し、サーバーの中だけで使うのが安全です。あわせて、住所変換で得た座標は利用規約で保存期間が決められている点も押さえておきます。

    目次

    この回で作るもの:住所検索から地点を登録

    操作画面の動き
    画面上部の検索欄に住所を入れて「住所で探す」候補の住所が一覧で出る(精度も表示)
    候補をクリック地図がその位置に移動し、仮ピンが立つ。フォームに「住所検索の結果」が出る
    必要ならピンをドラッグして調整し、保存座標と一緒に Google の place_id と「住所検索由来」の印が保存される
    見つからない住所「候補が見つかりませんでした」と表示

    仕組み:ブラウザ → 自社サーバー → Google

    経路使うキーキーの置き場所
    ブラウザ → Maps JavaScript API(地図表示)ブラウザ用キーNUXT_PUBLIC_GOOGLE_MAPS_API_KEY(公開前提。HTTPリファラー制限で守る)
    ブラウザ → /api/geocode(自社サーバー)なし—
    自社サーバー → Geocoding API v4サーバー用キーNUXT_GOOGLE_MAPS_SERVER_KEY(サーバーの環境変数だけ)

    ブラウザが呼ぶのは自社サイトの /api/geocode だけです。Google への問い合わせとサーバー用キーはサーバーの中で完結するので、開発者ツールの通信一覧を見てもキーは出てきません。第1回で runtimeConfig の public の外に googleMapsServerKey を用意しておいたのは、この回のためです。

    Geocoding API v4 の要点

    v4 は、従来の maps/api/geocode/json(v3)に代わる新しい版です。公式ドキュメントで確認した要点は次のとおりです(2026年9月時点)。

    項目内容
    リクエストGET https://geocode.googleapis.com/v4/geocode/address/{住所}(住所はURLエンコード)
    キーの渡し方X-Goog-Api-Key ヘッダー(URLの key= でも可)
    返すフィールドの指定X-Goog-FieldMask ヘッダー。省略時は全フィールドが返る(指定は任意だが推奨)
    言語・地域languageCode(省略時は英語)、regionCode
    応答results[] に placeId、location.latitude / location.longitude、formattedAddress、granularity(精度) など
    料金区分Geocoding の SKU は Essentials 区分

    📰 出典:Google Maps Platform「Geocode an address(v4)」

    📰 出典:Google Maps Platform「Choose fields to return」

    実装

    ファイル役割
    shared/types/geocode.ts住所変換の候補の型(新規)
    server/utils/geocode.tsGeocoding API v4 を呼ぶ関数(新規)
    server/api/geocode.get.tsGET /api/geocode?address=…(新規)
    server/utils/pointSchema.ts / server/api/points/index.post.tssource と placeId を受け付けて保存(更新)
    app/components/AddressSearch.vue住所検索欄と候補一覧(新規)
    app/composables/useDraftPoint.ts / PointForm.vue / pages/index.vue仮ピンに「出どころ」を持たせる(更新)

    手順1:サーバー用キーを用意する

    Google Cloud コンソールで、ブラウザ用とは別のAPIキーを作ります。API制限は「Geocoding API」のみ、可能ならアプリケーション制限に自社サーバーのIPアドレスを指定します。値は .env に書き、コミットしません。

    .env.example(抜粋)

    # サーバー用キー(第6回以降: Geocoding API v4 をサーバーから呼ぶ場合に使用)。
    # ブラウザ用キーとは別に発行し、API 制限(+可能なら IP アドレス制限)を設定する。
    # runtimeConfig.googleMapsServerKey に対応(クライアントには公開されない)
    NUXT_GOOGLE_MAPS_SERVER_KEY=

    📰 出典:Google Maps Platform「API security best practices」

    手順2:Geocoding API v4 を呼ぶ関数

    server/utils/geocode.ts(抜粋)

    // 返してほしいフィールドだけを指定する(フィールドマスク)。省略すると全フィールドが返る
    const FIELD_MASK = [
      'results.placeId',
      'results.location',
      'results.formattedAddress',
      'results.granularity',
    ].join(',')
    
    export async function geocodeAddress(address: string, apiKey: string): Promise<GeocodeCandidate[]> {
      // 住所はパスの一部として送る。「/」「#」「+」なども含めて URL エンコードする
      const url = `https://geocode.googleapis.com/v4/geocode/address/${encodeURIComponent(address)}`
    
      const res = await $fetch<V4GeocodeAddressResponse>(url, {
        headers: {
          'X-Goog-Api-Key': apiKey, // キーは URL ではなくヘッダーで渡す(ログに残りにくい)
          'X-Goog-FieldMask': FIELD_MASK,
        },
        query: { languageCode: 'ja', regionCode: 'JP' }, // 既定は英語なので日本語を指定
        timeout: 5000,
        retry: 0, // 課金対象のリクエストなので自動リトライはしない
      })
    
      return (res.results ?? []).flatMap((r) => {
        const lat = r.location?.latitude
        const lng = r.location?.longitude
        if (!r.placeId || typeof lat !== 'number' || typeof lng !== 'number') return []
        return [{
          placeId: r.placeId,
          formattedAddress: r.formattedAddress ?? '',
          lat,
          lng,
          granularity: r.granularity ?? null,
        }]
      })
    }

    ポイントは3つです。1つ目は住所の URL エンコードです。公式ドキュメントでは、住所に含まれる「/」「#」「+」を %2F・%23・%2B にエンコードするよう説明されています。encodeURIComponent はこれらをまとめて処理します。2つ目はフィールドマスクで、画面に使う4項目だけを要求しています。3つ目は retry: 0 です。Nuxt の $fetch は失敗時に自動で再送する設定があるため、課金対象のAPIでは明示的に切っておきます。

    応答の型(V4GeocodeAddressResponse)は、公式リファレンスの GeocodeResult から使う項目だけを書き出したものです。Google 側の項目が欠けても落ちないよう、すべて省略可能として扱い、座標や place_id が無い結果は候補から外しています。

    📰 出典:Google Maps Platform「GeocodeResult(v4 リファレンス)」

    手順3:ブラウザから呼ぶ API

    server/api/geocode.get.ts

    import { z } from 'zod'
    
    const querySchema = z.object({
      address: z.string().trim().min(2, '住所を入力してください').max(200),
    })
    
    export default defineEventHandler(async (event) => {
      const { address } = await getValidatedQuery(event, (q) => querySchema.parse(q))
    
      const { googleMapsServerKey } = useRuntimeConfig(event)
      if (!googleMapsServerKey) {
        throw createError({
          statusCode: 503,
          statusMessage: 'Geocoding is not configured',
          message: 'サーバー用APIキー(NUXT_GOOGLE_MAPS_SERVER_KEY)が未設定です',
        })
      }
    
      try {
        const candidates = await geocodeAddress(address, googleMapsServerKey)
        return { candidates }
      } catch (e) {
        // Google 側のエラー内容はサーバーのログにだけ出し、利用者には一般的なメッセージを返す
        const status = (e as { statusCode?: number }).statusCode
        const detail = (e as { data?: { error?: { status?: string; message?: string } } }).data?.error
        console.error('[geocode] Geocoding API error', status, detail?.status, detail?.message)
        throw createError({
          statusCode: 502,
          statusMessage: 'Geocoding failed',
          message: '住所の変換に失敗しました。時間をおいて再度お試しください',
        })
      }
    })

    入力は zod で2〜200文字に制限し、キーが未設定なら 503、Google 側のエラーは 502 にまとめて返します。Google のエラー内容(キーが無効、制限に引っかかった等)は運用者には必要ですが、利用者に見せる必要はないので、サーバーのログにだけ出しています。

    手順4:出どころ(source)と place_id を保存する

    server/utils/pointSchema.ts(追加部分)

      // 第6回: 座標の出どころと Google の place_id(手動登録なら省略 → manual / null)
      source: z.enum(POINT_SOURCES).default('manual'),
      placeId: z.string().min(1).max(300).nullable().default(null),

    server/utils/points.ts(追加部分)と server/api/points/index.post.ts(抜粋)

    // Google 由来(geocoding / places)の座標だけ取得日時を記録する。
    // 規約上の保存期間(第6回・本文参照)を後から判定できるようにするため。
    export function fetchedAtFor(source: PointSource, now: string): string | null {
      return source === 'geocoding' || source === 'places' ? now : null
    }
      const fetchedAt = fetchedAtFor(input.source, now)
    
      const { rows } = await db.sql`INSERT INTO points
          (name, category, lat, lng, memo, place_id, source, fetched_at, updated_at)
        VALUES (${input.name}, ${input.category}, ${input.lat}, ${input.lng}, ${input.memo},
          ${input.placeId}, ${input.source}, ${fetchedAt}, ${now})
        RETURNING *`

    第3回で points テーブルに用意しておいた place_id・source・fetched_at の列を、ここで初めて使います。取得日時はブラウザから受け取らず、サーバーで決めます。整形済みの住所(formattedAddress)は画面に表示するだけで、DB には保存していません。理由は後述の利用規約の節で説明します。

    手順5:住所検索の画面

    app/components/AddressSearch.vue(スクリプトの要点)

    async function search() {
      errorMessage.value = null
      searching.value = true
      try {
        // ブラウザが呼ぶのは自サイトの API だけ。Google へのリクエストとキーはサーバー側にある
        const res = await $fetch<{ candidates: GeocodeCandidate[] }>('/api/geocode', {
          query: { address: address.value },
        })
        candidates.value = res.candidates
      } catch (e) {
        candidates.value = null
        const status = (e as { statusCode?: number }).statusCode
        errorMessage.value =
          status === 400 ? '住所を2文字以上で入力してください。'
          : status === 503 ? '住所検索は未設定です(サーバー用APIキーを設定してください)。'
          : '住所の変換に失敗しました。時間をおいて再度お試しください。'
      } finally {
        searching.value = false
      }
    }

    検索はボタンを押したときだけ実行します。1文字入力するごとに検索する作りにすると、その回数だけ課金されるためです。候補には granularity(精度)を「建物単位」「推定(番地の補間)」「区域の中心」「おおよその位置」と日本語で添え、精度の低い候補をそのまま登録しないよう利用者に気づいてもらいます。

    候補を選ぶと、ページ側で仮ピンを立てます。

    app/pages/index.vue(追加部分)

    // 住所検索の候補を選ぶ → その位置に仮ピンを立てて地図を寄せる
    function onAddressPick(c: GeocodeCandidate) {
      if (!map.value) return
      clearSelection()
      setDraft(map.value, { lat: c.lat, lng: c.lng }, {
        source: 'geocoding',
        placeId: c.placeId,
        label: c.formattedAddress,
      })
      map.value.panTo({ lat: c.lat, lng: c.lng })
      map.value.setZoom(17)
    }

    前回の useDraftPoint には、仮ピンの「出どころ」(draftOrigin)を持たせるよう引数を1つ足しました。地図クリックなら null、住所検索なら source: 'geocoding' と place_id です。登録フォームはこれを受け取り、POST /api/points の本文に source と placeId を加えて送ります。

    住所検索から立てたピンをドラッグで動かした場合も、出どころは geocoding のままにしています。人が位置を直した座標を「自社で決めた値」とみなせるかは規約の解釈に関わるため、サンプルでは保守的に扱いました。

    Geocoding の結果を保存するときの利用規約

    Google Maps Platform の Service Specific Terms(2026年6月10日版)の Geocoding API の項では、次のように定められています。

    • Geocoding API から得た緯度・経度は、連続30暦日まで一時的にキャッシュ(保存)でき、その後は削除しなければならない
    • 例外として、リクエスト元アプリのエンドユーザー向け機能のためだけに、エンドユーザーごとに論理的に分離して保存する場合は無期限に保存できる(複数のエンドユーザーをまたいで使ってはならない)
    • place_id はキャッシュ制限の対象外で、無期限に保存できる

    📰 出典:Google Maps Platform Service Specific Terms

    📰 出典:Google Maps Platform「Geocoding API Policies」

    この連載のアプリは、社内の複数の人が同じ地点データを見る「共有の台帳」です。上の例外(エンドユーザーごとの分離)には当たらない可能性が高いと筆者は考えています。そこでサンプルでは、次の設計にしました。

    保存するもの扱い
    place_id無期限に保存してよいので保存する
    source(geocoding)と fetched_at(取得日時)どの座標がいつ Google から来たかを後から判定できるようにする
    緯度・経度保存するが、30日を超えたものは再取得または削除する運用が必要(バッチ処理は本連載では未実装)
    整形済み住所保存しない(画面表示のみ)

    ただし、これは筆者の読み方にもとづく設計例です。実際の運用可否は、契約内容と規約本文をもとに法務部門や専門家に確認してください。

    動作確認の方法

    1. .env に NUXT_GOOGLE_MAPS_SERVER_KEY を設定して npm run dev
    2. 検索欄に「東京都千代田区丸の内1-9-1」と入れて「住所で探す」→ 東京駅付近の候補が出ることを確認
    3. 候補をクリック → 地図が移動して仮ピンが立ち、フォームに住所が出ることを確認。保存後、curl localhost:3000/api/points で "source":"geocoding" と placeId、fetchedAt が入っていることを確認
    4. 存在しない住所で「候補が見つかりませんでした」が出ることを確認
    5. ブラウザの開発者ツールの Network タブで、/api/geocode だけが呼ばれ、サーバー用キーがどこにも現れないことを確認

    筆者の環境では、npm run build と npm run typecheck の成功に加え、ビルド後のサーバーに対して curl で次を確認しました。サーバー用キー未設定で 503、住所が1文字や未指定で 400、source: "geocoding" と placeId 付きの登録で fetchedAt が記録されること、source に想定外の値を送ると 400。ダミーのサーバー用キーでは Google から「INVALID_ARGUMENT(API key not valid)」が返り、画面向けには 502 と一般的なメッセージになること。ビルド後のHTMLとブラウザ向けファイルにサーバー用キー(ダミー値)と Geocoding API のURLが含まれないことも確認しています。

    有効なキーが無いため、実際の住所変換の結果(候補の内容・精度・日本語表記)と、地図上での仮ピン表示は確認できていません。 応答の形は公式ドキュメントの記載にもとづいて実装しています。ご自身のキーで確認してください。

    つまずきやすい点・セキュリティ上の注意

    • ブラウザ用キーを流用する:ブラウザ用キーは HTTPリファラー制限なので、サーバーからの呼び出しには合いません。用途ごとに別キーにします
    • /api/geocode を誰でも呼べる状態で公開する:自社サーバー経由でも、外部から大量に呼ばれればその分課金されます。本番ではログイン必須にし、1人あたりの回数制限や Cloud コンソールの1日あたりクォータ上限を設定します(連載では認証を省略しています)
    • 入力のたびに検索する:オートコンプリート風にすると回数が跳ね上がります。住所変換はボタン操作で1回にします
    • language を指定し忘れる:v4 は既定で英語の住所を返します
    • 住所に建物名や部屋番号を含める:公式ドキュメントでは、会社名や部屋番号・階数などの要素は避け、その国の郵便の表記に沿った住所にするよう案内されています

    発注者向けメモ

    • 住所変換は1回の検索ごとに課金される構造です(Geocoding は Essentials 区分)。「検索ボタンを押した回数」がそのまま費用の単位になると考えてください。単価は改定されることがあるため、公式の料金ページで確認します
    • ブラウザ用とサーバー用でキーを分けているかは、見積りや成果物のレビューで必ず確認したい点です。分けていないと、キーの悪用で想定外の請求が起きるリスクが高まります
    • 「変換した座標をずっと保存してよいか」は要件定義で決める事項です。利用規約には保存期間の制限があり、社内共有の台帳は例外に当たらない可能性があります。30日ごとの再取得の仕組みを作るのか、座標は人が地図で決める(第5回)運用にするのか、法務確認も含めて早めに決めると手戻りが減ります
    • 住所の表記ゆれ(「1-9-1」と「一丁目9番1号」など)で候補が出ない・ずれることがあります。精度の表示と、地図で確認して微調整する手順を業務フローに入れておくと安心です

    発注者がやることのチェックリストです。

    • ☐ ブラウザ用・サーバー用のAPIキーを分けて、自社名義のプロジェクトで発行した
    • ☐ Geocoding API の1日あたりクォータ上限と予算アラートを設定した
    • ☐ 住所変換で得た座標の保存方針を、規約を踏まえて法務と確認した
    • ☐ 住所検索を使える人(ログイン要否)を決めた

    開発会社への確認に使える質問例です。

    • 「住所変換のAPIキーは、ブラウザに出ない形で管理されていますか?」
    • 「住所変換の結果の座標は、利用規約上どのように保存・更新する設計ですか?」
    • 「住所変換の1日あたりの上限回数や、想定外に呼ばれたときの止め方は決まっていますか?」

    まとめと次回予告

    この回では、住所を Geocoding API v4 で座標に変換し、その位置に仮ピンを立てて登録できるようにしました。サーバー用キーはサーバーの中だけで使う、フィールドマスクで必要な項目だけを受け取る、place_id と出どころ・取得日時を保存して規約上の保存期間を管理できるようにする、の3点が押さえどころです。

    次回は「施設名で検索して地点を取り込む(Places API (New))」です。店舗名やビル名で検索して候補から選び、その施設の位置を地図に取り込みます。要求するフィールドによって料金区分が変わる仕組みも解説します。

    この連載の記事一覧

    この記事は連載「NuxtとGoogle Mapsで作る情報集約マップ」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次