Build a Weather Web App Using Node Express and WeatherStack

A weather app takes a city name from the user, fetches the current conditions from a weather service and shows them on a page. In this project, an Express server asks Weatherstack’s API for the weather and returns the finished page.

You’ll build the server and its EJS page template, keeping the Weatherstack key on the server.

What an Express weather web app does

An Express weather web app is a city-search page backed by a Node.js server that requests weather data and returns a completed page. Weatherstack supplies the current conditions through its application programming interface (API).

For a London search, the browser submits the city to Express, which sends the request to Weatherstack. Embedded JavaScript (EJS) turns the response into HTML, so the browser receives a temperature rather than the provider access key.

ComponentJobData it receives
BrowserSubmits the city and displays the pageCity name, temperature or an error
Express serverValidates input and requests current conditionsCity name and the server-only key
WeatherstackAnswers the /current requestEncoded location query and account key
EJS templateEscapes values and renders the formSelected display values, never the key

The project uses the Weatherstack current-weather endpoint, not a forecast endpoint. A current temperature cannot tell you whether it will rain tomorrow.

Prerequisites

Use a supported Node.js release with npm and a terminal where you can create project files. The executable example ran on Linux ARM64 with Node.js 26.10.0 and npm 11.19.1, using Express 5.2.1 and EJS 6.0.1.

  • Basic JavaScript, including objects and async functions.
  • A Weatherstack account and an access key from its dashboard.
  • A local port for the server and internet access for the provider request.

Weatherstack’s pricing page lists its Free plan with 100 calls per month, HTTPS encryption and non-commercial use. Check the account allowance before repeated searches, and choose a plan that permits your intended use.

node --version
npm --version

Node supplies fetch and AbortSignal.timeout, while its –env-file option loads the server key from .env.

Build the Express weather server

The server accepts a form submission and completes one provider lookup before rendering the response. Keep the access key in the server environment because putting it in a template would expose it to anyone who opens the page source.

Step 1: Install Express and EJS

Create a project directory, initialize its package metadata and install the web framework with its template engine. The views directory holds the template, while public holds the stylesheet that Express can serve without running a route handler.

mkdir weather-app
cd weather-app
npm init -y
npm install --package-lock=false express ejs
mkdir -p views public

Express handles incoming requests and selects the response, while EJS produces the page from the values supplied by the route. Keep .env outside public because that directory exposes its files to the browser.

Step 2: Store the Weatherstack key

Create .env in the project root and replace the placeholder with the key from your Weatherstack dashboard. No quotes are needed around a key without spaces.

WEATHERSTACK_KEY=PASTE_YOUR_KEY_HERE

Keep .env and node_modules out of Git through a root-level .gitignore, and reset the Weatherstack key if it reaches a public repository.

.env
node_modules/

Step 3: Handle the weather lookup

Whitespace-only input and a semicolon-separated bulk lookup stop before reaching Weatherstack because this form accepts one city name. Save the handler as server.js in the project root.

URLSearchParams encodes the city and requests metric units, which match the Celsius label. The handler uses an 8-second timeout before rendering a result or an error message.

const express = require('express');
const path = require('node:path');

