A weather widget looks like a beginner project, and the happy path is: two API calls and some HTML. What makes it a good intermediate project is everything around the happy path. The visitor's IP might be private, shared, or on a plan tier that does not resolve to a city. The weather service resolves place names fuzzily, so the city you ask for is not always the city you get. A busy page will ask for the same city's weather hundreds of times a minute. And the whole thing runs on a piece of personal data, the visitor's IP address, that you should handle with some care.
You will build an Express server with one route, GET /api/weather, and a static page that renders the result. The route works out where the visitor is, gets the weather for that place, caches it, and returns only the handful of fields the widget needs. The page lets the visitor correct the location and remembers their choice.
The two APIs
IP Lookup (iplookup) is a GET with one required parameter, ip. On every plan it returns country, countryName, region, regionName, continent, continentName and timezone. The fields a weather widget most wants, city and coordinates, are premium: paid plans include them, lower plans do not. Build for both.
curl "https://api.apiverve.com/v1/iplookup?ip=173.172.81.20" \
-H "x-api-key: YOUR_API_KEY"Look for city (premium) and timezone (every plan). The widget uses whichever is present.
Weather (weatherforecast) is a GET whose first endpoint takes city (required) and an optional boolean forecast. It does not accept coordinates, which settles a design question for you: whatever you learn from the IP, you will hand the weather call a place name. Current conditions come back on every plan as tempC, tempF, windKph, windMph, windDegree and windDir. The 7-day forecast array, with one entry per day carrying date, condition, maxtempC, mintempC, chanceOfRain and more, is premium, as are fields like feelslikeC and precipMm.
curl "https://api.apiverve.com/v1/weatherforecast?city=Lincoln%2C+Nebraska" \
-H "x-api-key: YOUR_API_KEY"tempC and windDir are on every plan; the forecast array appears on plans that include it.
Two details from live responses matter here. First, responses also carry a location object (name, region, country) and a top-level condition string. They are not in the reference's field list, so the code treats them as optional extras. Second, place names are matched leniently. Ask for a nonsense name and you may get a real answer for a real town somewhere else. The location echo is how you show the visitor which place the numbers belong to, so they can spot a wrong guess.
The catalog lists both APIs at 10 credits per call, so a cold page view costs 20. That number is why the cache in this lesson is not optional.
Set up the project
Same skeleton as the previous project: an ES module project with Express 4 and your key in an environment variable.
mkdir weather-widget && cd weather-widget
npm init -y
npm pkg set type=module
npm install express@4
mkdir public
export APIVERVE_API_KEY=YOUR_API_KEY
export DEFAULT_CITY="London"The client helper is the one from the signup gate, repeated so this project stands alone. The weather call does real work upstream, so the route gives it a longer timeout than the default.
const BASE = 'https://api.apiverve.com/v1';
export class VerveError extends Error {
constructor(message, status, outOfCredits = false) {
super(message);
this.status = status;
this.outOfCredits = outOfCredits;
}
}
export async function verve(api, params, { method = 'GET', timeoutMs = 5000 } = {}) {
const key = process.env.APIVERVE_API_KEY;
if (!key) throw new Error('APIVERVE_API_KEY is not set');
let url = `${BASE}/${api}`;
const init = { method, headers: { 'x-api-key': key }, signal: AbortSignal.timeout(timeoutMs) };
if (method === 'GET') {
url += `?${new URLSearchParams(params)}`;
} else {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(params);
}
const res = await fetch(url, init);
const body = await res.json().catch(() => null);
if (!res.ok || body?.status !== 'ok') {
const outOfCredits = res.status === 429 && res.headers.get('x-api-remaining-credits') === '0';
throw new VerveError(body?.error || `HTTP ${res.status}`, res.status, outOfCredits);
}
return body.data;
}Step 1: turn an IP into a place name
The visitor's IP is on the request. Behind a load balancer or CDN, though, req.ip is the proxy's address unless you tell Express to trust the X-Forwarded-For header. Set trust proxy to the number of proxies in front of your app, not to true blindly: if you trust every hop, anyone can send a forged header and pick their own IP. Here that only changes which city they see, but it also lets a script spend your credits on arbitrary lookups.
Then filter out addresses that can never resolve. On your laptop req.ip is ::1 or 127.0.0.1; inside a company network it might be 10.x.x.x. Spending 10 credits to learn that a loopback address has no city is the kind of waste that adds up.
The fallback chain is the core idea of this step:
- The city the visitor chose, if they have told you.
cityfrom the IP lookup, qualified withregionNameto reduce ambiguity, on plans that return it.- The city in the IANA
timezone, which every plan returns.America/ChicagobecomesChicago. This is crude: it is the timezone's reference city, which can be hundreds of kilometres away. But it gets the climate roughly right and it costs nothing extra. - A default city from configuration, so the widget always renders something.
import { verve } from './verve.js';
const PRIVATE = /^(10\.|127\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|::1$|f[cd][0-9a-f]{2}:|fe80:)/i;
export function publicIp(raw) {
const ip = String(raw ?? '').replace(/^::ffff:/, '');
return ip && !PRIVATE.test(ip) ? ip : null;
}
export async function placeFromIp(rawIp) {
const ip = publicIp(rawIp);
if (!ip) return null;
try {
const loc = await verve('iplookup', { ip }, { timeoutMs: 2000 });
if (loc.city) {
return {
query: loc.regionName ? `${loc.city}, ${loc.regionName}` : loc.city,
precision: 'city',
};
}
if (loc.timezone && loc.timezone.includes('/') && !loc.timezone.startsWith('Etc/')) {
return { query: loc.timezone.split('/').pop().replace(/_/g, ' '), precision: 'timezone' };
}
return null;
} catch {
return null; // a failed lookup is a fallback, never an error page
}
}Notice what placeFromIp returns: a place and how much to trust it, never the IP itself and never the raw lookup. Nothing downstream needs the address, so nothing downstream gets it.
Your account's plan does not include the premium city field on iplookup. What does the widget fall back to before the configured default?
Answer the check above to continue.
Privacy: you are handling personal data
An IP address identifies a connection, and in many jurisdictions, including the EU under GDPR, it is treated as personal data. Sending it to a third party to look up a location is processing it. None of that stops you building this widget, but it should shape how you build it.
- Do not log it. The error handler above logs the failure, not the request. Check that your access logs and error tracker are not quietly keeping IPs for longer than you need.
- Do not cache by it. The cache in this project is keyed by city. A cache keyed by IP is a list of visitors and their locations, which is a dataset you now have to protect.
- Say so. Your privacy notice should mention that the visitor's IP is used to estimate their location and that a third-party service performs the lookup.
- Offer the manual route. The city form is not only a correction tool. It lets a visitor who would rather not be located skip the IP lookup altogether.
- Return the minimum. The route never sends the IP or the raw lookup back to the browser. The widget gets a place name and some numbers.
Ideas to extend it
- Units by country. The IP lookup's
countryis on every plan. ShowtempFandwindMphwhen it isUS, Celsius everywhere else, and let the visitor override. - Rain banner. On plans with the forecast,
forecastSummary.rainExpectedOnnames the first day at or above a 50 percent chance of rain. One line of text is often more useful than a whole forecast. - Server-side render. Fetch the weather in the page's server handler instead of from the browser, so the widget appears with the page and needs no client-side JavaScript.
- Weather alerts. Pair this with the Weather Alerts API and show a warning strip when one is active.
For timeouts and cache durations across the catalog, the making requests page has a table worth keeping open.
Next you will deal with a kind of data where rounding errors cost real money. Continue to Build a currency converter.