Building an Hourly Temperature Explorer with Astro, TypeScript and Open-Meteo
External APIs are a good way to build small applications that still involve several real-world development concerns: asynchronous communication, data validation, error handling, third-party data structures and UI updates.
For this project, I wanted to keep the application deliberately simple.
The user provides three values:
- latitude;
- longitude;
- date.
The application sends the request to Open-Meteo and displays the hourly temperatures for the selected geographical location and day.
There is no weather dashboard, authentication layer or large frontend framework. The objective is to focus on the API integration itself and keep the architecture small enough to understand at a glance.
Source code
The complete project is available on GitHub:
github.com/sfestacatenate/astro-open-meteo-test
The application
The interface contains a small form with latitude, longitude and date fields.
The default coordinates point to London:
Latitude: 51.5074
Longitude: -0.1278
After selecting a date and submitting the form, the application retrieves the hourly temperature data and presents it in two different ways:
- an SVG line chart, with the hour on the X axis and temperature on the Y axis;
- a complete list containing the temperature for every timestamp returned by the API.
The complete application flow is intentionally straightforward:
User input
|
v
Validation
|
v
Open-Meteo API
|
v
Typed response
|
v
Application model
|
+----> SVG chart
|
+----> Hourly temperature list
Even with such a small use case, there is enough application logic to justify separating responsibilities instead of placing everything inside a single Astro page.
Technology stack
The application uses:
- Astro;
- TypeScript;
- the native Fetch API;
- native SVG;
- Open-Meteo.
One deliberate decision: there is no React, Vue or Svelte component inside the project.
For an application with one form, one API request and one simple time series, adding another UI runtime would not provide much value.
Astro can render the static structure of the page while a small amount of client-side TypeScript handles the interactive part.
The same reasoning applies to the chart. Drawing a single line with roughly one point per hour does not necessarily require Chart.js, D3 or another visualization dependency.
Native SVG is enough.
Project structure
The source code is organized like this:
src/
├── components/
│ ├── TemperatureChart.astro
│ ├── TemperatureResults.astro
│ └── WeatherSearchForm.astro
│
├── layouts/
│ └── BaseLayout.astro
│
├── pages/
│ └── index.astro
│
├── scripts/
│ ├── temperature-chart.ts
│ └── weather-app.ts
│
├── services/
│ └── open-meteo.ts
│
├── styles/
│ └── global.css
│
├── types/
│ └── weather.ts
│
└── utils/
├── date.ts
└── validation.ts
The important point is not the number of folders. The important point is that each part has a clear responsibility.
The Astro components define presentation boundaries.
The browser scripts handle interaction and DOM updates.
The service is responsible for communicating with Open-Meteo.
The types directory contains the application and API contracts, while the utility modules contain reusable date and validation logic.
For a small project, I would not add more architectural layers than this. The objective is separation of concerns, not abstraction for its own sake.
Defining the data contracts
Before calling the API, it is useful to define the data used internally by the application.
The query is very small:
export interface WeatherQuery {
latitude: number;
longitude: number;
date: string;
}
The application does not need every piece of information returned by Open-Meteo. For the hourly series, it only needs a timestamp and a temperature:
export interface HourlyTemperature {
time: string;
temperature: number;
}
The final application model contains some additional contextual information:
export interface WeatherResult {
latitude: number;
longitude: number;
timezone: string;
timezoneAbbreviation: string;
elevation: number;
temperatureUnit: string;
source: WeatherDataSource;
hours: HourlyTemperature[];
}
I prefer exposing this application-oriented object to the UI rather than passing the raw Open-Meteo response around the application.
This creates a clear boundary:
Open-Meteo response
|
v
open-meteo.ts
|
v
WeatherResult
|
v
UI
If the external API changes, or if another weather provider is introduced later, most of the UI does not need to know about it.
Building the Open-Meteo request
The API integration lives in services/open-meteo.ts.
Two endpoints are used:
const FORECAST_ENDPOINT =
'https://api.open-meteo.com/v1/forecast';
const ARCHIVE_ENDPOINT =
'https://archive-api.open-meteo.com/v1/archive';
The application chooses the appropriate data source according to the selected date.
Recent and future dates use the Forecast API, while older dates use the Historical Weather API.
The UI does not make this decision itself. It simply calls:
getHourlyTemperatures(query)
The service then determines which endpoint is required.
The request URL is built with URL and URLSearchParams:
const url = new URL(endpoint);
url.search = new URLSearchParams({
latitude: String(query.latitude),
longitude: String(query.longitude),
hourly: 'temperature_2m',
start_date: query.date,
end_date: query.date,
timezone: 'auto',
temperature_unit: 'celsius'
}).toString();
Only one weather variable is requested:
temperature_2m
There is no reason to download humidity, precipitation, wind speed or other values that the application never uses.
The selected date is passed as both start_date and end_date, because the application is interested in exactly one day.
Why timezone=auto matters
One small parameter deserves particular attention:
timezone=auto
Latitude and longitude identify the location being queried, but the browser running the application may be located somewhere completely different.
For example:
Browser: Rome
Coordinates: London
The hourly data should represent the selected day according to the requested geographical location, not according to the browser timezone.
By asking Open-Meteo to automatically resolve the timezone from the coordinates, the returned timestamps can be used as the local hours for that location.
This also means that the application does not need to maintain its own coordinate-to-timezone mapping.
Fetching and transforming the response
The actual HTTP request uses the native Fetch API:
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json'
},
signal
});
The optional signal comes from an AbortController, which allows an in-flight request to be cancelled when necessary.
After parsing the JSON, API errors are handled before the response is transformed into the application model.
The hourly temperatures are created by combining the timestamps and temperature arrays returned by Open-Meteo:
const hours = payload.hourly.time.flatMap(
(time, index) => {
const temperature =
payload.hourly.temperature_2m[index];
return temperature == null
? []
: [{ time, temperature }];
}
);
Using flatMap() here also makes it easy to discard a temperature value if the provider returns null.
The UI therefore receives a much simpler structure:
[
{
time: '2026-08-20T00:00',
temperature: 17.4
},
{
time: '2026-08-20T01:00',
temperature: 17.1
}
]
instead of dealing directly with parallel arrays.
Keeping API logic out of the browser orchestration
The client-side application is initialized in weather-app.ts.
Its job is different from the API service.
It finds the relevant DOM elements, listens for form submission, manages loading and error states and renders the returned information.
Conceptually:
const query = readQuery(form);
const weather = await getHourlyTemperatures(
query,
activeController.signal
);
renderResults(weather, query.date, ...);
Notice what is missing here.
- There is no Open-Meteo endpoint.
- There are no query parameters.
- There is no knowledge of the JSON structure returned by the provider.
weather-app.ts only knows that it can submit a WeatherQuery and receive a WeatherResult.
That boundary is one of the main reasons for keeping the service separate even in a small project.
Rendering the hourly values
The text representation of the result is intentionally simple.
Each entry is created with DOM APIs:
function createTemperatureRow(
hour: HourlyTemperature,
unit: string
): HTMLElement {
const row = document.createElement('div');
row.className = 'temperature-row';
const time = document.createElement('time');
time.dateTime = hour.time;
time.textContent = formatHour(hour.time);
const temperature = document.createElement('strong');
temperature.textContent =
`${hour.temperature.toFixed(1)} ${unit}`;
row.append(time, temperature);
return row;
}
This produces values such as:
00:00 17.4 °C
01:00 17.1 °C
02:00 16.8 °C
...
The complete list remains useful even after adding the chart because it provides precise values instead of only a visual approximation.
Drawing the chart without a charting library
The graphical representation is implemented in temperature-chart.ts.
The chart uses a standard SVG coordinate system with explicit dimensions and margins:
const WIDTH = 760;
const HEIGHT = 360;
const MARGIN = {
top: 24,
right: 24,
bottom: 58,
left: 64
};
Each hourly temperature needs to be converted into an (x, y) coordinate.
Calculating the X coordinate
The horizontal coordinate depends on the position of the point in the returned time series:
const xForIndex = (index: number): number => {
if (hours.length === 1) {
return MARGIN.left + plotWidth / 2;
}
return (
MARGIN.left +
(index / (hours.length - 1)) * plotWidth
);
};
Calculating the Y coordinate
The vertical coordinate maps the temperature into the available chart height:
const yForTemperature = (
temperature: number
): number =>
MARGIN.top +
((scale.max - temperature) /
(scale.max - scale.min)) *
plotHeight;
SVG has its origin in the top-left corner, so larger Y coordinates move downward. This is why the temperature calculation appears inverted.
Creating the line
The generated points are joined into an SVG path:
const pathData = points
.map(
(point, index) =>
`${index === 0 ? 'M' : 'L'} ` +
`${point.x.toFixed(2)} ` +
`${point.y.toFixed(2)}`
)
.join(' ');
The first point uses the SVG M command to move to the initial position, while all following points use L to draw line segments.
The result is a simple temperature curve for the selected day.
Creating a readable temperature scale
Using the exact minimum and maximum temperature values as the chart boundaries would make the graph look cramped.
Instead, the renderer adds some padding around the real range and calculates a convenient step size.
The scale can therefore become something like:
Real values: 13.8 °C → 22.7 °C
Displayed scale: 12.0 °C → 24.0 °C
The Y axis is then divided into a small number of readable ticks.
This is one of the reasons I kept the SVG renderer in its own module: chart calculations are a separate responsibility from API access and DOM orchestration.
Not assuming exactly 24 points
A tempting implementation would be:
for (let hour = 0; hour < 24; hour++) {
// render temperature
}
The application deliberately does not do this.
Instead, it renders every timestamp returned by Open-Meteo.
This is a safer model when timezones and daylight-saving transitions are involved.
The API is treated as the source of truth for the available hourly timestamps. The UI does not manufacture a fixed 24-row structure on its own.
Loading, cancellation and errors
External APIs introduce another concern that purely local applications do not have: requests can fail or take time.
When a request starts, the submit button is disabled and its label changes from:
Get temperatures
to:
Loading...
Errors are displayed in a dedicated message area instead of being left as unhandled promise rejections.
The application also creates an AbortController for the request:
activeController?.abort();
activeController = new AbortController();
and passes its signal to the API service.
This pattern is useful whenever a previous request becomes irrelevant after a new user action.
Even in a small application, handling loading, failure and cancellation explicitly makes the API integration easier to reason about.
Why Astro works well here
This project is also a useful example of what Astro does not require.
Using Astro does not mean that every interactive feature needs React.
Most of this page is static markup. Only form submission, API communication, result rendering and the SVG chart require browser-side JavaScript.
The architecture can therefore remain:
Astro
|
+---- static page structure
|
+---- client-side TypeScript
|
+---- Fetch API
|
+---- DOM updates
|
+---- SVG rendering
For this particular application, introducing React would add another abstraction without solving a problem that the platform APIs cannot already handle cleanly.
That does not make React unnecessary in general. It simply means that the complexity of the solution should match the complexity of the application.
Running the project
Clone the repository:
git clone https://github.com/sfestacatenate/astro-open-meteo-test.git
cd astro-open-meteo-test
Install the dependencies:
npm install
Start the development server:
npm run dev
Create a production build:
npm run build
Preview the generated build locally:
npm run preview
Possible extensions
The application is intentionally limited to one use case, but there are several natural ways to extend it without turning it into a full weather dashboard.
- add a Celsius/Fahrenheit selector;
- retrieve the browser position through the Geolocation API;
- store latitude, longitude and date in the URL;
- add unit tests for validation and endpoint selection;
- add end-to-end tests;
- compare temperatures from two different dates;
- retrieve additional Open-Meteo variables such as precipitation or wind speed.
I would still keep each extension focused.
The interesting part of this project is precisely that the complete flow from user input to external API to visualization remains easy to follow.
Final thoughts
Calling a REST endpoint with fetch() is easy.
Designing the small amount of code around that call is more interesting.
Even this deliberately limited application has to answer several questions:
- Where should API-specific code live?
- What data should the UI receive?
- How should external responses be typed?
- How should invalid input and failed requests be handled?
- Which timezone should define the selected day?
- How much frontend framework code is actually necessary?
- Is a charting dependency justified for a single time series?
For this project, the answer was a small Astro application with clear module boundaries, a dedicated Open-Meteo service, TypeScript contracts and a dependency-free SVG visualization.
The result is not intended to be a weather dashboard or a production weather platform.
It is a compact example of how a third-party REST API can be integrated into an Astro application without making the surrounding architecture more complicated than the problem requires.
Data source: Weather data is provided by Open-Meteo .

Comments
Post a Comment