Build a location-aware weather widget

A small widget that greets each visitor with the weather where they are, built from an IP lookup, a weather call, a per-city cache and a fallback for every step that can go wrong.

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.

Try it liveIP Lookup10 credits
GEThttps://api.apiverve.com/v1/iplookup
curl "https://api.apiverve.com/v1/iplookup?ip=173.172.81.20" \
  -H "x-api-key: YOUR_API_KEY"
Sign in or create a free account to run this with your own 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.

Try it liveWeather10 credits
GEThttps://api.apiverve.com/v1/weatherforecast
curl "https://api.apiverve.com/v1/weatherforecast?city=Lincoln%2C+Nebraska" \
  -H "x-api-key: YOUR_API_KEY"
Sign in or create a free account to run this with your own 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.

verve.js
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:

  1. The city the visitor chose, if they have told you.
  2. city from the IP lookup, qualified with regionName to reduce ambiguity, on plans that return it.
  3. The city in the IANA timezone, which every plan returns. America/Chicago becomes Chicago. 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.
  4. A default city from configuration, so the widget always renders something.
locate.js
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.

Check yourself

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.

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales