Autofill city and state from a ZIP code in JavaScript

Build a browser form that looks up a US ZIP code, fills editable city and state fields, and handles missing results without losing the user's input.

JavaScript (browser)Last tested Get the complete example ↓
On this page

Working example

HTML + JavaScript

Before you start

You'll build a US address form that fills city and state after a ZIP lookup. The finished example is one HTML file, with the form, styles, and JavaScript together. You'll need a browser, a text editor, and Python 3 to serve it locally.

Create a Public key on the keys page. Add localhost to its allowed domains, along with the production hostname where you will publish the form. A public key starts with parse_public_ and is intended for browser code. Keep a parse_ secret key on your server.

Copy the complete example at the bottom of this page into index.html. Replace YOUR_PUBLIC_API_KEY with your public key. From the directory containing that file, run:

Terminal
python3 -m http.server 8000

Open localhost:8000. Use that hostname, because it matches the allowed domain you added. Opening the file directly with file:// won't provide the website origin the public key needs.

1. Create the form

Start with a ZIP field and separate city and state fields. This example is deliberately US-only, so the user has a five-digit ZIP in hand and the request will always include the country.

HTML
<form id="address-form">
  <div class="lookup">
    <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="33139" required>
    </label>
    <button type="submit">Find city &amp; state</button>
  </div>
  <div class="fields">
    <label for="city">City
      <input id="city" name="city" type="text" autocomplete="address-level2">
    </label>
    <label for="state">State
      <input id="state" name="state" type="text" autocomplete="address-level1" maxlength="2">
    </label>
  </div>
  <p id="status" role="status" aria-live="polite" aria-atomic="true">City and state are always editable.</p>
</form>

Keep the ZIP as text. An input with type="number", or converting its value with Number(), can remove a leading zero. inputmode="numeric" asks mobile browsers for a numeric keyboard while keeping the value a string.

The city and state fields stay editable. A lookup provides a useful starting value, and the person completing the form can adjust it. Clicking Find city & state again refills both fields. Keep the labels and status message when you adapt the design, so the result is understandable without relying on a color change.

2. Look up the ZIP

The request calls /postal/{code}?country=US. Encode the code before putting it in the path and pass the public key using the authentication shown in the example.

JavaScript
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 and try again.',
      401: 'Check your public API key.',
      403: 'Check your public key, its allowed hostnames, and your account access.',
      404: 'This ZIP was not found in the covered data. Enter your city and state manually.',
      429: 'The request limit was reached. Try again later or enter the fields manually.'
    };
    throw new Error(messages[response.status] || 'The lookup is unavailable. Try again or enter the fields manually.');
  }
  return response.json();
}

The country is part of this form's context. Five-digit postal codes exist in several countries. Passing country=US makes that context explicit, even when a particular code could resolve without it.

Check the HTTP response before treating the JSON as a lookup result. A successful fetch() only tells you a response arrived. A missing code, a rejected key, or an exhausted allowance also returns a response and needs its own handling. The example reports a failed lookup beside the form instead of filling fields from an error body.

This example runs when the visitor clicks Find city & state. Only a complete ZIP can submit. There is no useful city/state lookup to perform while the user has entered the first two digits, and typing alone doesn't use a request.

3. Fill the fields for the current ZIP

On success, take city and state from the response. The state field uses the short state code, which is convenient when your form already submits values such as NC or CO.

JavaScript
function fillAddress(data) {
  if (!data || typeof data.postal !== 'string') {
    throw new Error('The lookup returned an unexpected response. Enter the fields manually.');
  }
  city.value = typeof data.city === 'string' ? data.city : '';
  state.value = typeof data.state === 'string' ? data.state : '';
  generated.city = city.value;
  generated.state = state.value;
  setStatus(city.value && state.value
    ? 'City and state filled. Check them and edit if needed.'
    : 'Some details are unavailable for this ZIP. Fill the missing fields manually.');
}

The code also needs to account for someone changing the ZIP while a request is in progress. A response for the previous value must not fill the form after a newer value has been entered. The complete example cancels superseded work and checks which request is current before updating the fields.

When the ZIP changes, clear the previously generated values that the visitor hasn't edited. That avoids pairing a new ZIP with the last lookup while preserving deliberate manual entries. Editing city or state during a lookup also cancels the request, so its response cannot overwrite the person's work. Missing response fields stay empty, and a failed lookup leaves room for manual entry.

4. Try the form before using it

Run a lookup with the ZIP already shown in the example. Check that the city and state become editable values. Change the ZIP and confirm the generated result clears. Repeat the lookup, edit the city manually, then change the ZIP to confirm that edit survives. Try an incomplete value and confirm it doesn't make a lookup request.

Use your browser's Network panel to inspect the request. You should see country=US and a public key. If the API rejects the key, check that its allowed domains include localhost and that the page is open at that hostname. If the lookup fails with your browser offline, the form should still allow you to enter city and state yourself.

