はじめに
みなさん!Amazonやフリマアプリなどで住所登録するときに郵便番号を入力するだけで、都道府県から市区町村まで自動的に入力されるのを見たことがありますよね?実は、郵便番号には対応する住所情報が紐づいていて、それを参照して自動入力が行われているんです。
でも、そんな大量のデータを自分で用意するのは大変ですよね。そんなときに便利なのがWeb APIというものです。Web APIとは、誰かが整理したデータを公開して、「自由に使っていいよ」と提供しているサービスのことです。
今回は、その中でもZipCloudさんが提供しているWeb APIを使わせてもらいました。このAPIを使えば、郵便番号から住所情報を簡単に取得できるんです。
郵便APIについてまとめてくれている記事があったので,載せておきます。
OpenAPIにはそれぞれ利用規約があるので,確認して使いましょう
使用技術
- Next.js(TypeScript)
- pnpm(パッケージマネージャー)
- shadcn/ui(uiライブラリ)
- zod(バリデーション)
- axios(API接続)
- GitHub(バージョン管理)
実行
詳しい使い方などは,zipCloudのページに記載されているので,今回は数字を打ち込みボタンをおすと,住所がでるというものを作ります!
導入
Nextの立ち上げ
作成したリポジトリをローカルにクローンし、そのディレクトリに移動します。そして、以下のコマンドを実行します。
npx create-next-app@latest .
今回は現在のディレクトリ(リポジトリのルート)にNextを作成したいため,アプリ名を.
としています。
本来は以下のようなふうにアプリ名を記入すると
アプリ名のディレクトリを作成してその中にNextを作成してくれる。npx create-next-app@latest <アプリ名>
コマンド実行後、いくつかの質問が表示されます。
以下のように回答してください:
- TypeScriptを使用するか? → Yes
- ESLintを使用するか? → Yes
- Tailwind CSSを使用するか? → Yes
- src/ ディレクトリを使用するか? → Yes
- App Routerを使用するか? → Yes
- next dev のために Turbopack を使用しますか? → Yes
- カスタムインポートエイリアスを追加するか? → No
Yes: 好みのエイリアスにカスタマイズします。
No: デフォルトの @/* を使用します。
各種ライブラリのインストール
-
shadcn/ui (UIライブラリ)
pnpm dlx shadcn@latest init
コマンド実行後、いくつかの質問が表示されます。自分好みに選んでください。
例)
- スタイル: Default
- ベースカラー: Gray
- テーマ用のCSS変数: Yes
./src/lib/utils.ts
や./components.json
が追加されていたら、インストール成功です!今回使うコンポーネント
pnpm dlx shadcn@latest add button card alert input-otp label form select input
コマンドを実行すると、
src/components/ui
にインストールしたコンポーネントが追加されています。 -
zod (バリデーションライブラリ)
pnpm add zod @hookform/resolvers react-hook-form
-
axios (API接続ライブラリ)
pnpm add axios
準備
各コンポーネントを格納するディレクトリを作成します。今回はfeatures
ディレクトリで作成します。
- ~/srcにfeaturesディレクトリを作成
- さまざまな機能を持つWebアプリを作る際、機能ごとにディレクトリを作ると便利です。
- ~/src/featuresにaddressディレクトリを作成
- ここに今回の住所取得機能を実装していきます。
- ~/src/features/addressにcomponents、api、typesディレクトリを作成
- componentsはコンポーネントを、apiはAPI接続のためのファイルを、typesは型定義ファイルを格納するためのディレクトリです。
結果的に以下のようなディレクトリ構造になっていればOKです。
tree -d src
の出力:
src
├── app
├── components
│ └── ui
├── features
│ └── address
│ ├── api
│ ├── components
│ └── types
└── lib
コードの実装
1. 型定義ファイルを作成する
まず、都道府県と住所検索のための型定義ファイルを作成します。
src/features/address/types/prefecture.ts
:
import { z } from "zod";
export type PrefectureType = z.infer<typeof PrefectureSchema>;
export const PrefectureSchema = z.enum([
"NA",
"hokkaido",
"aomori",
"iwate",
"miyagi",
"akita",
"yamagata",
"fukushima",
"ibaraki",
"tochigi",
"gunma",
"saitama",
"chiba",
"tokyo",
"kanagawa",
"niigata",
"toyama",
"ishikawa",
"fukui",
"yamanashi",
"nagano",
"gifu",
"shizuoka",
"aichi",
"mie",
"shiga",
"kyoto",
"osaka",
"hyogo",
"nara",
"wakayama",
"tottori",
"shimane",
"okayama",
"hiroshima",
"yamaguchi",
"tokushima",
"kagawa",
"ehime",
"kochi",
"fukuoka",
"saga",
"nagasaki",
"kumamoto",
"oita",
"miyazaki",
"kagoshima",
"okinawa"
]);
export const prefectureLabelMap: Readonly<Record<PrefectureType, string>> = {
NA: "未選択",
hokkaido: "北海道",
aomori: "青森県",
iwate: "岩手県",
miyagi: "宮城県",
akita: "秋田県",
yamagata: "山形県",
fukushima: "福島県",
ibaraki: "茨城県",
tochigi: "栃木県",
gunma: "群馬県",
saitama: "埼玉県",
chiba: "千葉県",
tokyo: "東京都",
kanagawa: "神奈川県",
niigata: "新潟県",
toyama: "富山県",
ishikawa: "石川県",
fukui: "福井県",
yamanashi: "山梨県",
nagano: "長野県",
gifu: "岐阜県",
shizuoka: "静岡県",
aichi: "愛知県",
mie: "三重県",
shiga: "滋賀県",
kyoto: "京都府",
osaka: "大阪府",
hyogo: "兵庫県",
nara: "奈良県",
wakayama: "和歌山県",
tottori: "鳥取県",
shimane: "島根県",
okayama: "岡山県",
hiroshima: "広島県",
yamaguchi: "山口県",
tokushima: "徳島県",
kagawa: "香川県",
ehime: "愛媛県",
kochi: "高知県",
fukuoka: "福岡県",
saga: "佐賀県",
nagasaki: "長崎県",
kumamoto: "熊本県",
oita: "大分県",
miyazaki: "宮崎県",
kagoshima: "鹿児島県",
okinawa: "沖縄県"
};
src/features/address/types/search.ts
:
import { PrefectureSchema } from "./prefecture";
import { z } from "zod";
export type AddressResultType = z.infer<typeof AddressResultSchema>;
export const AddressResultSchema = z.union([
z.object({
status: z.union([z.literal("idle"), z.literal("progress")]),
}),
z.object({
status: z.literal("done"),
prefecture: PrefectureSchema,
city: z.string(),
}),
]);
export const ZipCloudResponseSchema = z.object({
message: z.string().nullable(),
results: z
.array(
z.object({
address1: z.string(),
address2: z.string(),
address3: z.string(),
})
)
.nullable(),
status: z.number(),
});
2. 住所検索のAPI接続を実装する
src/features/address/api/getAddress.ts
:
import axios from "axios";
import { z } from "zod";
import { AddressResultType, ZipCloudResponseSchema } from "../types/search";
import { prefectureLabelMap, PrefectureSchema } from "../types/prefecture";
const zipCodeSchema = z.string().regex(/^\d{7}$/, "郵便番号は7桁の数字である必要があります");
export async function getAddress(
zipCode: z.infer<typeof zipCodeSchema>
): Promise<AddressResultType> {
try {
zipCodeSchema.parse(zipCode);
const response = await axios.get("https://zipcloud.ibsnet.co.jp/api/search", {
params: { zipcode: zipCode },
});
const validatedResponse = ZipCloudResponseSchema.parse(response.data);
if (validatedResponse.results && validatedResponse.results.length > 0) {
const result = validatedResponse.results[0];
const prefectureEntry = Object.entries(prefectureLabelMap).find(
([_, value]) => value === result.address1
);
if (!prefectureEntry) {
return { status: "idle" };
}
const [prefectureCode] = prefectureEntry;
return {
status: "done",
prefecture: PrefectureSchema.parse(prefectureCode),
city: `${result.address2}${result.address3}`,
};
}
return { status: "idle" };
} catch (error) {
if (error instanceof z.ZodError) {
console.error("バリデーションエラー:", error.errors);
} else {
console.error("郵便番号検索でエラーが発生しました:", error);
}
return { status: "idle" };
}
}
3. 住所フォームのコンポーネントを作成する
src/features/address/components/AddressForm.tsx
:
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot
} from "@/components/ui/input-otp";
import { getEntries } from "@/lib/map";
import { prefectureLabelMap, PrefectureSchema } from "../types/prefecture";
import { getAddress } from "../api/getAddress";
import { AddressResultType } from "../types/search";
const FormSchema = z.object({
postal_code: z.string().length(7, "郵便番号は7桁で入力してください"),
prefecture: PrefectureSchema,
city: z.string(),
address1: z.string(),
address2: z.string()
});
export const AddressForm = () => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
postal_code: "",
prefecture: "NA",
city: "",
address1: "",
address2: ""
}
});
const handleSubmit = () => {
//submit
};
const searchAddress = async (zipCode: string): Promise<AddressResultType> => {
setIsLoading(true);
try {
const address = await getAddress(zipCode);
if (address.status !== "done") {
setIsLoading(false);
return { status: "idle" };
}
return { ...address, status: "done" };
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-[600px]">
<CardHeader>
<CardTitle>住所</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<FormField
control={form.control}
name="postal_code"
render={({ field }) => (
<FormItem>
<FormLabel>
郵便番号
</FormLabel>
<FormControl>
<InputOTP
maxLength={7}
value={field.value}
onChange={async value => {
field.onChange(value);
if (value.length === 7) {
const address = await searchAddress(value);
if (address.status !== "done") {
return;
}
form.setValue("prefecture", address.prefecture);
form.setValue("city", address.city);
}
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator>-</InputOTPSeparator>
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
<InputOTPSlot index={6} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="prefecture"
render={({ field }) => (
<FormItem>
<FormLabel>
都道府県
</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{getEntries(prefectureLabelMap).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>
市区町村
</FormLabel>
<FormControl>
<Input type="text" placeholder="OO市OO町" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address1"
render={({ field }) => (
<FormItem>
<FormLabel>丁目・番地・号(数字は半角数字)</FormLabel>
<FormControl>
<Input type="text" placeholder="1-2-3" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address2"
render={({ field }) => (
<FormItem>
<FormLabel>建物名/会社名・部屋番号</FormLabel>
<FormControl>
<Input type="text" placeholder="OOマンション101" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isLoading} className="mt-5">
{isLoading ? "読み込み中..." : "保存"}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
};
4. コンポーネントをページに配置する
src/app/page.tsx
:
import { AddressForm } from "@/features/address/components/AddressForm";
export default function Home() {
return (
<main>
<AddressForm />
</main>
);
}
5. サポート関数を追加する
src/lib/map.ts
:
export function getEntries<T extends object>(obj: T): [keyof T, T[keyof T]][] {
return Object.entries(obj) as [keyof T, T[keyof T]][];
}
結果
まとめ
今回は郵便番号から住所を出力するformを作成しました。
私たちが日常的に使っているwebアプリは色々ユーザーが使いやすいように考えられているので、それを参考に実際に作ってみるのは大変勉強になりました。
今後も実際に使ってみたいと思うデザインなどは模倣してみたいと思います
ディレクトリ構成のおさらい
src
├── app
│ └── page.tsx
├── components
│ └── ui
├── features
│ └── address
│ ├── api
│ │ └── getAddress.ts
│ ├── components
│ │ └── AddressForm.tsx
│ └── types
│ ├── prefecture.ts
│ └── search.ts
└── lib
└── map.ts