Build a local weather card
Build a weather card from a postal code using JavaScript, Node, or Python, with current conditions and observation time.
On this page
Working example
HTML + JavaScriptRun the example
Download HTMLweather-card.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:
python3 -m http.server 8000Open localhost:8000/weather-card.html. When publishing, add your website's hostname to the key. Keep secret keys on your server.
A place and its weather
Build a local weather card from a postal code. The sample starts with 10001 in New York, US, and shows current conditions with their observation time. Choose Change place to look somewhere else.
Use the browser download for the complete card, or Node/Python for a JSON result your app renders.
1. Start with the place
Keep the postal code and its country together. Postal gives you the coordinates for that area:
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
const weather = await getJson('/weather', { lat: place.latitude, lon: place.longitude }, request.signal);
const current = currentWeather(weather);Pass the postal lookup's latitude and longitude to Weather. If your app already has coordinates from a city, address, or shared device location, start with this call.
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
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.
Display zero as a temperature and leave a missing value as null. If the condition is unknown, show an unavailable label rather than a clear-weather icon. 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.
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>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, { headers: { 'Parse-Version': '2.0.0' }, 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
- Autofill city and state from a ZIP code
- Choose an Australian suburb from a postcode
- Find nearby ZIP codes
- Calculate distance between ZIP codes
- Find an open store nearby
- Add places to a ZIP-code CSV