Find nearby ZIP codes

Build a browser search that lists nearby US ZIP codes with distances in miles and kilometers, using an adjustable radius.

JavaScript (browser)Last tested Download and run ↓
On this page

Working example

HTML + JavaScript

Run the example

Download HTML

zip-code-radius.html

Replace YOUR_PUBLIC_API_KEY in the downloaded file with your public key. Add localhost to that key's allowed domains. In a terminal in the download folder, run:

Terminal · Python 3
python3 -m http.server 8000

Open localhost:8000/zip-code-radius.html. When publishing, add your website's hostname to the key. Keep secret keys on your server.

Need a key? Set up this example

Build a ZIP code radius search in one HTML file. Someone enters a US ZIP and a distance, chooses miles or kilometers, and gets nearby ZIP codes ordered nearest first.

1. Collect the search area

The form asks for an origin ZIP, a radius, and a unit. ZIP is a text input, which preserves leading zeroes. The radius is a number because it represents a measurement.

HTML
<form id="radius-form">
  <div class="fields">
    <label for="zip">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="28202" required>
    </label>
    <label for="radius">Radius
      <input id="radius" name="radius" type="number" min="0.1" max="500" step="0.1" value="25" aria-describedby="radius-limit" required>
    </label>
    <label for="unit">Unit
      <select id="unit" name="unit"><option value="mi">Miles</option><option value="km">km</option></select>
    </label>
  </div>
  <button type="submit">Find nearby ZIPs</button>
  <p id="status" role="status" aria-live="polite" aria-atomic="true">Enter a ZIP and choose how far to search.</p>
</form>

Run the search on submit. Someone can change the radius from 10 to 25 before making another request.

The endpoint accepts radii from 0.1 to 800 kilometers or 0.1 to 500 miles. Keep the unit visible beside the radius. The same number describes a different search area after someone changes from miles to kilometers.

2. Request nearby ZIP codes

Call /postal/{code}/nearby with country=US, radius, and unit. The unit values are mi and km.

JavaScript
const url = new URL(`https://api.parseapi.com/postal/${encodeURIComponent(code)}/nearby`);
url.search = new URLSearchParams({ country: 'US', radius: searchRadius, unit: searchUnit, key: API_KEY });
const response = await fetch(url, { headers: { 'Parse-Version': '2.0.0' }, signal });

URLSearchParams encodes the query values. Keep country=US: five-digit postal codes also exist in other countries.

The response echoes your postal, country, radius, and unit; nearby contains the matching codes and distances. This endpoint gives you the radius control. A postal lookup's optional deep.neighbors is a short surrounding-code list.

The example checks the HTTP status and displays a lookup error when the request fails. While a request is running, the displayed results must still belong to that request's ZIP and radius. Changing the inputs clears an old result so it cannot be mistaken for the new search.

3. Render the results as a table

Read the rows from nearby. Each row provides postal, city, distance in kilometers, and distance_mi in miles. Choose the distance field that matches the selected display unit.

JavaScript
const miles = data.unit === 'mi';
document.querySelector('#distance-heading').textContent = miles ? 'Miles' : 'km';
document.querySelector('#summary').textContent = `Within ${data.radius} ${data.unit} of ${data.postal}`;
rows.replaceChildren();
// The API returns nearest first and excludes the origin. Keep that order.
for (const item of data.nearby) {
  const row = document.createElement('tr');
  const distance = miles ? item.distance_mi : item.distance;
  for (const value of [item.postal, item.city ?? 'Unavailable',
    Number.isFinite(distance) ? distance.toFixed(1) : 'Unavailable']) {
    const cell = document.createElement('td');
    cell.textContent = value;
    row.append(cell);
  }
  rows.append(row);
}

The response is ordered nearest first and excludes the origin ZIP. Keep the returned order when building the table. Use textContent for API values so a name is displayed as text instead of being interpreted as HTML.

A city name can be unavailable. Show an empty-value label for that cell and keep the ZIP and distance visible. If the array is empty, explain that no nearby codes were returned for this search. An empty search result is different from a failed request.

The response includes at most 100 nearby ZIP codes. At that limit, label the list as the first 100 results; more codes may fall within the radius.

4. Check changes and empty states

Run the example with its initial ZIP and radius, then try a smaller radius. Check that the result heading and distance column reflect the search you ran. Switch units and run it again to confirm that the request sends the selected unit.

Inspect the response in your browser's Network panel if you want to compare the table to the actual JSON. Try an incomplete ZIP and a radius outside the form's range, then check the error state with the browser offline. None of those cases should leave an old table presented as a fresh result.

How to use these distances

Distances are straight lines between approximate postal coordinates. Both codes need coordinates; a named postal record without a point cannot appear in this search.

Use this list for nearby postal areas. A store locator needs your own store locations, and a delivery boundary may require address-level measurements. See the Postal API reference for the lookup contract and coverage details.

Next steps

If you already have two ZIP codes, calculate the distance between them in one request. To fill location fields in a form, start with ZIP-code autofill. The Postal API brings lookup, distance, and nearby search together.

Complete example

One HTML file with the markup, 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
zip-code-radius.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ZIP codes within a radius</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; }
    body { margin: 0; padding: 24px; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }
    main { max-width: 560px; margin: 0 auto; }
    h1 { margin: 0 0 6px; font-size: clamp(23px, 5vw, 30px); font-weight: 600; letter-spacing: -.035em; }
    p { margin: 0; }
    .intro, .note { color: var(--muted); }
    form { margin-top: 24px; }
    .fields { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
    label { display: block; font-size: 13px; font-weight: 550; }
    input, select, button { font: inherit; }
    input, select { width: 100%; min-width: 0; margin-top: 7px; min-height: 46px; padding: 11px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--field); color: var(--ink); }
    /* Keep native selection; size the closed control consistently in Safari. */
    select:not([multiple]):where(:not([size]), [size="0"], [size="1"]) {
      -webkit-appearance: none;
      appearance: none;
      padding-right: 40px;
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m4 6 4 4 4-4' fill='none' stroke='%2371717a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
      background-repeat: no-repeat;
      background-position: right 12px center;
      background-size: 16px;
    }
    :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
    button { min-height: 46px; margin-top: 14px; padding: 11px 18px; border: 0; border-radius: 7px; background: var(--button); color: #07313c; cursor: pointer; font-size: 14px; font-weight: 650; }
    button:disabled { opacity: .55; cursor: wait; }
    #status { min-height: 44px; margin-top: 14px; color: var(--muted); font-size: 13px; }
    #status[data-error="true"] { color: var(--error); }
    .results { max-height: 280px; overflow: auto; border-block: 1px solid var(--line); margin: 6px 0 18px; }
    table { width: 100%; border-collapse: collapse; text-align: left; font-size: 13px; }
    caption { padding: 12px 8px; text-align: left; color: var(--muted); }
    th, td { padding: 10px 8px; border-bottom: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; }
    th { position: sticky; top: 0; background: var(--bg); color: var(--muted); font-weight: 550; }
    th:last-child, td:last-child { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
    .note { border-top: 1px solid var(--line); padding-top: 16px; font-size: 12px; }
    @media (max-width: 380px) { body { padding: 18px; } .fields { grid-template-columns: 1fr 1fr; } .fields label:first-child { grid-column: 1 / -1; } button { width: 100%; } }
  </style>
</head>
<body>
  <main>
    <h1>Start with a ZIP. Look around.</h1>
    <p class="intro">Find nearby US ZIP codes, ordered by distance.</p>
    <!-- example:form -->
    <form id="radius-form">
      <div class="fields">
        <label for="zip">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="28202" required>
        </label>
        <label for="radius">Radius
          <input id="radius" name="radius" type="number" min="0.1" max="500" step="0.1" value="25" aria-describedby="radius-limit" required>
        </label>
        <label for="unit">Unit
          <select id="unit" name="unit"><option value="mi">Miles</option><option value="km">km</option></select>
        </label>
      </div>
      <button type="submit">Find nearby ZIPs</button>
      <p id="status" role="status" aria-live="polite" aria-atomic="true">Enter a ZIP and choose how far to search.</p>
    </form>
    <!-- /example:form -->
    <div id="results" class="results" tabindex="0" role="region" aria-label="Nearby ZIP codes" hidden>
      <table>
        <caption id="summary"></caption>
        <thead><tr><th scope="col">ZIP</th><th scope="col">City</th><th scope="col" id="distance-heading">Miles</th></tr></thead>
        <tbody id="rows"></tbody>
      </table>
    </div>
    <p class="note">Up to 100 nearby ZIPs, excluding the starting ZIP. Distances are straight lines between approximate postal coordinates. <span id="radius-limit">Radius: 0.1 to 500 miles.</span></p>
  </main>
  <script>
    // Use a public key restricted to your development and production hostnames.
    const API_KEY = 'YOUR_PUBLIC_API_KEY';
    const form = document.querySelector('#radius-form');
    const zip = document.querySelector('#zip');
    const radius = document.querySelector('#radius');
    const unit = document.querySelector('#unit');
    const button = form.querySelector('button');
    const status = document.querySelector('#status');
    const results = document.querySelector('#results');
    const rows = document.querySelector('#rows');
    let controller;
    let revision = 0;

    function setStatus(message, error = false) {
      status.textContent = message;
      status.dataset.error = String(error);
    }

    function cancelRequest() {
      revision += 1;
      controller?.abort();
      controller = undefined;
      button.disabled = false;
      form.setAttribute('aria-busy', 'false');
    }

    async function lookupNearby(code, searchRadius, searchUnit, signal) {
      if (!API_KEY.startsWith('parse_public_')) {
        throw new Error('Add your public API key and allow this hostname in its domain list.');
      }
      // example:request
      const url = new URL(`https://api.parseapi.com/postal/${encodeURIComponent(code)}/nearby`);
      url.search = new URLSearchParams({ country: 'US', radius: searchRadius, unit: searchUnit, key: API_KEY });
      const response = await fetch(url, { headers: { 'Parse-Version': '2.0.0' }, signal });
      // /example:request
      if (!response.ok) {
        const messages = {
          400: 'Check the ZIP and radius. Some ZIPs have no coordinates for a nearby search.',
          401: 'Check your public API key.',
          403: 'Check your public key, its allowed hostnames, and your account access.',
          404: 'The starting 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 nearby search is unavailable. Try again shortly.');
      }
      const data = await response.json();
      if (data?.country !== 'US' || data.postal !== code || data.unit !== searchUnit || data.radius !== Number(searchRadius)) {
        throw new Error('The search returned an unexpected ZIP, country, or radius. Try again.');
      }
      return data;
    }

    function showNearby(data) {
      if (!data || !Array.isArray(data.nearby) || data.nearby.length > 100 || !['mi', 'km'].includes(data.unit)
        || !Number.isFinite(data.radius) || typeof data.postal !== 'string'
        || data.nearby.some(item => !item || !/^[0-9]{5}$/.test(item.postal) || typeof item.postal !== 'string'
          || item.postal === data.postal || !(item.city === null || typeof item.city === 'string')
          || !Number.isFinite(item.distance) || item.distance < 0 || !Number.isFinite(item.distance_mi) || item.distance_mi < 0)) {
        throw new Error('The search returned an unexpected response. Try again.');
      }
      // example:result
      const miles = data.unit === 'mi';
      document.querySelector('#distance-heading').textContent = miles ? 'Miles' : 'km';
      document.querySelector('#summary').textContent = `Within ${data.radius} ${data.unit} of ${data.postal}`;
      rows.replaceChildren();
      // The API returns nearest first and excludes the origin. Keep that order.
      for (const item of data.nearby) {
        const row = document.createElement('tr');
        const distance = miles ? item.distance_mi : item.distance;
        for (const value of [item.postal, item.city ?? 'Unavailable',
          Number.isFinite(distance) ? distance.toFixed(1) : 'Unavailable']) {
          const cell = document.createElement('td');
          cell.textContent = value;
          row.append(cell);
        }
        rows.append(row);
      }
      // /example:result
      results.hidden = data.nearby.length === 0;
      setStatus(data.nearby.length
        ? `${data.nearby.length} nearby ZIP${data.nearby.length === 1 ? '' : 's'} returned, nearest first. Maximum 100.`
        : 'No other covered ZIPs with coordinates were found within this radius.');
    }

    form.addEventListener('input', () => {
      cancelRequest();
      results.hidden = true;
      rows.replaceChildren();
      radius.max = unit.value === 'mi' ? '500' : '800';
      document.querySelector('#radius-limit').textContent = `Radius: 0.1 to ${radius.max} ${unit.value === 'mi' ? 'miles' : 'km'}.`;
      setStatus('Search again to use the changed values.');
    });

    form.addEventListener('submit', async (event) => {
      event.preventDefault();
      if (!form.reportValidity()) return;
      cancelRequest();
      results.hidden = true;
      rows.replaceChildren();
      const current = revision;
      controller = new AbortController();
      const request = controller;
      let timedOut = false;
      const timeout = setTimeout(() => { timedOut = true; request.abort(); }, 10000);
      button.disabled = true;
      form.setAttribute('aria-busy', 'true');
      setStatus('Finding nearby ZIP codes...');
      try {
        const data = await lookupNearby(zip.value, radius.value, unit.value, request.signal);
        if (current !== revision) return;
        if (request.signal.aborted) throw new Error('The search was cancelled.');
        showNearby(data);
      } catch (error) {
        if (current !== revision) return;
        const message = timedOut ? 'The search timed out. Try again.'
          : error instanceof TypeError ? 'Could not reach ParseAPI. Check your connection and try again.'
          : error instanceof SyntaxError ? 'The response could not be read. Try again.' : error.message;
        setStatus(message, true);
      } finally {
        clearTimeout(timeout);
        if (current === revision) cancelRequest();
      }
    });
  </script>
</body>
</html>

Need a key? Set up this browser example. For every field and parameter, see Postal API reference.

Build something else

All tutorials →