A weather site is close to the worst possible candidate for static generation. The whole point of the content is that it changes: temperature now, forecast for the next sixteen days, and a clock that has to be right to the second. Every instinct says application server.
I run worldtimeweather.com — local time, current conditions, a 16-day forecast and monthly climate normals for 400 cities in 199 countries, in three languages. There is no application server. There is no build framework. There are roughly 95,000 pre-rendered HTML files on disk, a JSON API that is also just files on disk, and one cron job that rewrites them every thirty minutes.
This post is about why that trade is a good one more often than people expect, and about the three problems that turned out to be genuinely interesting.
The shape of it
The entire system is:
cron (*/30) → python generators → flat files → nginx/Apache
The generators pull current conditions from Open-Meteo, do their own math for anything that can be computed rather than fetched, and write HTML and JSON to disk. The web server does what web servers were originally good at: hands over files.
What this buys is not performance in the benchmark sense — though a flat file is hard to beat. What it buys is the absence of a class of problems. There is no connection pool to exhaust, no process to restart, no slow query under load, no 3am page because traffic arrived. A burst of traffic to a static file is a bandwidth question, not an architecture question.
What it costs is latency of change. Every edit means regeneration. If you want a value on the page to be fresher than thirty minutes, this design says no. That is the real trade, and it is worth stating plainly rather than pretending the approach is free.
Problem 1: the clock
The obvious objection to pre-rendering a time-and-weather page is the clock. A file written at 12:00 is wrong at 12:01.
The clock is the one thing that genuinely belongs in the browser. What the server needs to ship is not the time — it is the offset: the IANA zone, the current UTC offset in seconds, and the winter/summer offsets so the page can reason about DST without another request.
"time": {
"timezone": "Europe/Madrid",
"utc_offset_seconds": 7200,
"utc_offset": "UTC+2",
"dst": { "observes_dst": true, "winter_offset_seconds": 3600, "summer_offset_seconds": 7200 }
}
With that baked into the page, a few lines of JavaScript render a clock that is correct indefinitely, and the file never goes stale for the reason you would expect it to.
The general principle held for the rest of the build: separate what changes from what changes fast. Coordinates never change. Timezone rules change a couple of times a year, not every thirty minutes. Climate normals are recomputed once a season. Only current conditions actually need the cron cadence, and they are a small part of the page.
Problem 2: sunrise and sunset without 400 API calls
Every city page shows sunrise and sunset. The lazy version calls a sunrise API 400 times per generation run — 19,200 requests a day, to fetch something that is a deterministic function of latitude, longitude and date.
The NOAA solar position algorithm is roughly thirty lines of arithmetic and lands within about five minutes of the published value. That is well inside what anybody needs from a "sunset at 20:42" line on a page. Computing it offline removed a dependency, removed a rate limit, and removed a failure mode.
Two edge cases are worth knowing before you implement it:
-
Polar day and polar night. Above the Arctic Circle in summer, the equation has no solution because the sun never sets. Handle the null explicitly, or you will ship
NaN:NaNto Tromsø. - The definition of "sunrise". NOAA's standard zenith of 90.833° includes atmospheric refraction and the solar disc radius. If you use a plain 90°, you will be consistently a couple of minutes off and it will look like a bug.
The wider lesson: before adding an API call, check whether the value is a function rather than a fact. Solar position is a function. Weather is a fact. Only the second one needs the network.
Problem 3: clean URLs on shared hosting, where nginx sees the request first
This one cost the most hours, and it is the one I could find the least written about.
The hosting stack is nginx in front of Apache — a very common shared-hosting arrangement. nginx serves static files directly and only proxies to Apache what it cannot handle itself. That is exactly the behaviour you want for performance, and it quietly breaks the usual approach to extensionless URLs.
The normal recipe is a rewrite in .htaccess: request /madrid, internally serve /madrid.html. But .htaccess is Apache's, and in this stack Apache never sees the request when the file exists. Worse, the rules interact in a way that produces redirect loops if you write them the obvious way.
What works is being explicit that the rewrite only applies when the target file exists and the request itself is not already a file:
RewriteEngine On
# Do not rewrite anything that is already a real file or directory
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Only rewrite if the .html version actually exists
RewriteCond %{REQUEST_FILENAME}.html -f
RewriteRule ^(.*)$ $1.html [L]
The second condition is the one people leave out. Without it, every 404 becomes a rewrite to a file that does not exist, and the error handling gets strange. With it, the rule is a no-op for anything unexpected.
If you control nginx, you would do this with try_files and never think about it again. On shared hosting you usually do not, and this is the version that survives.
The API is the same files
Because everything is already on disk as JSON, an API came almost for free:
https://worldtimeweather.com/api/v1/city/madrid.json
No key, no signup, no rate limit — not out of generosity, but because there is nothing to meter. It is a file. The same generator that writes the HTML writes the JSON, so the two can never disagree, which is a class of bug that dynamic sites have and this one structurally cannot.
The only piece of dynamic code in the entire system is a small PHP shim that adds CORS headers. That is the honest asterisk on "no backend".
When this is the wrong choice
I would not build this way if:
- Content is per-user. Static generation and personalisation are opposites. The moment the page depends on who is asking, you are writing an application.
- The data must be fresh to the second. Prices, availability, anything transactional. Thirty minutes is fine for weather and fatal for a booking system.
- The page count is unbounded. 95,000 pages regenerate in a fixed window. Ten million would not, and at that point you want on-demand rendering with a cache in front.
The heuristic I would offer: static generation works when the number of possible pages is finite and known in advance, and staleness is measured in minutes rather than milliseconds. A surprising amount of the web fits that description — documentation, catalogues, reference data, and yes, weather for a fixed list of cities.
What it actually feels like to operate
Someone commented on Reddit that the site showed Celsius and km/h for US locations. Adding Fahrenheit and mph meant editing one generator, adding two fields, and waiting for the next cron run. No deploy, no migration, no cache invalidation strategy, no worrying about whether the change would hold under load. Thirty minutes later every one of the 95,000 pages and every API response had the new fields.
That is the part that does not show up in architecture diagrams: the boring stack is boring to change too, and after a while that is worth more than the elegance of the alternative.
The API is free and keyless if you want to poke at it: worldtimeweather.com/api.html. I would genuinely like to know what field is missing from the response shape.
Top comments (1)
Author here. The thing I most want feedback on is the API response shape: worldtimeweather.com/api/v1/city/m...
If you were dropping this into a project and a field is missing (or named badly), say so. Adding a field is one generator change and a 30-minute wait, so there is no reason not to.
Also happy to go into detail on the nginx-in-front-of-Apache rewrite issue if anyone is fighting the same thing on shared hosting. It took me longer than I would like to admit.