New to Rust? Grab our free Rust for Beginners eBook Get it free →
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.
| Component | Job | Data it receives |
|---|---|---|
| Browser | Submits the city and displays the page | City name, temperature or an error |
| Express server | Validates input and requests current conditions | City name and the server-only key |
| Weatherstack | Answers the /current request | Encoded location query and account key |
| EJS template | Escapes values and renders the form | Selected 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.

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.
| Check | Expected result |
|---|---|
| Open the page | City name field and Get weather button |
| Submit a valid city with a valid key | Resolved location and temperature in °C |
| Submit spaces | HTTP 400 and a city-name message |
| Leave the placeholder key unchanged | Provider rejection, not a temperature |
| Inspect the returned page source | No 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.

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, /<London>, 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

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 failure | Cause or boundary | Action |
|---|---|---|
| Enter one city name, up to 100 characters. | Blank, repeated or oversized city field, or bulk delimiter | Submit one location without a semicolon |
| The server needs a Weatherstack access key. | WEATHERSTACK_KEY is empty or absent | Fill .env and restart with –env-file=.env |
| Weatherstack rejected the server access key. | Provider reports a missing or invalid key | Copy the account key without logging it |
| Weatherstack could not complete this lookup. | Provider returned another error object | Inspect the account allowance and requested location |
| Weather service unavailable. Try again later. | Provider returned a non-success HTTP status | Wait before another request |
| Weather service returned incomplete data. | Expected location or temperature field is absent | Inspect the provider contract before changing the template |
| Weather lookup timed out. Try again. | Request exceeded the timeout | Retry deliberately rather than in a loop |
| The request could not be processed. | Body exceeded the parser limit or another server error occurred | Return 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.




