A practical migration guide from JSONP-based Yubinbango to jpzip-js. The HTML stays untouched. A 30-line shim handles the rest.
#TL;DR
- Yubinbango’s code has been effectively frozen since 2017 (
yubinbango-core0.6.3 / 2016-06-30,yubinbangoproper’s last code change is 2017-02-18). Only the data repo still ships monthly updates - JSONP — injecting a
<script>for each 3-digit prefix that callswindow.$yubin(...)— forces your Content-Security-Policy to allowscript-src https://yubinbango.github.io - jpzip-js is fetch-only, so
script-src 'self'and an allow-listedconnect-src https://jpzip.nadai.devare enough - Keep your
h-adrmarkup as is. A 30-line shim reproduces Yubinbango’s.p-postal-code→.p-region/.p-locality/.p-street-addressauto-fill - You also gain TypeScript types, ESM + CJS dual builds, runtime support for Node / Bun / Deno / Cloudflare Workers / Vercel Edge, and proper handling of multi-town postcodes via a
townsarray
#Background: what is Yubinbango (and h-adr)?
Yubinbango is the de-facto Japanese postal-code auto-fill library on the web. Drop a <script> tag in your page, mark a form with class="h-adr", and any 7-digit postcode typed into .p-postal-code triggers Yubinbango to fill .p-region (都道府県), .p-locality (市区町村), and .p-street-address (町域).
The h-adr and p-* class names come from the microformats2 address vocabulary. Yubinbango piggybacks on this vocabulary so the same markup can be machine-read for other purposes.
The underlying data is Japan Post’s KEN_ALL.csv — the official monthly export of all 120,677 Japanese postcode entries. Both Yubinbango and jpzip normalize this CSV to JSON, but they distribute it very differently (see the table below).
#Why migrate
| Concern | Yubinbango | jpzip-js |
|---|---|---|
| Package distribution | Browser bundle not on npm (loaded from yubinbango.github.io). yubinbango-core is on npm but last published 2016-06-30 | @jpzip/jpzip on npm, current |
| Data fetch mechanism | JSONP (<script> injection + window.$yubin(...) callback) | fetch for JSON |
| TypeScript types | None shipped | Bundled .d.ts |
| Module format | Global window.YubinBango | ESM + CJS dual |
| Runtime support | Browser only | Node 18+, Bun, Deno, browser, Cloudflare Workers, Vercel Edge |
| CSP impact | Requires script-src https://yubinbango.github.io | connect-src https://jpzip.nadai.dev only |
| Multi-town entries | Returns one entry only | Returns towns array |
| Romaji + government codes | Not exposed | prefecture_roma, city_code included |
| Data refresh cadence | Auto-updated via yubinbango-data GitHub Actions (last push 2026-05-01) | Auto-updated 1st and 15th of every month |
Note that Yubinbango itself does not depend on jQuery — a common misconception. yubinbango.js uses document.querySelectorAll and addEventListener directly. The reasons to migrate are the distribution model, missing types, CSP friction, and the inability to run anywhere outside the browser — not jQuery.
#Migration steps
#1. Inventory existing usage
git grep -n 'yubinbango' -- '*.html' '*.tsx' '*.ts' '*.js' '*.vue' '*.astro'
git grep -n 'YubinBango' -- '*.ts' '*.tsx' '*.js' '*.vue'
git grep -n 'p-postal-code\|h-adr' -- '*.html' '*.tsx' '*.vue' '*.astro'
Three things to find:
<script>tags loadingyubinbango.js- Direct calls to
new YubinBango.Core(...) - HTML using
class="h-adr"andclass="p-postal-code"
The HTML stays put. Only the first two are rewritten.
#2. Install jpzip-js
npm install @jpzip/jpzip
Zero runtime dependencies. When only lookup is imported, the gzipped bundle delta is about 4 KiB (measured in the section below).
#3. Write the compatibility shim
Add yubinbango-shim.ts to your project. It listens on .p-postal-code inputs inside .h-adr forms, calls jpzip.lookup, and writes the result back to the address fields — about 30 lines.
import { lookup } from '@jpzip/jpzip';
const ZIP_RE = /\d{7}/;
const setField = (form: HTMLElement, sel: string, value: string) => {
const el = form.querySelector<HTMLInputElement>(sel);
if (el) el.value = value;
};
const fillAddress = async (input: HTMLInputElement) => {
const form = input.closest<HTMLElement>('.h-adr');
if (!form) return;
const raw = input.value.replace(/[^\d]/g, '');
if (!ZIP_RE.test(raw)) return;
const entry = await lookup(raw);
if (!entry) return;
// Yubinbango parity: pick the first town when several share a postcode
const town = entry.towns[0];
setField(form, '.p-region', entry.prefecture);
setField(form, '.p-locality', entry.city);
setField(form, '.p-street-address', town?.town ?? '');
};
export const initYubinbangoShim = () => {
document.querySelectorAll<HTMLInputElement>('.h-adr .p-postal-code').forEach((input) => {
input.addEventListener('input', () => {
void fillAddress(input);
});
});
};
Key points:
- Only these 30 lines touch the DOM.
@jpzip/jpzipitself is DOM-free, so if you later move to React / Vue / Svelte, you replace the shim with a hook callinglookupdirectly lookupreturnsnullfor not-found postcodes and malformed input — always branch on ittowns[0]matches Yubinbango behavior. If you want to expose disambiguation UI for multi-town postcodes, surfaceentry.townsto your form layer
#4. Swap the <script> tag for an ESM import
Delete the <script> tag from your HTML:
- <script src="https://yubinbango.github.io/yubinbango/yubinbango.js" charset="UTF-8"></script>
Initialize the shim from your entry point:
import { initYubinbangoShim } from './yubinbango-shim';
initYubinbangoShim();
If you were calling new YubinBango.Core(...) directly, replace it with a lookup call:
- new YubinBango.Core(zipcode, (addr) => {
- form.region.value = addr.region;
- form.locality.value = addr.locality;
- form.street.value = addr.street;
- });
+ const entry = await lookup(zipcode);
+ if (entry) {
+ form.region.value = entry.prefecture;
+ form.locality.value = entry.city;
+ form.street.value = entry.towns[0]?.town ?? '';
+ }
Yubinbango’s region_id (the JIS prefecture number) maps to entry.prefecture_code in jpzip.
#5. Tighten your CSP
A site using Yubinbango had to allow JSONP fetches:
- Content-Security-Policy: script-src 'self' https://yubinbango.github.io;
+ Content-Security-Policy: script-src 'self'; connect-src 'self' https://jpzip.nadai.dev;
The <script> injection is gone, so script-src can drop down to 'self'. Data flows through fetch, which is governed by connect-src.
#6. Verify against real postcodes
Manual test with Yokohama City Hall (231-0017, 神奈川県横浜市中区本町) and Tokyo Metropolitan Government Building (163-8001, 東京都新宿区西新宿). Both are public landmarks with stable postcodes — useful for regression tests.
A Vitest spec:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { initYubinbangoShim } from './yubinbango-shim';
describe('yubinbango-shim', () => {
beforeEach(() => {
document.body.innerHTML = `
<form class="h-adr">
<input class="p-postal-code" />
<input class="p-region" />
<input class="p-locality" />
<input class="p-street-address" />
</form>
`;
initYubinbangoShim();
});
it('fills address fields when a valid postcode is typed', async () => {
const user = userEvent.setup();
const zip = document.querySelector('.p-postal-code') as HTMLInputElement;
await user.type(zip, '2310017');
// Mock the network with MSW or vi.mock against @jpzip/jpzip
await vi.waitFor(() => {
const region = document.querySelector('.p-region') as HTMLInputElement;
expect(region.value).toBe('神奈川県');
});
});
});
#Common pitfalls
- Firing on every keystroke: if you only want one
lookupwhen 7 digits are present, gate withraw.length === 7or debounce the handler - IME composition events: Japanese forms with IME-enabled inputs can fire
inputmid-composition; handlecompositionstart/compositionendif you observe stray triggers (rare for postcode fields, but possible on paste-from-clipboard) - Multi-town entries: business postcodes and some rural splits return
towns.length > 1. Decide whethertowns[0]is acceptable or whether you need a selection UI - SSR-rendered forms: in Next.js or Astro, don’t run
lookupagainst the server-side HTML. DeferinitYubinbangoShim()touseEffect/client:load - Tests against real network:
lookuphits the CDN. Use MSW (Mock Service Worker) or avi.mockagainst@jpzip/jpzipin your test environment
#Measured results
Numbers from an internal Vite + TypeScript sample app with three h-adr forms:
| Metric | Yubinbango | jpzip-js |
|---|---|---|
| First lookup latency (p50, Tokyo → Cloudflare edge) | ~180 ms | ~70 ms |
| Repeat lookups (cache hit) | ~180 ms (each JSONP refetches) | ~0.3 ms (L1 LRU) |
| Bundle size delta (gzip) | 0 (external <script>) | ~4 KiB (lookup only) |
Required CSP script-src addition | https://yubinbango.github.io | none |
| TypeScript types | declaration file must be hand-rolled | bundled |
The cache-hit gap is the big one. Yubinbango re-injects a <script> for every lookup (browser cache is the only mitigation), while jpzip-js short-circuits repeat lookups through its in-memory L1 LRU.
#Summary
Yubinbango’s core code is effectively frozen but its data is current and the library still works. Its limitations — JSONP-driven CSP relaxation, no TypeScript types, no non-browser runtime — show up gradually as your front-end stack modernizes.
The migration to jpzip-js is small in scope: keep the class="h-adr" markup, swap a <script> tag for a 30-line shim, and tighten one CSP directive. The side effects are a stricter CSP, free Node / Workers / Edge support, and a less brittle data refresh cadence.
Related reading:
- The jpzip project overview — why this is delivered as a static dataset on Cloudflare Pages
- Serving 120,677 entries from static JSON — chunking strategy and the L1 / L2 cache layout
- MCP server for Japanese postcodes — using jpzip from Claude / Cursor through MCP