Build a store locator in JavaScript
Search your own branch list by US ZIP, filter by distance and service, and let visitors choose a nearby location in a complete browser example.
On this page
Working example
HTML + JavaScriptBefore you start
Build a store locator with a ZIP input, radius and service filters, and a selectable list ordered by distance. You supply the branches. The Postal API resolves the ZIP to approximate coordinates; JavaScript measures the distance to each branch in your list.
The complete example is one HTML file with six fictional demo branches. Five have coordinates, mostly around Boston. One has missing coordinates so you can see how the example excludes an unlocated branch. The names and services are sample data, not real businesses or availability claims.
Create a Public key on the keys page. Add localhost to its allowed hostnames and add your production hostname when you use the form there. Replace YOUR_PUBLIC_API_KEY in the downloaded store-locator-javascript.html with your public key. Keep secret keys on your server.
Run this command from the file's directory:
python3 -m http.server 8000Open the local store locator. Use the allowed localhost hostname; opening the HTML directly does not provide that website origin.
1. Supply your own locations
Each branch needs a stable ID, a display name, an area label, coordinates, and the services you want to filter. Replace this array with your own public location data.
// Replace these fictional branches with your own public location data.
const STORES = [
{ id: 'beacon', name: 'Beacon demo branch', area: 'Boston, MA', latitude: 42.358, longitude: -71.062, services: ['pickup', 'repairs'] },
{ id: 'cambridge', name: 'Cambridge demo branch', area: 'Cambridge, MA', latitude: 42.366, longitude: -71.105, services: ['pickup'] },
{ id: 'quincy', name: 'Quincy demo branch', area: 'Quincy, MA', latitude: 42.2529, longitude: -71.0023, services: ['repairs'] },
{ id: 'providence', name: 'Providence demo branch', area: 'Providence, RI', latitude: 41.824, longitude: -71.4128, services: ['pickup'] },
{ id: 'manhattan', name: 'Manhattan demo branch', area: 'New York, NY', latitude: 40.754, longitude: -73.984, services: ['pickup', 'repairs'] },
{ id: 'unlocated', name: 'Unlocated demo branch', area: 'Location pending', latitude: null, longitude: null, services: ['pickup'] }
];Keep latitude and longitude in separate, explicitly named fields. A valid latitude is between -90 and 90; longitude is between -180 and 180. Both must be finite numbers. Zero is valid. A missing coordinate is not zero, and a numeric-looking string is not accepted by this example.
The complete code checks branch IDs and display fields before searching. It excludes branches without usable coordinates and reports the omitted count with the results. A missing point must not quietly become a branch at (0, 0) or a zero-mile match.
For a real locator, use verified branch coordinates. A store's ZIP centroid is still an area approximation and may not locate its entrance. Any branch data included in this HTML can be read by visitors, so include only data you intend to make public.
2. Ask for a ZIP and search filters
The page makes no API request until the visitor submits the form. The initial 02108 value is a US ZIP near the demo's Boston branches.
<form id="locator-form">
<div class="fields">
<label for="zip">US ZIP code
<input id="zip" name="postal" type="text" inputmode="numeric" autocomplete="postal-code" pattern="[0-9]{5}" maxlength="5" title="Enter a five-digit US ZIP code" value="02108" required>
</label>
<label for="radius">Search radius
<input id="radius" name="radius" type="number" min="1" max="500" step="1" value="25" required>
</label>
<label for="unit">Distance unit
<select id="unit" name="unit"><option value="mi">Miles</option><option value="km">Kilometers</option></select>
</label>
<label for="service">Service
<select id="service" name="service"><option value="all">Any service</option><option value="pickup">Pickup</option><option value="repairs">Repairs</option></select>
</label>
</div>
<button id="search" type="submit">Find branches</button>
<p id="status" role="status" aria-live="polite" aria-atomic="true">Choose a radius from 1 to 500, then search.</p>
</form>Use a text field for the ZIP, even though this example accepts five digits. That preserves the leading zero in 02108. inputmode="numeric" provides a convenient mobile keyboard without converting the value to a number.
The radius accepts whole numbers from 1 to 500 in the selected unit. That is a limit chosen for this demo's form, not a limit on the ordinary postal lookup. The service filter uses fields from your branch list. Neither filter is sent to the API.
3. Resolve the ZIP to a starting point
Call /postal/{code}?country=US. Keep the country explicit because postal codes can overlap across countries.
async function lookupPostal(code, signal) {
if (!API_KEY.startsWith('parse_public_')) {
throw new Error('Add your public API key and allow this hostname in its domain list.');
}
const url = new URL('https://api.parseapi.com/postal/' + encodeURIComponent(code));
url.search = new URLSearchParams({ country: 'US', key: API_KEY });
const response = await fetch(url, { signal });
if (!response.ok) {
const messages = {
400: 'Check the five-digit ZIP code.',
401: 'Check your public API key.',
403: 'Check your public key and its allowed hostnames.',
404: 'This ZIP was not found in the covered data. Try another ZIP.',
429: 'The request limit was reached. Try again later.'
};
throw new Error(messages[response.status] || 'The ZIP lookup is unavailable. Try again later.');
}
const data = await response.json();
if (!data || data.postal !== code || data.country !== 'US') {
throw new Error('The lookup returned an unexpected ZIP or country.');
}
if (!validCoordinates(data)) throw new Error('This ZIP has no usable coordinates for a distance search. Try another ZIP.');
return data;
}Check the HTTP status, returned ZIP, and country before reading the coordinates. The response fields used for measurement are latitude and longitude. A postal record can exist without usable coordinates; show that as an unavailable starting point, not as an empty store search.
The example checks for a browser public key and distinguishes a rejected key, unknown ZIP, rate limit, malformed response, and failed connection. In each case, the visitor keeps their inputs and can search again. The Postal reference describes the lookup and its nullable fields.
4. Filter and sort by distance
Calculate a great-circle distance between the postal point and each located branch. The haversine calculation below uses a spherical Earth with a mean radius of 6,371.0088 kilometers. Miles convert using 1.609344 kilometers per mile.
function distanceKm(from, to) {
const radians = (degrees) => degrees * Math.PI / 180;
const lat1 = radians(from.latitude);
const lat2 = radians(to.latitude);
const deltaLat = lat2 - lat1;
const deltaLon = radians(to.longitude - from.longitude);
const h = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return 2 * 6371.0088 * Math.asin(Math.sqrt(Math.min(1, Math.max(0, h))));
}
function nearestStores(origin, list, radiusKm, wantedService) {
return list
.filter((store) => wantedService === 'all' || store.services.includes(wantedService))
.map((store) => ({ ...store, distance: distanceKm(origin, store) }))
.filter((store) => store.distance <= radiusKm)
.sort((a, b) => a.distance - b.distance || a.id.localeCompare(b.id));
}First apply the service filter. Then calculate distances, keep those within the requested radius, and sort nearest first. The ID provides a stable tie-break when two locations have the same distance.
Filter and sort using the full numeric result. Round only the displayed distance. Rounding before the radius check could include a branch just outside the chosen boundary. A zero-distance match remains valid.
This measures approximate straight-line distance from the ZIP's centroid to your branch point. It is not road mileage or travel time. A branch that is nearer in a straight line may take longer to reach. Do not use this result to promise delivery eligibility or infer the visitor's exact location.
5. Show a list the visitor can choose from
Each result displays the branch name, area, approximate distance, and services. The button selects that branch and shows its ID alongside the ZIP used for the search.
function showStores(origin, matches, settings, omitted) {
const resultRevision = revision;
const omittedNote = omitted ? ` ${omitted} branch${omitted === 1 ? '' : 'es'} without usable coordinates omitted.` : '';
summary.textContent = `Within ${settings.amount} ${settings.unit} of ${origin.postal}`;
rows.replaceChildren();
for (const store of matches) {
const row = document.createElement('li');
const name = document.createElement('p');
name.className = 'store-name';
name.textContent = store.name;
const detail = document.createElement('p');
detail.className = 'detail';
const distance = store.distance / (settings.unit === 'mi' ? 1.609344 : 1);
detail.textContent = `${store.area} · ${distance.toFixed(1)} ${settings.unit} approx. · ${store.services.map((item) => SERVICES[item]).join(', ')}`;
const choose = document.createElement('button');
choose.type = 'button';
choose.className = 'choose';
choose.textContent = 'Choose this branch';
choose.setAttribute('aria-label', 'Choose ' + store.name);
choose.setAttribute('aria-pressed', 'false');
choose.addEventListener('click', () => {
if (resultRevision !== revision) return;
for (const button of rows.querySelectorAll('button')) button.setAttribute('aria-pressed', String(button === choose));
selection.textContent = 'Selected: ' + store.name + '\n' + JSON.stringify({ store_id: store.id, search_postal: origin.postal }, null, 2);
});
row.append(name, detail, choose);
rows.append(row);
}
results.hidden = false;
setStatus(matches.length
? `${matches.length} matching demo branch${matches.length === 1 ? '' : 'es'}, nearest first.${omittedNote}`
: `No located demo branches match this radius and service. Increase the radius or change the service.${omittedNote}`);
}Use textContent for names and labels. A display value should be text, even if it contains characters that resemble HTML. Keep the stable branch ID separate from its human-readable name.
Choose this branch only displays a local object. Replace that output with your own application behavior, such as carrying store_id into the next form step. Validate the ID and current availability on your server before accepting an order or reservation. This demo does neither.
An empty match list is a successful search with no located branch inside the chosen radius and service filter. The page says so and suggests changing the filters. That differs from an unavailable ZIP or failed request.
6. Keep edits and results together
Changing the ZIP, radius, unit, or service immediately clears the result list and branch selection. It also cancels an active request. The example uses both an AbortController and a request revision, so an old response cannot repaint results after an edit or a newer search.
A ten-second timeout ends a stalled lookup and leaves the form ready to retry. The result buttons also check the revision before selecting a branch. Only the current search can produce a selection.
Try 02108 with 25 miles and any service, then choose a branch. Change the service to Repairs and confirm the selection disappears. Search again; only branches offering repairs should remain. Switch to kilometers and search again to see the selected unit applied consistently.
Try 33139 with a small radius to check the no-match state for this fictional list. Edit a ZIP during a slow request and verify no old results return. Take the browser offline, search, restore the connection, and retry. In your own tests, include a postal response with missing coordinates and a branch at exactly the starting point.
Adapt the locator
This example fits a small public location list. For a very large catalog, avoid downloading and sorting every branch in every browser; design the loading and search around the size and update frequency of your data. A map can be added later while preserving the same branch IDs and selected result.
If you need nearby postal areas instead of your own branches, search ZIP codes within a radius. To measure between two postal areas, use the ZIP distance calculator. Both use the Postal API, but their results represent postal areas rather than stores.
Complete example
One HTML file with the form, styles, and JavaScript. Replace YOUR_PUBLIC_API_KEY with your public key, then run it on a domain you have added to that key.
View and copy the complete source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Store locator in JavaScript</title>
<style>
:root { color-scheme: light dark; --bg: #fff; --ink: #152029; --muted: #5e6b75; --line: #d9e2e7; --field: #f6f9fa; --accent: #007c91; --button: #c5f6ff; --error: #b42335; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg: #0b1015; --ink: #eff7fa; --muted: #94a6b2; --line: #2c3942; --field: #121b23; --accent: #67e8f9; --button: #67e8f9; --error: #fda4af; }
}
:root[data-theme="dark"] { color-scheme: dark; --bg: #0b1015; --ink: #eff7fa; --muted: #94a6b2; --line: #2c3942; --field: #121b23; --accent: #67e8f9; --button: #67e8f9; --error: #fda4af; }
:root[data-theme="light"] { color-scheme: light; }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; padding: 24px; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }
main { max-width: 580px; margin: 0 auto; }
h1 { margin: 0 0 6px; font-size: clamp(23px, 5vw, 30px); font-weight: 600; letter-spacing: -.035em; }
h2 { margin: 0; font-size: 17px; font-weight: 600; }
p { margin: 0; }
.intro, .note, .detail { color: var(--muted); }
form { margin-top: 24px; }
label { display: block; min-width: 0; font-size: 13px; font-weight: 550; }
input, select, button { font: inherit; }
input, select { display: block; width: 100%; min-width: 0; max-width: 100%; margin-top: 7px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 7px; background: var(--field); color: var(--ink); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
button { min-height: 44px; padding: 10px 14px; border: 0; border-radius: 7px; background: var(--button); color: #07313c; cursor: pointer; font-size: 14px; font-weight: 650; }
button:disabled { opacity: .55; cursor: wait; }
#search { margin-top: 18px; width: 100%; }
#status { margin-top: 12px; min-height: 44px; color: var(--muted); font-size: 13px; }
#status[data-error="true"] { color: var(--error); }
#results { margin-top: 22px; }
#rows { list-style: none; padding: 0; margin: 8px 0 0; }
#rows li { padding: 16px 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
.store-name { font-weight: 600; }
.detail { font-size: 13px; margin: 3px 0 10px; }
.choose { background: var(--field); color: var(--accent); border: 1px solid var(--line); font-weight: 500; }
.choose[aria-pressed="true"] { border-color: var(--accent); }
#selection { margin-top: 16px; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.6 ui-monospace, monospace; }
.note { border-top: 1px solid var(--line); margin-top: 22px; padding-top: 16px; font-size: 12px; }
@media (max-width: 380px) { body { padding: 18px; } .fields { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<main>
<h1>Find a branch near a ZIP.</h1>
<p class="intro">Search six fictional demo branches. One has no coordinates and is excluded. Try 02108 near Boston.</p>
<!-- example:form -->
<form id="locator-form">
<div class="fields">
<label for="zip">US ZIP code
<input id="zip" name="postal" type="text" inputmode="numeric" autocomplete="postal-code" pattern="[0-9]{5}" maxlength="5" title="Enter a five-digit US ZIP code" value="02108" required>
</label>
<label for="radius">Search radius
<input id="radius" name="radius" type="number" min="1" max="500" step="1" value="25" required>
</label>
<label for="unit">Distance unit
<select id="unit" name="unit"><option value="mi">Miles</option><option value="km">Kilometers</option></select>
</label>
<label for="service">Service
<select id="service" name="service"><option value="all">Any service</option><option value="pickup">Pickup</option><option value="repairs">Repairs</option></select>
</label>
</div>
<button id="search" type="submit">Find branches</button>
<p id="status" role="status" aria-live="polite" aria-atomic="true">Choose a radius from 1 to 500, then search.</p>
</form>
<!-- /example:form -->
<section id="results" aria-labelledby="summary" hidden>
<h2 id="summary"></h2>
<ol id="rows"></ol>
</section>
<p id="selection" role="status" aria-live="polite"></p>
<p class="note">Names, locations, and services are fictional. Distances are approximate straight-line measurements from a ZIP centroid, not road mileage. Choosing a branch only shows a local selection; it makes no reservation.</p>
</main>
<script>
// Use a public key restricted to your development and production hostnames.
const API_KEY = 'YOUR_PUBLIC_API_KEY';
// example:stores
// Replace these fictional branches with your own public location data.
const STORES = [
{ id: 'beacon', name: 'Beacon demo branch', area: 'Boston, MA', latitude: 42.358, longitude: -71.062, services: ['pickup', 'repairs'] },
{ id: 'cambridge', name: 'Cambridge demo branch', area: 'Cambridge, MA', latitude: 42.366, longitude: -71.105, services: ['pickup'] },
{ id: 'quincy', name: 'Quincy demo branch', area: 'Quincy, MA', latitude: 42.2529, longitude: -71.0023, services: ['repairs'] },
{ id: 'providence', name: 'Providence demo branch', area: 'Providence, RI', latitude: 41.824, longitude: -71.4128, services: ['pickup'] },
{ id: 'manhattan', name: 'Manhattan demo branch', area: 'New York, NY', latitude: 40.754, longitude: -73.984, services: ['pickup', 'repairs'] },
{ id: 'unlocated', name: 'Unlocated demo branch', area: 'Location pending', latitude: null, longitude: null, services: ['pickup'] }
];
// /example:stores
const form = document.querySelector('#locator-form');
const zip = document.querySelector('#zip');
const radius = document.querySelector('#radius');
const unit = document.querySelector('#unit');
const service = document.querySelector('#service');
const search = document.querySelector('#search');
const status = document.querySelector('#status');
const results = document.querySelector('#results');
const summary = document.querySelector('#summary');
const rows = document.querySelector('#rows');
const selection = document.querySelector('#selection');
const SERVICES = { pickup: 'Pickup', repairs: 'Repairs' };
let controller;
let revision = 0;
function setStatus(message, error = false) {
status.textContent = message;
status.dataset.error = String(error);
}
function validCoordinates(point) {
return point && Number.isFinite(point.latitude) && Math.abs(point.latitude) <= 90 &&
Number.isFinite(point.longitude) && Math.abs(point.longitude) <= 180;
}
function readStores(list) {
if (!Array.isArray(list)) throw new Error('Check your branch list.');
const ids = new Set();
const located = [];
for (const store of list) {
if (!store || typeof store.id !== 'string' || !store.id.trim() || ids.has(store.id) ||
typeof store.name !== 'string' || !store.name.trim() || typeof store.area !== 'string' ||
!Array.isArray(store.services) || store.services.some((item) => !Object.hasOwn(SERVICES, item))) {
throw new Error('Check branch IDs, names, areas, and services in your location list.');
}
ids.add(store.id);
if (validCoordinates(store)) located.push(store);
}
return { located, omitted: list.length - located.length };
}
function readSearch() {
const code = zip.value.trim();
const amount = Number(radius.value);
if (!/^[0-9]{5}$/.test(code)) throw new Error('Enter a five-digit US ZIP code.');
if (!Number.isInteger(amount) || amount < 1 || amount > 500 || !['mi', 'km'].includes(unit.value)) {
throw new Error('Choose a whole-number radius from 1 to 500 and a distance unit.');
}
if (service.value !== 'all' && !Object.hasOwn(SERVICES, service.value)) throw new Error('Choose a listed service.');
return { code, amount, unit: unit.value, service: service.value, radiusKm: amount * (unit.value === 'mi' ? 1.609344 : 1) };
}
// example:request
async function lookupPostal(code, signal) {
if (!API_KEY.startsWith('parse_public_')) {
throw new Error('Add your public API key and allow this hostname in its domain list.');
}
const url = new URL('https://api.parseapi.com/postal/' + encodeURIComponent(code));
url.search = new URLSearchParams({ country: 'US', key: API_KEY });
const response = await fetch(url, { signal });
if (!response.ok) {
const messages = {
400: 'Check the five-digit ZIP code.',
401: 'Check your public API key.',
403: 'Check your public key and its allowed hostnames.',
404: 'This ZIP was not found in the covered data. Try another ZIP.',
429: 'The request limit was reached. Try again later.'
};
throw new Error(messages[response.status] || 'The ZIP lookup is unavailable. Try again later.');
}
const data = await response.json();
if (!data || data.postal !== code || data.country !== 'US') {
throw new Error('The lookup returned an unexpected ZIP or country.');
}
if (!validCoordinates(data)) throw new Error('This ZIP has no usable coordinates for a distance search. Try another ZIP.');
return data;
}
// /example:request
// example:distance
function distanceKm(from, to) {
const radians = (degrees) => degrees * Math.PI / 180;
const lat1 = radians(from.latitude);
const lat2 = radians(to.latitude);
const deltaLat = lat2 - lat1;
const deltaLon = radians(to.longitude - from.longitude);
const h = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return 2 * 6371.0088 * Math.asin(Math.sqrt(Math.min(1, Math.max(0, h))));
}
function nearestStores(origin, list, radiusKm, wantedService) {
return list
.filter((store) => wantedService === 'all' || store.services.includes(wantedService))
.map((store) => ({ ...store, distance: distanceKm(origin, store) }))
.filter((store) => store.distance <= radiusKm)
.sort((a, b) => a.distance - b.distance || a.id.localeCompare(b.id));
}
// /example:distance
function cancelRequest() {
revision += 1;
controller?.abort();
controller = undefined;
search.disabled = false;
form.setAttribute('aria-busy', 'false');
}
function clearResults() {
results.hidden = true;
rows.replaceChildren();
summary.textContent = '';
selection.textContent = '';
}
// example:result
function showStores(origin, matches, settings, omitted) {
const resultRevision = revision;
const omittedNote = omitted ? ` ${omitted} branch${omitted === 1 ? '' : 'es'} without usable coordinates omitted.` : '';
summary.textContent = `Within ${settings.amount} ${settings.unit} of ${origin.postal}`;
rows.replaceChildren();
for (const store of matches) {
const row = document.createElement('li');
const name = document.createElement('p');
name.className = 'store-name';
name.textContent = store.name;
const detail = document.createElement('p');
detail.className = 'detail';
const distance = store.distance / (settings.unit === 'mi' ? 1.609344 : 1);
detail.textContent = `${store.area} · ${distance.toFixed(1)} ${settings.unit} approx. · ${store.services.map((item) => SERVICES[item]).join(', ')}`;
const choose = document.createElement('button');
choose.type = 'button';
choose.className = 'choose';
choose.textContent = 'Choose this branch';
choose.setAttribute('aria-label', 'Choose ' + store.name);
choose.setAttribute('aria-pressed', 'false');
choose.addEventListener('click', () => {
if (resultRevision !== revision) return;
for (const button of rows.querySelectorAll('button')) button.setAttribute('aria-pressed', String(button === choose));
selection.textContent = 'Selected: ' + store.name + '\n' + JSON.stringify({ store_id: store.id, search_postal: origin.postal }, null, 2);
});
row.append(name, detail, choose);
rows.append(row);
}
results.hidden = false;
setStatus(matches.length
? `${matches.length} matching demo branch${matches.length === 1 ? '' : 'es'}, nearest first.${omittedNote}`
: `No located demo branches match this radius and service. Increase the radius or change the service.${omittedNote}`);
}
// /example:result
form.addEventListener('input', () => {
cancelRequest();
clearResults();
setStatus('Search again to use the changed values.');
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
cancelRequest();
clearResults();
if (!form.reportValidity()) return;
let settings;
let branches;
try { settings = readSearch(); branches = readStores(STORES); }
catch (error) { setStatus(error.message, true); return; }
const current = revision;
controller = new AbortController();
const request = controller;
let timedOut = false;
const timeout = setTimeout(() => { timedOut = true; request.abort(); }, 10000);
search.disabled = true;
form.setAttribute('aria-busy', 'true');
setStatus('Finding the ZIP and measuring distances...');
try {
const origin = await lookupPostal(settings.code, request.signal);
if (current !== revision) return;
const matches = nearestStores(origin, branches.located, settings.radiusKm, settings.service);
showStores(origin, matches, settings, branches.omitted);
} catch (error) {
if (current !== revision) return;
const message = timedOut ? 'The lookup timed out. Search again to retry.'
: error instanceof TypeError ? 'Could not reach parseAPI. Check your connection and search again.'
: error instanceof SyntaxError ? 'The response could not be read. Search again to retry.' : error.message;
setStatus(message, true);
} finally {
clearTimeout(timeout);
if (current === revision) {
controller = undefined;
search.disabled = false;
form.setAttribute('aria-busy', 'false');
}
}
});
</script>
</body>
</html>
Need a key? Create a public API key. For every field and parameter, see Postal API reference.
Build something else
- Autofill city and state from a ZIP code in JavaScript
- Find ZIP codes within a radius in JavaScript
- Calculate distance between ZIP codes in JavaScript
- Add city and state to a ZIP-code CSV with Python