function createApp({ apiKey = process.env.WEATHERSTACK_KEY,
  fetchWeather = fetch } = {}) {
  const app = express();
  app.set('view engine', 'ejs');
  app.set('views', path.join(__dirname, 'views'));
  app.use(express.static(path.join(__dirname, 'public')));
  app.use(express.urlencoded({ extended: false, limit: '2kb' }));

  const show = (res, status, city = '', weather = null, error = null) =>
    res.status(status).render('index', { city, weather, error });

  app.get('/', (req, res) => show(res, 200));
  app.post('/', async (req, res) => {
    const city = typeof req.body?.city === 'string'
      ? req.body.city.trim() : '';
    if (!city || city.length > 100 || city.includes(';')) {
      return show(res, 400, city, null,
        'Enter one city name, up to 100 characters.');
    }
    if (!apiKey) {
      return show(res, 503, city, null,
        'The server needs a Weatherstack access key.');
    }

    const url = new URL('https://api.weatherstack.com/current');
    url.search = new URLSearchParams({
      access_key: apiKey, query: city, units: 'm'
    }).toString();

    try {
      const response = await fetchWeather(url, {
        signal: AbortSignal.timeout(8000)
      });
      if (!response.ok) {
        return show(res, 502, city, null,
          'Weather service unavailable. Try again later.');
      }
      const data = await response.json();
      if (data.success === false || data.error) {
        const keyError = ['missing_access_key', 'invalid_access_key']
          .includes(data.error?.type);
        return show(res, 502, city, null, keyError
          ? 'Weatherstack rejected the server access key.'
          : 'Weatherstack could not complete this lookup.');
      }
      if (typeof data.location?.name !== 'string' ||
          typeof data.location?.country !== 'string' ||
          !Number.isFinite(data.current?.temperature)) {
        return show(res, 502, city, null,
          'Weather service returned incomplete data.');
      }
      const weather = `${data.location.name}, ${data.location.country}: ` +
        `${data.current.temperature} °C`;
      return show(res, 200, city, weather);
    } catch (error) {
      const timedOut = ['TimeoutError', 'AbortError'].includes(error.name);
      return show(res, 502, city, null, timedOut
        ? 'Weather lookup timed out. Try again.'
        : 'Weather response could not be read. Try again later.');
    }
  });

  app.use((error, req, res, next) => {
    if (res.headersSent) return next(error);
    return show(res, error.status === 413 ? 413 : 500, '', null,
      'The request could not be processed.');
  });
  return app;
}

if (require.main === module) {
  const port = Number(process.env.PORT || 3000);
  createApp().listen(port, () => console.log(`Weather app listening on ${port}`));
}
module.exports = { createApp };

The Express route handlers separate opening the page from submitting the form. express.urlencoded turns the form body into request data, but the city still needs a type check because a duplicate field can produce an array.

Weatherstack returned a missing_access_key error inside an HTTP 200 response when no key was supplied. That is why the handler checks data.error as well as response.ok.

Number.isFinite accepts a temperature of zero without treating it as missing. I used a zero-degree fixture to keep that case visible, and I kept the provider’s full error text out of the page so an upstream message cannot expose request details.

The createApp function accepts a replacement fetch function for tests, so keep its default when you want Node to request live data from Weatherstack.

Connect the city form to the server

The template submits the city to the same server that delivered the page. A new search replaces the displayed result, while an error leaves the submitted value in the form so you can correct it.

Step 4: Render the city form with EJS

Save this file as views/index.ejs. The field name city matches the server’s input check, and the submit method is POST because the lookup is handled by the POST route.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>City weather</title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <main>
    <h1>City weather</h1>
    <p>Search for current conditions in Celsius.</p>
    <form action="/" method="post">
      <label for="city">City name</label>
      <input id="city" name="city" type="text" value="<%= city %>"
        placeholder="London" maxlength="100" required>
      <button type="submit">Get weather</button>
    </form>
    <% if (weather) { %>
      <p class="result" role="status"><%= weather %></p>
    <% } %>
    <% if (error) { %>
      <p class="error" role="alert"><%= error %></p>
    <% } %>
  </main>
</body>
</html>

The EJS escaped-output tag converts angle brackets in city names or provider values into text rather than page markup. Use it for the input value and the result, and keep the unescaped output tag away from those values.

I submitted spaces through the form and got the server’s validation message without a provider request, because spaces can satisfy the browser’s required attribute.

City weather form rejecting a whitespace-only city name
The server rejects a blank city before making a weather request.

Step 5: Style the form for narrow screens

The form fills the available card width, while the card stops growing at 560 pixels so its input does not stretch across a desktop screen. Save the stylesheet as public/style.css.

* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
  background: #eef4f9;
  color: #18293a;
  font: 18px/1.5 system-ui, sans-serif;
}
main {
  width: min(100%, 560px);
  padding: 32px;
  border: 1px solid #b7cbdc;
  border-radius: 12px;
  background: white;
}
h1 { margin-top: 0; }
label { display: block; font-weight: 600; }
input, button {
  width: 100%;
  margin-top: 12px;
  padding: 12px;
  border: 1px solid #66849e;
  border-radius: 6px;
  font: inherit;
}
button { background: #154f77; color: white; cursor: pointer; }
input:focus-visible, button:focus-visible {
  outline: 3px solid #dd9c20;
  outline-offset: 3px;
}
.result { background: #e5f3ec; padding: 12px; }
.error { color: #9a1e26; }

The local page fit a 375-pixel viewport without horizontal scrolling, and its focus outline remains visible during keyboard navigation. Keep the viewport declaration so the layout follows a phone’s width.

Verify the form and weather response

The app needs your Weatherstack key to retrieve live conditions. The fixture result shown here demonstrates rendering only, and its zero-degree value is not a measured London temperature.

Step 6: Start the server and submit a city

Run the server from the project root so Node can load .env, then open http://localhost:3000 in your browser. Type London and select Get weather to make one lookup with your account.

node --env-file=.env server.js

The startup message is Weather app listening on 3000 when you use the default port. Stop the process with Ctrl+C before starting another copy, or choose an available port through the PORT environment variable.

CheckExpected result
Open the pageCity name field and Get weather button
Submit a valid city with a valid keyResolved location and temperature in °C
Submit spacesHTTP 400 and a city-name message
Leave the placeholder key unchangedProvider rejection, not a temperature
Inspect the returned page sourceNo WEATHERSTACK_KEY value

I left the placeholder key in .env for a live rejection check, and the form showed Weatherstack rejected the server access key. That confirms the failure path, not authenticated weather retrieval.

City weather form showing London test fixture at zero degrees Celsius
Demonstration response from a test fixture, not a live weather observation.

Test the handler without consuming weather calls

Save server.test.js beside server.js to exercise the routes and template with labelled response fixtures, without consuming Weatherstack calls.

const test = require('node:test');
const assert = require('node:assert/strict');
const { once } = require('node:events');
const { createApp } = require('./server');

async function withServer(options, check) {
  const server = createApp(options).listen(0, '127.0.0.1');
  await once(server, 'listening');
  const base = `http://127.0.0.1:${server.address().port}`;
  const submit = (city) => fetch(base, {
    method: 'POST', body: new URLSearchParams({ city })
  });
  try { await check(base, submit); }
  finally { await new Promise(resolve => server.close(resolve)); }
}

test('fixture success, URL encoding and escaped output', async () => {
  const fixture = {
    location: { name: '<London>', country: 'United Kingdom' },
    current: { temperature: 0 }
  };
  await withServer({ apiKey: 'test-only-key', fetchWeather: async url => {
    assert.equal(url.searchParams.get('query'), 'New York & Queens');
    assert.equal(url.searchParams.get('units'), 'm');
    return Response.json(fixture);
  } }, async (base, submit) => {
    const first = await fetch(base);
    assert.equal(first.status, 200);
    assert.equal((await fetch(`${base}/style.css`)).status, 200);
    const response = await submit(' New York & Queens ');
    const html = await response.text();
    assert.equal(response.status, 200);
    assert.match(html, /&lt;London&gt;, United Kingdom: 0 °C/);
    assert.doesNotMatch(html, /test-only-key/);
  });
});

test('invalid input stops before calling the provider', async () => {
  await withServer({ apiKey: 'test-only-key', fetchWeather: async () => {
    assert.fail('Provider must not be called');
  } }, async (base, submit) => {
    for (const city of ['   ', 'x'.repeat(101), 'London;Paris']) {
      assert.equal((await submit(city)).status, 400);
    }
    const duplicate = await fetch(base, {
      method: 'POST', body: new URLSearchParams([['city', 'London'], ['city', 'Paris']])
    });
    assert.equal(duplicate.status, 400);
    assert.equal((await submit('x'.repeat(3000))).status, 413);
  });
});

test('provider errors, bad JSON, missing fields and timeouts', async () => {
  const responses = [
    () => new Response('Unavailable', { status: 503 }),
    () => Response.json({ success: false,
      error: { type: 'invalid_access_key' } }),
    () => Response.json({ success: false, error: { type: 'request_failed' } }),
    () => new Response('not JSON'),
    () => Response.json({ location: {}, current: {} }),
    () => { throw new DOMException('Timed out', 'TimeoutError'); },
    () => { throw new TypeError('Network error'); }
  ];
  for (const response of responses) {
    await withServer({ apiKey: 'test-only-key', fetchWeather: async () => response() },
      async (base, submit) => assert.equal((await submit('London')).status, 502));
  }
  await withServer({ apiKey: '' }, async (base, submit) => {
    assert.equal((await submit('London')).status, 503);
  });
});

Each test starts the app on an available local port and closes it after the checks. The success fixture uses zero and angle brackets to exercise both numeric validation and escaped output, while the failure cases keep network errors separate from invalid input.

node --test --test-reporter=tap server.test.js
Node test runner reporting three passing weather app test groups
Fixture tests cover output escaping, input validation and provider failures.

The executed test run passed all three groups with no failures. Those results cover the local behavior, while your account key and provider allowance remain requirements for a successful live lookup.

Handle input and Weatherstack errors

A provider failure should leave the form usable rather than turning a JSON parse error into a crashed request. The official Weatherstack response schema separates weather fields from the success and error fields.

Visible failureCause or boundaryAction
Enter one city name, up to 100 characters.Blank, repeated or oversized city field, or bulk delimiterSubmit one location without a semicolon
The server needs a Weatherstack access key.WEATHERSTACK_KEY is empty or absentFill .env and restart with –env-file=.env
Weatherstack rejected the server access key.Provider reports a missing or invalid keyCopy the account key without logging it
Weatherstack could not complete this lookup.Provider returned another error objectInspect the account allowance and requested location
Weather service unavailable. Try again later.Provider returned a non-success HTTP statusWait before another request
Weather service returned incomplete data.Expected location or temperature field is absentInspect the provider contract before changing the template
Weather lookup timed out. Try again.Request exceeded the timeoutRetry deliberately rather than in a loop
The request could not be processed.Body exceeded the parser limit or another server error occurredReturn to the city form and submit a short value

Use the Express route-matching walkthrough if a submission reaches the wrong handler. A port already in use is a startup problem, so stop the conflicting process you own or select another port before debugging Weatherstack.

For public deployment, server-side key storage does not stop visitors from consuming your allowance through the form. Add request limiting and a bounded cache before exposing the endpoint, and keep both the browser connection and provider connection on HTTPS.

Keep the provider key behind the server

Try encoding a city that contains an ampersand before adding another provider parameter. The encoded value stays inside query instead of becoming a separate parameter.

node -e "console.log(new URLSearchParams({ query: 'New York & Queens', units: 'm' }).toString())"

The command prints query=New+York+%26+Queens&units=m. If you add Fahrenheit later, validate the selected unit on the server and change the displayed label along with the provider request.

Weather app questions

Weatherstack’s endpoint and plan boundaries decide how far you can extend this city-search project.

Can I build this weather web app without React?

Yes. Express renders the EJS template after the lookup, so a normal HTML form can submit the city without a client-side framework.

Can this form show a weather forecast?

The /current endpoint returns current conditions. Weatherstack lists forecast access on Professional and higher plans, so a forecast needs a suitable plan and its own response handling.

Do I need a database for this city-search app?

No database is needed for one lookup and its returned page. Saving favorite locations or keeping a shared cache adds storage requirements that this example does not implement.

Pankaj Kumar
Pankaj Kumar

Pankaj Kumar is the founder and CEO of CodeForGeek, with more than 14 years in IT. He is an open-source enthusiast who enjoys sharing what he learns through CodeForGeek and YouTube, with a focus on Python, data analytics, machine learning, Angular, Node.js, and Kafka.

Articles: 337