メインコンテンツへスキップ
住所APIナビ

Next.jsで郵便番号から住所を自動入力する方法

Next.js App Router で、郵便番号から都道府県・市区町村・町域を自動入力する実装手順です。タイムアウト、空結果、レート制限の扱いまで含めたコード例を掲載します。

情報の最終確認日:ページ更新日:人手による確認待ち

執筆:住所APIナビ編集部

郵便番号を入力したら住所欄が埋まる、という挙動は入力の手間を大きく減らします。ここでは Next.js App Router で実装する手順を、失敗時の扱いまで含めて説明します。

動作環境

以下の組み合わせを前提に書いています。掲載しているコードは構文チェックを自動で行っていますが、 断片であるため完全な型チェックは通していません。ご自身の環境で確認してください。

  • Node.js 24.x
  • Next.js 16(App Router)
  • TypeScript 5.9(strict)
  • React 19

使用するAPIは zipcloud 郵便番号検索API です。APIキーが不要なため、最短で動かせます。本番運用に移す前に、利用規約とレートリミットの扱いを必ず確認してください。

1. 郵便番号を正規化する

先に入力値を整えます。全角数字や 、ハイフンの種類がばらつくため、そのまま API に渡すと引けません。

// src/lib/postal-code.ts
 
/** 入力された郵便番号を7桁の半角数字に整える。整えられなければ null。 */
export function toPostalCode7(input: string): string | null {
  const normalized = input
    .normalize('NFKC') // 全角数字・全角ハイフンを半角へ
    .replace(/[〒\s]/g, '')
    .replace(/[-‐‑‒–—―ー]/g, '')
 
  return /^\d{7}$/.test(normalized) ? normalized : null
}

NFKC は全角数字 123123 に変換します。ハイフンは種類が多いため、正規化後にまとめて除去しています。

2. Route Handler を作る

// src/app/api/postal-code/route.ts
import { NextResponse } from 'next/server'
 
import { toPostalCode7 } from '@/lib/postal-code'
 
type ZipcloudResult = {
  zipcode: string
  address1: string // 都道府県
  address2: string // 市区町村
  address3: string // 町域
}
 
type ZipcloudResponse = {
  status: number
  message: string | null
  results: ZipcloudResult[] | null
}
 
export async function GET(request: Request) {
  const raw = new URL(request.url).searchParams.get('zipcode') ?? ''
  const zipcode = toPostalCode7(raw)
 
  if (!zipcode) {
    return NextResponse.json(
      { error: 'invalid_zipcode', message: '郵便番号は7桁で入力してください。' },
      { status: 400 },
    )
  }
 
  // 外部APIが応答しないときにリクエストを溜めないよう、必ず打ち切る。
  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), 5000)
 
  try {
    const response = await fetch(
      `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`,
      { signal: controller.signal, cache: 'no-store' },
    )
 
    if (response.status === 429) {
      return NextResponse.json(
        { error: 'rate_limited', message: '時間をおいて再度お試しください。' },
        { status: 429 },
      )
    }
 
    if (!response.ok) {
      return NextResponse.json(
        { error: 'upstream_error', message: '住所を取得できませんでした。' },
        { status: 502 },
      )
    }
 
    const data = (await response.json()) as ZipcloudResponse
 
    // status が 200 でも results が null(該当なし)のことがある。
    if (data.status !== 200 || !data.results || data.results.length === 0) {
      return NextResponse.json({ candidates: [] }, { status: 200 })
    }
 
    return NextResponse.json({
      candidates: data.results.map((r) => ({
        prefecture: r.address1,
        city: r.address2,
        town: r.address3,
      })),
    })
  } catch (error) {
    const aborted = error instanceof Error && error.name === 'AbortError'
    return NextResponse.json(
      {
        error: aborted ? 'timeout' : 'network_error',
        message: '住所を取得できませんでした。手入力でも先に進めます。',
      },
      { status: 504 },
    )
  } finally {
    clearTimeout(timeout)
  }
}

押さえておきたいのは次の4点です。

  • タイムアウトを必ず入れる。 AbortController がないと、外部APIが遅いときにリクエストが滞留します。
  • status: 200 でも resultsnull になる。 該当なしはエラーではなく「候補0件」として扱います。
  • 候補は配列で返す。 郵便番号によっては複数の町域が該当します。1件目を勝手に採用しないでください。
  • 失敗しても手入力で先に進める。 住所補完は補助機能です。落ちたらフォーム全体が止まる作りにしてはいけません。

3. フォーム側から呼ぶ

// src/components/AddressFields.tsx
'use client'
 
import { useState } from 'react'
 
type Candidate = { prefecture: string; city: string; town: string }
 
export function AddressFields() {
  const [zipcode, setZipcode] = useState('')
  const [address, setAddress] = useState('')
  const [status, setStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle')
 
  async function lookup(value: string) {
    setStatus('loading')
    try {
      const response = await fetch(`/api/postal-code?zipcode=${encodeURIComponent(value)}`)
      const data = (await response.json()) as { candidates?: Candidate[] }
      const first = data.candidates?.[0]
 
      if (!first) {
        setStatus('empty')
        return
      }
 
      setAddress(`${first.prefecture}${first.city}${first.town}`)
      setStatus('idle')
    } catch {
      setStatus('error')
    }
  }
 
  return (
    <div>
      <label htmlFor="zipcode">郵便番号</label>
      <input
        id="zipcode"
        name="postal-code"
        inputMode="numeric"
        autoComplete="postal-code"
        value={zipcode}
        onChange={(event) => setZipcode(event.target.value)}
        onBlur={(event) => void lookup(event.target.value)}
      />
 
      <label htmlFor="address">住所</label>
      <input
        id="address"
        name="address-line1"
        autoComplete="address-line1"
        value={address}
        onChange={(event) => setAddress(event.target.value)}
      />
 
      {/* エラーは色だけでなくテキストでも伝える */}
      <p role="status" aria-live="polite">
        {status === 'loading' && '住所を検索しています…'}
        {status === 'empty' && '該当する住所が見つかりませんでした。手入力してください。'}
        {status === 'error' && '住所を取得できませんでした。手入力してください。'}
      </p>
    </div>
  )
}

実装時の注意点

入力途中で毎回叩かない。 onChange ごとにリクエストすると、7桁入力するだけで最大7回呼ぶことになります。onBlur か、7桁そろった時点で1回だけ呼びます。

番地は自動入力しない。 APIが返すのは町域までです。番地・建物名の欄は空のまま残し、ユーザーに入力してもらいます。

autoComplete を正しく指定する。 postal-code / address-line1 を指定すると、ブラウザの自動入力が効きます。API を呼ぶ前に埋まることも多く、体験としてはこちらのほうが速いです。

複数候補は選ばせる。 同じ郵便番号に複数の町域が対応する場合があります。候補が2件以上あるときは選択UIを出すのが安全です。

表記ゆれは別問題。 APIが返す住所と、ユーザーが手入力した住所は表記が揃いません。手元のデータを整えたい場合は住所整形ツールで挙動を確認できます。

関連ページ