Build a local weather card

Turn a postal code into a weather card with current conditions, an observation time, and a simple way to change places.

JavaScript (browser) · Node · PythonLast tested Get the complete example ↓
On this page

Working example

HTML + JavaScript
Use this example

A place and its weather

Add a small weather card to a local guide, travel page, or dashboard. It starts with the named example 10001 in New York, US. Choose Change place to look somewhere else.

The browser example is a complete card you can copy into your project. Use this example prepares its HTML with your public key. The Node and Python versions return the same place and observation fields for a card rendered by your own app; their setup and run commands appear above.

1. Start with the place

Keep the postal code and its country together. Postal gives you the coordinates for that area:

JavaScript
const data = await getJson(`/postal/${encodeURIComponent(postal)}`, { country }, request.signal);
place = readPlace(data, postal, country);

The same code can exist in several countries, so the country is an explicit choice. Keep postal codes as text to preserve spaces and leading zeroes. The sample menu contains six countries; replace it with the countries your application supports.

2. Ask for weather at those coordinates

JavaScript
const weather = await getJson('/weather', { lat: place.latitude, lon: place.longitude }, request.signal);
const current = currentWeather(weather);

That is the composition: the first answer supplies the next input. A city, an address, or a device location shared with your app can supply coordinates to this same Weather operation.

The complete source skips Weather when either coordinate is missing. It keeps the place visible and offers another lookup. Postal coordinates describe an approximate area, so the card is local weather rather than an observation at someone's building.

3. Put the observation on the card

JavaScript
return {
  temperature: current.temperature ?? null,
  temperature_f: current.temperature_f ?? null,
  condition_name: current.condition_name ?? null,
  observed_at: current.observed_at ?? null
};

Show the temperature, condition, and observation time together. temperature is Celsius; temperature_f is Fahrenheit. The browser card leads with Fahrenheit for its US selection and Celsius elsewhere, and shows the other value when available.

A missing value stays null. Zero degrees is a temperature; an unknown condition is not clear weather. If the weather lookup fails after the place resolves, keep that place and show an unavailable state. An older observation remains labeled with its actual time.

Changing the browser inputs clears the previous weather immediately. Submit loads the new place; late responses cannot replace it. There is no background polling or visitor-location lookup.

Make it part of your app

Replace the sample postal code and country with an explicit place from your application. If you already have coordinates, start with Weather and leave out the postal step.

For a server-rendered card, choose Node or Python above. Run the download without arguments for the New York example, or add "10001" US after its filename to supply a postal code and country. Both scripts read PARSEAPI_KEY and return JSON; keep that secret key on your server. Their error.stage identifies which lookup failed, and the resolved place survives a later weather failure.

The browser gives each update ten seconds and makes no automatic retries. The server versions reuse one SDK client with its ordinary timeout and retry defaults. Their exit codes are 0 for completed lookups, 2 for a result that needs attention, and 1 if the script could not start.

This card shows current conditions. Add forecasts only when your feature needs them, using the separate deep fields in the Weather reference. The Postal reference describes location coverage.

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.

Download HTML
View and copy the complete source
weather-card.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Local weather card</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: 28px; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }
    main { max-width: 520px; margin: 0 auto; }
    p, h1 { margin: 0; }
    .topline { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
    .eyebrow { color: var(--accent); font-size: 11px; font-weight: 650; letter-spacing: .1em; }
    h1 { margin-top: 18px; font-size: clamp(26px, 6vw, 34px); line-height: 1.15; letter-spacing: -.035em; overflow-wrap: anywhere; }
    #area, #condition, #observed, #status { color: var(--muted); }
    #area { margin-top: 7px; font-size: 13px; }
    .reading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 14px; margin-top: 22px; }
    #temperature { font-size: clamp(56px, 13vw, 86px); line-height: 1; font-weight: 400; letter-spacing: -.065em; font-variant-numeric: tabular-nums; }
    #temperature[data-empty="true"] { font-size: 32px; letter-spacing: -.035em; }
    #alternate { color: var(--muted); font-size: 18px; }
    #condition { margin-top: 15px; font-size: 17px; }
    #observed { margin-top: 25px; font-size: 12px; }
    #status { min-height: 20px; margin-top: 12px; font-size: 13px; }
    #status[data-error="true"] { color: var(--error); }
    #status[data-quiet="true"] { position: absolute; width: 1px; height: 1px; min-height: 0; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
    button, input, select { font: inherit; }
    button { min-height: 44px; padding: 10px 14px; border: 0; border-radius: 7px; background: var(--button); color: #07313c; cursor: pointer; font-size: 13px; font-weight: 650; }
    button:disabled { opacity: .55; cursor: wait; }
    #change { padding-inline: 0; color: var(--accent); background: transparent; font-weight: 500; white-space: nowrap; }
    #place-form { margin-top: 24px; padding-top: 22px; border-top: 1px solid var(--line); }
    .fields { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); gap: 12px; }
    label { display: block; min-width: 0; font-size: 12px; font-weight: 550; }
    input, select { display: block; width: 100%; min-width: 0; min-height: 46px; margin-top: 7px; padding: 11px 10px; border: 1px solid var(--line); border-radius: 7px; color: var(--ink); background: var(--field); }
    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; }
    #show { margin-top: 16px; }
    :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
    @media (max-width: 360px) { body { padding: 20px; } .fields { grid-template-columns: 1fr; } }
  </style>
