This guide wires jpzip-ruby into a Rails 7/8 form to autofill the prefecture and city from a Japanese postal code. It shows two patterns — Turbo Frame and a Stimulus fetch controller — and a cache layer that stops Puma workers from re-fetching the same CDN bucket, all in a shape you can ship to production.
Two pieces of context for readers outside the Rails or Japan-address world. Hotwire is Rails’ default front-end stack: Turbo drives partial page updates over HTML, and Stimulus is a small controller framework for sprinkling JavaScript onto server-rendered markup. jpzip serves Japan Post’s KEN_ALL.csv — the official, monthly-updated postal-code file — as normalized CDN JSON (120,677 entries), and jpzip-ruby is the Ruby gem that looks those entries up with no API key and no rate limit.
#TL;DR
- There are two implementation patterns. Turbo Frame renders the address fields server-side with almost no JavaScript. A Stimulus fetch controller pulls JSON and fills the inputs client-side.
- Separation of concerns mirrors the React setup. Syntax checks (seven digits) belong to the model/form; the address autofill (postcode → address lookup) belongs to jpzip-ruby.
- Client-side autofill sits outside the trust boundary. After submit, always re-call
Jpzip.lookupon the server and confirm the prefecture and city match. - jpzip-ruby fetches data in 948 three-digit-prefix buckets and keeps them in an L1 LRU cache. A repeated lookup in the same process returns in about 0.3 ms without a network call.
- L1 is process-local, so under multi-worker Puma you plug a Rails.cache-backed L2 (a
Jpzip::Cachesubclass) to share buckets across workers. preload("all")keeps roughly 37 MiB resident per worker — skip it for address forms. L1 plus L2 is enough.
#Why this setup
Most “Rails postal code autofill” examples call an external API (such as zipcloud) straight from JavaScript. They work, but they inherit that API’s availability, rate limits, and CSP constraints. jpzip-ruby reads CDN-hosted static JSON from the Ruby side, so your Rails process owns the cache and there is no rate-limit axis at all.
With Hotwire, decide which pattern to build first.
| Concern | Turbo Frame | Stimulus + fetch |
|---|---|---|
| JavaScript | almost none (3 lines for auto-submit) | one controller (~40 lines) |
| With JS disabled | works via submit button | does not work (no PE) |
| Builds the fields | server-side (ERB partial) | client-side (input.value) |
| Rich UX (town picker, preview) | needs a server round-trip | done on the client |
| Response format | HTML fragment | JSON |
| Testing | system tests | controller test + system test |
Turbo Frame is the “server is the source of truth” Rails approach, and progressive enhancement falls out naturally. Stimulus + fetch suits an SPA-like feel. This article implements both and closes with a shared server-side check.
#Integration steps
#1. Add the gems and configure an initializer
# Gemfile
gem "jpzip"
gem "turbo-rails"
gem "stimulus-rails"
# config/initializers/jpzip.rb
require "jpzip"
Jpzip.configure(
memory_cache_size: 256, # L1: number of prefix buckets to keep (default 100)
)
memory_cache_size is how many three-digit-prefix buckets L1 keeps. The whole dataset splits into 948 buckets, so 256 is a reasonable “don’t evict the busy urban buckets” figure. The L2 cache comes in step 5.
jpzip-ruby requires Ruby 3.2+ (it uses Data.define) and has zero runtime dependencies beyond the standard library (net/http, json, monitor).
#2. Build the lookup endpoint
Serve both the Turbo Frame HTML and the Stimulus JSON from one action.
# config/routes.rb
get "/zipcode", to: "zipcodes#show", as: :zipcode
# app/controllers/zipcodes_controller.rb
class ZipcodesController < ApplicationController
# GET /zipcode?code=2310017
def show
code = params[:code].to_s.gsub(/\D/, "") # strip hyphens etc. down to 7 digits
@entry = Jpzip.lookup(code) # nil when not found
respond_to do |format|
# Pattern A: return the address-fields partial (a turbo-frame)
format.html { render partial: "zipcodes/address_fields", locals: { entry: @entry } }
# Pattern B: return JSON
format.json do
return head :not_found if @entry.nil?
render json: {
prefecture: @entry.prefecture,
city: @entry.city,
town: @entry.towns.first&.town,
}
end
end
end
end
Jpzip.lookup returns nil for non-seven-digit input without touching the network, so normalizing with gsub(/\D/, "") safely rejects malformed values.
#3. Pattern A — swap the address fields with a Turbo Frame
Extract the address fields into a partial and wrap that partial itself in turbo_frame_tag. The frame exists exactly once on the page, and the controller returns the same partial, so the response always contains a matching turbo-frame.
<%# app/views/zipcodes/_address_fields.html.erb %>
<%= turbo_frame_tag "address_fields" do %>
<%= label_tag "user[prefecture]", "Prefecture" %>
<%= text_field_tag "user[prefecture]", entry&.prefecture %>
<%= label_tag "user[city]", "City" %>
<%= text_field_tag "user[city]", entry&.city %>
<%= label_tag "user[town]", "Town" %>
<%= text_field_tag "user[town]", entry&.towns&.first&.town %>
<output role="status" aria-live="polite">
<%= "Address filled in" if entry %>
</output>
<% end %>
<%# app/views/users/new.html.erb %>
<%# Postal-code GET form. It replaces the address turbo-frame. %>
<%= form_with url: zipcode_path, method: :get, data: { turbo_frame: "address_fields" } do %>
<%= label_tag :code, "Postal code" %>
<%= text_field_tag :code, params[:code], inputmode: "numeric", maxlength: 8 %>
<%= submit_tag "Look up address" %> <%# works even with JS disabled %>
<% end %>
<%# Main form. The address fields (turbo-frame) live inside it. %>
<%= form_with model: @user do |f| %>
<%= render "zipcodes/address_fields", entry: nil %>
<%= f.submit "Register" %>
<% end %>
Looking up code=2310017 returns:
entry = Jpzip.lookup("2310017")
"#{entry.prefecture} #{entry.city} #{entry.towns.first.town}"
# => 神奈川県 横浜市中区 港町
# (231-0017 — Naka Ward, Yokohama)
Pinning the example to 231-0017 in Naka Ward, Yokohama keeps it recognizable when you revisit the code.
To auto-submit on blur (instead of clicking the button) when JavaScript is on, add a three-line Stimulus controller.
// app/javascript/controllers/autosubmit_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
submit() { this.element.requestSubmit() }
}
Put data-controller="autosubmit" on the GET form and data-action="blur->autosubmit#submit" on the postal-code field, and the frame swaps the moment focus leaves. Hide the submit button only when JS is on, and the form still works without it.
#4. Pattern B — fetch from a Stimulus controller
This pulls JSON and writes the input values client-side. Write one @hotwired/stimulus controller.
// app/javascript/controllers/zipcode_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["code", "prefecture", "city", "town", "status"]
static values = { url: String }
#lastLookedUp = ""
async lookup() {
const code = this.codeTarget.value.replace(/\D/g, "")
if (code.length !== 7) return
if (code === this.#lastLookedUp) return // skip the duplicate lookup
this.#lastLookedUp = code
this.codeTarget.setAttribute("aria-busy", "true")
try {
const res = await fetch(`${this.urlValue}?code=${code}`, {
headers: { Accept: "application/json" },
})
if (!res.ok) {
this.statusTarget.textContent = "No address found for that postal code"
return
}
const a = await res.json()
this.prefectureTarget.value = a.prefecture
this.cityTarget.value = a.city
this.townTarget.value = a.town ?? ""
this.statusTarget.textContent = "Address filled in"
} finally {
this.codeTarget.removeAttribute("aria-busy")
}
}
}
<%# app/views/users/new.html.erb (Pattern B) %>
<%= form_with model: @user, data: { controller: "zipcode", zipcode_url_value: zipcode_path } do |f| %>
<%= f.label :zipcode, "Postal code" %>
<%= f.text_field :zipcode, inputmode: "numeric", maxlength: 8,
data: { zipcode_target: "code", action: "blur->zipcode#lookup" } %>
<%= f.label :prefecture, "Prefecture" %>
<%= f.text_field :prefecture, data: { zipcode_target: "prefecture" } %>
<%= f.label :city, "City" %>
<%= f.text_field :city, data: { zipcode_target: "city" } %>
<%= f.label :town, "Town" %>
<%= f.text_field :town, data: { zipcode_target: "town" } %>
<output role="status" aria-live="polite" data-zipcode-target="status"></output>
<%= f.submit "Register" %>
<% end %>
#lastLookedUp remembers the last successful postal code and skips a duplicate lookup for the same value. L1 makes the second lookup cheap anyway, but the real reason is UX: if the user edits the town by hand and then blurs the postal-code field again, an unconditional re-fill would clobber that edit. The aria-busy and aria-live="polite" pairing tells a screen reader “loading” and “done,” exactly as in the React article.
#5. Add an L2 cache to stop duplicate CDN fetches
Through step 1, L1 is process-local. Run Puma with four workers and the first lookup of a given prefix bucket triggers four CDN fetches, one per worker. To collapse that to one, subclass Jpzip::Cache and delegate to Rails.cache.
# config/initializers/jpzip.rb
require "jpzip"
# Keys are the full prefix-bucket URL (e.g. https://jpzip.nadai.dev/p/231.json);
# values are raw JSON bytes. Store them in Rails.cache (Solid Cache / Redis / etc.).
class RailsJpzipCache < Jpzip::Cache
def get(key) = Rails.cache.read(key)
def set(key, value) = Rails.cache.write(key, value, expires_in: 7.days)
def delete(key) = Rails.cache.delete(key)
def clear = nil # do not wipe all of Rails.cache (it is shared)
end
Jpzip.configure(
memory_cache_size: 256,
cache: RailsJpzipCache.new,
)
Now the first worker to fetch a bucket writes the JSON into Rails.cache, and the rest hit L2 instead of the CDN. Even an admin page resolving 50 addresses at once touches only a handful of prefix buckets, so network round-trips top out at the bucket count. expires_in: 7.days stays well under the monthly data-update cycle, and L1/L2 are cleared automatically when Jpzip.meta detects a version change.
Leaving clear as a no-op matters. Rails.cache normally holds other things too, so wiping all of it on refresh would be a collateral-damage bug. To purge a specific bucket, use delete with the key.
#6. Re-validate on the server at submit time
Client-side autofill is an input aid, not trusted input. The user may have edited the address before submitting, so re-run the lookup in the create action.
# app/controllers/users_controller.rb
def create
zip = user_params[:zipcode].to_s.gsub(/\D/, "")
entry = Jpzip.lookup(zip)
if entry.nil?
return render :new, status: :unprocessable_entity,
alert: "Invalid postal code"
end
if entry.prefecture != user_params[:prefecture] || entry.city != user_params[:city]
return render :new, status: :unprocessable_entity,
alert: "Postal code and address do not match"
end
@user = User.new(user_params)
@user.save ? redirect_to(@user) : render(:new, status: :unprocessable_entity)
end
Checking the prefecture and city catches a tampered direct POST. Do not require an exact town match — users append a street address there, so equality is too strict.
#Pitfalls
- Nesting the lookup form inside the main form. HTML forbids nested forms. Keep the postal-code GET form and the main POST form separate, and put the address turbo-frame in the main form. Frame swaps cross form boundaries fine.
- Declaring
turbo_frame_tagtwice. Define the frame once, in the partial; both the view and the controller render that same partial. Wrapping it again nests frames and the swap stops working. - No matching frame in the response. Turbo finds the turbo-frame with the same id in the response and swaps only its contents. If the controller returns a full layout instead of the address partial, the frame is missing and Turbo errors.
- Assuming L1 is shared. L1 is per-worker. If duplicate fetches matter at your scale, always add L2. Conversely, a single-process dev environment makes it easy to misjudge the behavior.
- Calling
preload("all")at boot. That keeps ~37 MiB resident per worker. It is overkill for sporadic address lookups and wastes boot time and memory; reserve a prefix-scoped preload for high-frequency batches. - Forgetting to decide on multiple towns. Taking
towns.firstfor a postal code withtowns.length > 1(bulk-mail or some areas) can mis-fill. Take the first for e-commerce; show a<select>for government forms — decide by requirement.
#Verifying it works
Use a Capybara system test for the blur → fields-filled path. To avoid hitting the real CDN, stub Jpzip.lookup or stub https://jpzip.nadai.dev/p/231.json with WebMock.
# test/system/address_form_test.rb
require "application_system_test_case"
class AddressFormTest < ApplicationSystemTestCase
test "blur on the postal code autofills the address" do
visit new_user_path
fill_in "Postal code", with: "231-0017"
find_field("Postal code").native.send_keys(:tab) # fire blur
assert_field "Prefecture", with: "神奈川県"
assert_field "City", with: "横浜市中区"
assert_text "Address filled in"
end
end
Test the endpoint on its own with a controller test.
# test/controllers/zipcodes_controller_test.rb
require "test_helper"
class ZipcodesControllerTest < ActionDispatch::IntegrationTest
test "returns the address as JSON" do
get zipcode_path(code: "2310017"), as: :json
assert_response :success
body = JSON.parse(response.body)
assert_equal "神奈川県", body["prefecture"]
assert_equal "横浜市中区", body["city"]
end
test "unknown postal code is 404" do
get zipcode_path(code: "0000000"), as: :json
assert_response :not_found
end
end
Once L2 is in place, looking up the same prefix twice and asserting no second HTTP call fires (WebMock’s assert_requested count) proves the cache is working.
#Wrap-up
For postal-code autofill on Rails + Hotwire, first decide between Turbo Frame and a Stimulus fetch controller. Pick Turbo Frame when the server is the source of truth and you want progressive enhancement; pick Stimulus + fetch when you want richer client-side UX.
Either way, the split stays the same: syntax checks on the form, address autofill via jpzip-ruby, final validation on the server. Just remember that L1 is process-local under multi-worker Puma — drop in a Jpzip::Cache L2 and you can ship it without hammering the CDN.
Related:
- What jpzip is — why a CDN static-delivery model
- Delivering 120,677 entries — the three-digit-prefix bucketing and L1 LRU design
- React Hook Form + Zod + jpzip — the same lookup pattern in React