What this lookup tells you

A ZIP lookup returns the primary city and state associated with a covered code. It does not establish that a particular street address receives mail. US ZIP+4 input resolves at the five-digit ZIP level, and this form keeps its input to five digits. The Postal API reference documents the available fields and coverage.

Next steps

Use the same public key setup to find ZIP codes within a radius, or add a distance calculator. The Postal API has a live lookup and coverage details when you're ready to adapt the form for more countries.

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.

Download HTML
View and copy the complete source
zip-code-autofill.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ZIP-code autofill</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: 540px; 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; }
    label { display: block; font-size: 13px; font-weight: 550; }
    input, button { font: inherit; }
    input { width: 100%; min-width: 0; 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; }
    .lookup { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 12px; }
    .fields { display: grid; grid-template-columns: minmax(0, 1fr) 90px; gap: 12px; margin-top: 20px; }
    button { min-height: 46px; padding: 11px 16px; 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 { margin-top: 14px; min-height: 44px; color: var(--muted); font-size: 13px; }
    #status[data-error="true"] { color: var(--error); }
    .note { border-top: 1px solid var(--line); margin-top: 12px; padding-top: 16px; font-size: 12px; }
    @media (max-width: 380px) { body { padding: 18px; } .lookup { grid-template-columns: 1fr; } button { width: 100%; } }
  </style>
</head>
<body>
  <main>
    <h1>One ZIP. Two fewer fields.</h1>
    <p class="intro">Look up a US ZIP, then edit the city or state as needed.</p>
    <!-- example:form -->
    <form id="address-form">
      <div class="lookup">
        <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="33139" required>
        </label>
        <button type="submit">Find city &amp; state</button>
      </div>
      <div class="fields">
        <label for="city">City
          <input id="city" name="city" type="text" autocomplete="address-level2">
        </label>
        <label for="state">State
          <input id="state" name="state" type="text" autocomplete="address-level1" maxlength="2">
        </label>
      </div>
      <p id="status" role="status" aria-live="polite" aria-atomic="true">City and state are always editable.</p>
    </form>
    <!-- /example:form -->
    <p class="note">A postal lookup suggests a primary city and state. It does not verify a street address or confirm delivery.</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('#address-form');
    const zip = document.querySelector('#zip');
    const city = document.querySelector('#city');
    const state = document.querySelector('#state');
    const button = form.querySelector('button');
    const status = document.querySelector('#status');
    const generated = { city: null, state: null };
    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');
    }

    // 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 and try again.',
          401: 'Check your public API key.',
          403: 'Check your public key, its allowed hostnames, and your account access.',
          404: 'This ZIP was not found in the covered data. Enter your city and state manually.',
          429: 'The request limit was reached. Try again later or enter the fields manually.'
        };
        throw new Error(messages[response.status] || 'The lookup is unavailable. Try again or enter the fields manually.');
      }
      return response.json();
    }
    // /example:request

    // example:result
    function fillAddress(data) {
      if (!data || typeof data.postal !== 'string') {
        throw new Error('The lookup returned an unexpected response. Enter the fields manually.');
      }
      city.value = typeof data.city === 'string' ? data.city : '';
      state.value = typeof data.state === 'string' ? data.state : '';
      generated.city = city.value;
      generated.state = state.value;
      setStatus(city.value && state.value
        ? 'City and state filled. Check them and edit if needed.'
        : 'Some details are unavailable for this ZIP. Fill the missing fields manually.');
    }
    // /example:result

    zip.addEventListener('input', () => {
      cancelRequest();
      for (const field of [city, state]) {
        if (generated[field.id] === field.value) field.value = '';
        generated[field.id] = null;
      }
      setStatus('Look up the new ZIP or enter your city and state manually.');
    });

    // An in-flight lookup must never overwrite something the person just typed.
    for (const field of [city, state]) {
      field.addEventListener('input', () => {
        generated[field.id] = null;
        cancelRequest();
        setStatus('Your edits are kept. Look up the ZIP again to refill both fields.');
      });
    }

    form.addEventListener('submit', async (event) => {
      event.preventDefault();
      if (!form.reportValidity()) return;
      cancelRequest();
      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 city and state...');
      try {
        const data = await lookupPostal(zip.value, request.signal);
        if (current !== revision) return;
        fillAddress(data);
      } catch (error) {
        if (current !== revision) return;
        const message = timedOut ? 'The lookup timed out. Try again or enter the fields manually.'
          : error instanceof TypeError ? 'Could not reach parseAPI. Check your connection or enter the fields manually.'
          : error instanceof SyntaxError ? 'The response could not be read. Enter the fields manually.'
          : error.message;
        setStatus(message, true);
      } finally {
        clearTimeout(timeout);
        if (current === revision) cancelRequest();
      }
    });
  </script>
</body>
</html>

Need a key? Create a public API key. For every field and parameter, see Postal API reference.

Build something else

All tutorials →