</head>
<body>
  <main>
    <div class="topline"><p class="eyebrow">CURRENT WEATHER</p><button id="change" type="button" aria-expanded="false" aria-controls="place-form">Change place</button></div>
    <section id="weather-card" aria-labelledby="place-name" aria-busy="true">
      <h1 id="place-name">10001</h1>
      <p id="area">United States</p>
      <div class="reading"><p id="temperature" data-empty="true">Loading</p><p id="alternate"></p></div>
      <p id="condition">Finding the latest observation.</p>
      <p id="observed"></p>
    </section>
    <form id="place-form" hidden>
      <div class="fields">
        <label for="postal">Postal code<input id="postal" name="postal" type="text" autocomplete="postal-code" maxlength="32" value="10001" required></label>
        <label for="country">Country<select id="country" name="country" autocomplete="country" required><option value="US" selected>United States</option><option value="GB">United Kingdom</option><option value="CA">Canada</option><option value="FR">France</option><option value="DE">Germany</option><option value="JP">Japan</option></select></label>
      </div>
      <button id="show" type="submit">Show weather</button>
    </form>
    <p id="status" role="status" aria-live="polite" aria-atomic="true"></p>
  </main>
  <script>
    // Restrict this public key to the hostnames where you run the card.
    const API_KEY = 'YOUR_PUBLIC_API_KEY';
    const COUNTRIES = { US: 'United States', GB: 'United Kingdom', CA: 'Canada', FR: 'France', DE: 'Germany', JP: 'Japan' };
    const $ = (selector) => document.querySelector(selector);
    const form = $('#place-form'), postalInput = $('#postal'), countryInput = $('#country');
    const card = $('#weather-card'), change = $('#change'), show = $('#show'), status = $('#status');
    let revision = 0, controller;

    function setStatus(message, error = false, quiet = false) { status.textContent = message; status.dataset.error = String(error); status.dataset.quiet = String(quiet); }
    function setBusy(busy) { card.setAttribute('aria-busy', String(busy)); show.disabled = busy; }
    function clearObservation(label, detail) {
      $('#temperature').textContent = label; $('#temperature').dataset.empty = 'true';
      $('#alternate').textContent = ''; $('#condition').textContent = detail; $('#observed').textContent = '';
    }
    function showPlace(place) {
      $('#place-name').textContent = place.city || place.postal;
      $('#area').textContent = `${place.postal} · ${COUNTRIES[place.country]}`;
    }
    function showEditor(focus = false) { form.hidden = false; change.setAttribute('aria-expanded', 'true'); if (focus) postalInput.focus(); }

    async function getJson(path, params, signal) {
      if (!API_KEY.startsWith('parse_public_')) throw new Error('Add your public API key to run this card.');
      const url = new URL(path, 'https://api.parseapi.com');
      for (const [key, value] of Object.entries({ ...params, key: API_KEY })) url.searchParams.set(key, String(value));
      let onAbort;
      const aborted = new Promise((_, reject) => {
        onAbort = () => reject(new Error('request_aborted'));
        signal.addEventListener('abort', onAbort, { once: true });
        if (signal.aborted) onAbort();
      });
      try {
        return await Promise.race([aborted, (async () => {
          if (signal.aborted) throw new Error('request_aborted');
          const response = await fetch(url, { signal, redirect: 'error', cache: 'no-store' });
          if (!response.ok) throw Object.assign(new Error('request_failed'), { status: response.status });
          return response.json();
        })()]);
      } finally { signal.removeEventListener('abort', onAbort); }
    }

    function readPlace(data, postal, country) {
      const code = (value) => typeof value === 'string' ? value.replace(/\s/g, '').toUpperCase() : '';
      if (!data || !code(data.postal) || code(data.postal) !== code(postal) || data.country !== country ||
          (data.city != null && typeof data.city !== 'string')) throw new Error('unexpected_response');
      for (const [field, limit] of [['latitude', 90], ['longitude', 180]]) {
        if (data[field] != null && (!Number.isFinite(data[field]) || Math.abs(data[field]) > limit)) throw new Error('unexpected_response');
      }
      return data;
    }

    function currentWeather(data) {
      const current = data?.current;
      if (!current || typeof current !== 'object' || Array.isArray(current)) throw new Error('unexpected_response');
      for (const field of ['temperature', 'temperature_f']) {
        if (current[field] != null && !Number.isFinite(current[field])) throw new Error('unexpected_response');
      }
      for (const field of ['condition_name', 'observed_at']) {
        if (current[field] != null && typeof current[field] !== 'string') throw new Error('unexpected_response');
      }
      // example:result
      return {
        temperature: current.temperature ?? null,
        temperature_f: current.temperature_f ?? null,
        condition_name: current.condition_name ?? null,
        observed_at: current.observed_at ?? null
      };
      // /example:result
    }

    function showObservation(current, country) {
      const fahrenheit = country === 'US' ? current.temperature_f != null : current.temperature == null && current.temperature_f != null;
      const primary = fahrenheit ? current.temperature_f : current.temperature;
      const other = fahrenheit ? current.temperature : current.temperature_f;
      $('#temperature').textContent = primary == null ? 'Unavailable' : `${Math.round(primary)}°${fahrenheit ? 'F' : 'C'}`;
      $('#temperature').dataset.empty = String(primary == null);
      $('#alternate').textContent = other == null ? '' : `${Math.round(other)}°${fahrenheit ? 'C' : 'F'}`;
      $('#condition').textContent = current.condition_name || 'Condition unavailable';
      const instant = current.observed_at && /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(current.observed_at) ? new Date(current.observed_at) : null;
      $('#observed').textContent = instant && Number.isFinite(instant.getTime())
        ? `Observed ${new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(instant)} UTC`
        : 'Observation time unavailable';
      return primary != null || !!current.condition_name;
    }

    async function refreshWeather(collapseEditor = false) {
      const currentRevision = ++revision;
      controller?.abort();
      const request = new AbortController(); controller = request;
      const postal = postalInput.value.trim(), country = countryInput.value;
      let place, timedOut = false;
      clearObservation('Loading', 'Finding the latest observation.');
      $('#place-name').textContent = postal || 'Choose a place'; $('#area').textContent = COUNTRIES[country] || '';
      setStatus('Loading weather.'); setBusy(true);
      const timer = setTimeout(() => { timedOut = true; request.abort(); }, 10000);
      const active = () => currentRevision === revision && !request.signal.aborted;
      try {
        if (!postal || postal.length > 32 || !Object.hasOwn(COUNTRIES, country)) throw new Error('Enter a postal code and choose its country.');
        // example:location
        const data = await getJson(`/postal/${encodeURIComponent(postal)}`, { country }, request.signal);
        place = readPlace(data, postal, country);
        // /example:location
        if (!active()) return;
        showPlace(place);
        if (place.latitude == null || place.longitude == null) {
          clearObservation('Unavailable', 'Weather is unavailable for this postal area.');
          setStatus('Choose another place.'); showEditor(); return;
        }
        // example:request
        const weather = await getJson('/weather', { lat: place.latitude, lon: place.longitude }, request.signal);
        const current = currentWeather(weather);
        // /example:request
        if (!active()) return;
        const observed = showObservation(current, country);
        if (collapseEditor) { form.hidden = true; change.setAttribute('aria-expanded', 'false'); change.focus(); }
        setStatus(observed ? `Weather loaded for ${place.city || place.postal}.` : 'No current observation is available.', false, observed);
      } catch (error) {
        if (currentRevision !== revision) return;
        clearObservation('Unavailable', place ? 'Current weather is unavailable.' : 'We could not find weather for this place.');
        const message = timedOut ? 'The lookup timed out. Try again.'
          : error.status === 404 ? (place ? 'No current observation is available. Try another place.' : 'Postal code not found. Check the country and code.')
          : error.message === 'unexpected_response' ? 'The result could not be read. Try again.'
          : ['request_failed', 'request_aborted'].includes(error.message) || error instanceof TypeError || error instanceof SyntaxError
            ? 'Weather could not be loaded. Try again.' : error.message;
        setStatus(message, true); showEditor();
      } finally {
        clearTimeout(timer);
        if (currentRevision === revision) setBusy(false);
      }
    }

    change.addEventListener('click', () => showEditor(true));
    form.addEventListener('submit', event => { event.preventDefault(); return refreshWeather(true); });
    function changedPlace() {
      revision++; controller?.abort(); setBusy(false);
      $('#place-name').textContent = 'Choose a place'; $('#area').textContent = '';
      clearObservation('Ready', 'Show weather for your selected place.'); setStatus('');
    }
    form.addEventListener('input', changedPlace);
    form.addEventListener('change', changedPlace);
    // A named place, never an inferred visitor location. Later changes require Show weather.
    void refreshWeather();
  </script>
</body>
</html>

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

Build something else

All tutorials →