DEV Community

Cover image for I Built a Weather-Aware Truck Router with React and Django (and Why the Fastest Route Isn't Always the Right One)
OLUWAYEMI FISAYO NATHANIEL
OLUWAYEMI FISAYO NATHANIEL

Posted on

I Built a Weather-Aware Truck Router with React and Django (and Why the Fastest Route Isn't Always the Right One)

If you're driving a car, the fastest route is usually the best route. If you're driving a fully loaded 18-wheeler, 45 mph crosswinds or a band of heavy snow somewhere along the way can turn "fastest" into "dangerous" fast.

So I built Weather-Aware Truck Routing: a web app that takes a trip, gets three truck routes, checks the weather at every checkpoint for the hour the truck will actually be there, scores the risk, and recommends the safest route. Drive time is only used as the final tie-breaker.

It started as a time-boxed full-stack engineering challenge, and I ended up enjoying it way more than I expected.

Weather-Aware Truck Routing planning a trip from Denver to Chicago


What the app does

  • Plan a trip: enter an origin and destination (with search-as-you-type), a departure time, the load weight, and how often you want weather checkpoints: every 10, 25 or 50 miles.
  • See 3 routes on a map: the selected route is colored by risk along its whole length. Each checkpoint shows its arrival time, wind, rain, snow, risk level and the reason for that level.
  • Get a recommendation that explains itself: not just "take Route B", but why, e.g. "Fewer Severe miles than Route A: 0 vs 42 mi", plus how much extra time it costs compared to the fastest route.
  • Scrub through the next 48 hours: a forecast slider (with a play button) animates a weather heatmap over the trip corridor, and a truck icon shows where you'd be at that hour.
  • Compare routes side by side: distance, drive time, arrival time, miles at each risk level and average risk.

The stack

Frontend

Tech What I used it for
React 19 UI
TypeScript Typed API client, typed responses, fewer surprises
Material UI (MUI 6) + Emotion Components, theming, layout
Leaflet + React-Leaflet 5 Interactive map, route lines, checkpoints, heatmap layer, truck marker
Vite Dev server and build (proxies /api to Django locally)
ESLint Linting

Backend

Tech What I used it for
Python 3.12 Language
Django 5.2 Web framework, settings, caching
Django REST Framework API views, request validation (serializers), consistent error handling
requests Calling TomTom and Open-Meteo
python-dotenv Loading config from .env
Redis (optional) Shared cache; falls back to in-memory caching if not set
Gunicorn Production WSGI server
WhiteNoise Serving the built React app straight from Django

External APIs and services

Service Role
TomTom Truck routing (with vehicle weight), route alternatives, geocoding and place suggestions
Open-Meteo Hourly forecasts: wind, rain, showers, snowfall
OpenStreetMap Map tiles

DevOps

Tech Role
Docker (multi-stage build) Builds the React app, then packages it with the Django API into one image
Render (Blueprint via render.yaml) Hosting on the free plan, one service, one URL
GitHub Actions Runs the backend tests and the frontend build on every push and PR

Fun fact: none of the services needs a payment card.


How a trip gets planned

When you hit "Plan trip", the backend runs this pipeline:

  1. Places – the typed text is geocoded with TomTom (skipped if you picked a suggestion that already has coordinates).
  2. Routes – TomTom's truck routing returns the best route plus up to 2 alternatives. I pass the gross vehicle weight (load + a 35,000 lb tractor-trailer, capped at 80,000 lb) so weight-restricted roads are avoided. If fewer than 3 distinct routes come back, the service asks for routes through points on either side of the trip's midpoint to force some variety.
  3. Checkpoints – placed at mile 0, every N miles, and at the destination.
  4. Weather – one batched Open-Meteo request fetches hourly wind, rain and snow for every checkpoint and every heatmap cell.
  5. Risk – each checkpoint uses the forecast hour closest to its arrival time and gets classified by the rules below.
  6. Ranking – routes are sorted and the deciding rule is explained in plain English.
  7. Heatmap – a grid of cells within about 30 miles of every route, with risk precomputed for all 49 hours (0–48).

The risk rules

Condition Low Moderate High Severe No Travel
Wind < 25 mph 25–34 35–44 45–54 ≥ 55 mph
Rain < 0.10 in/hr 0.10–0.25 0.25–0.50 0.50–1.00 > 1.00
Snow < 0.5 in/hr 0.5–1.0 1.0–2.0 2.0–3.0 > 3.0

Then there are load rules, because a heavy truck in strong wind is a different story:

  • ≥ 55 mph wind → No Travel for any load
  • 45–54 mph with more than 30,000 lb → No Travel
  • 35–44 mph with more than 40,000 lb → Severe

A checkpoint's final level is the worst of wind (after load rules), rain and snow. All of this lives in one small file, written to read exactly like the table:

def apply_load_rules(wind_level: RiskLevel, load_weight_lb: int) -> RiskLevel:
    """Heavy loads are riskier in strong wind (the wind level already covers the ≥55 mph rule)."""
    if wind_level == RiskLevel.SEVERE and load_weight_lb > NO_TRAVEL_IN_SEVERE_WIND_ABOVE_LB:
        return RiskLevel.NO_TRAVEL
    if wind_level == RiskLevel.HIGH and load_weight_lb > SEVERE_IN_HIGH_WIND_ABOVE_LB:
        return RiskLevel.SEVERE
    return wind_level


def assess(weather: WeatherReading, load_weight_lb: int) -> RiskAssessment:
    wind_level = classify_wind(weather.wind_mph)
    loaded_wind_level = apply_load_rules(wind_level, load_weight_lb)
    rain_level = classify_rain(weather.rain_in_hr)
    snow_level = classify_snow(weather.snow_in_hr)
    # ...build human-readable reasons...
    return RiskAssessment(level=max(loaded_wind_level, rain_level, snow_level), reasons=reasons)
Enter fullscreen mode Exit fullscreen mode

Every checkpoint also carries a list of reasons like "Wind 47 mph → Severe" and "42,000 lb load in that wind → No Travel", so the driver never has to guess why something is red.


Picking the winner

Routes are ranked by, in order:

  1. Fewest No-Travel miles (I added this one so a route you shouldn't drive can never win on other numbers)
  2. Fewest Severe miles
  3. Fewest High miles
  4. Lowest average risk (weighted by miles, 0 = Low to 4 = No Travel)
  5. Shortest travel time

In Python this turned out to be a one-liner, because tuples compare element by element:

def rank_key(route: RouteDto) -> tuple:
    s = route.summary
    return (s.no_travel_miles, s.severe_miles, s.high_miles, s.average_risk, route.duration_seconds)

ranked = sorted(routes, key=rank_key)
Enter fullscreen mode Exit fullscreen mode

To explain the result, the service walks the same criteria in order and finds the first one where the winner beats the runner-up. That becomes the headline reason shown in the UI.


The parts I found most interesting

1. Weather is checked for when you'll be there, not now

A storm that's over Nebraska right now might be gone by the time you get there, or one might roll in. So every checkpoint gets an estimated arrival time, and the risk uses the forecast for that hour.

I don't just divide distance by average speed either. TomTom returns a distance/time profile along the route, and I interpolate on that, so slow city stretches and fast interstates are timed correctly:

def estimate_arrival(route, fraction_of_route, departure_time):
    """Uses the provider's (distance, time) profile, so slow city stretches and fast highways are respected."""
    meters = fraction_of_route * route.distance_meters
    distances = [m for m, _ in route.time_profile]
    seconds = [s for _, s in route.time_profile]
    return departure_time + timedelta(seconds=interpolate(meters, distances, seconds))
Enter fullscreen mode Exit fullscreen mode

2. Checkpoints that "cover" road, so the miles always add up

To say "Route A has 42 Severe miles", each checkpoint needs to stand for a stretch of road. Each one covers the road halfway back to the previous checkpoint and halfway forward to the next. That way the miles per risk level always add up to the full route length, whether you chose 10, 25 or 50 mile spacing.

3. Fewer weather calls with grid snapping + caching

Three routes between the same cities overlap a lot. So before fetching weather, every point is snapped to a 0.1° grid (roughly 7 miles). Points in the same cell share one forecast, all missing cells go out in one batched request, and results are cached for 30 minutes (in memory, or Redis if REDIS_URL is set).

4. Getting around shared-IP rate limits on free hosting

This one surprised me. Open-Meteo's free tier is limited per IP address, and on free hosting, lots of apps share the same outbound IP. So the server could hit the limit without me doing anything wrong.

The fix: on the live site, the visitor's browser downloads the forecasts, while the server still does all the risk work.

  • The browser asks the server to plan a trip.
  • The server works out exactly which forecasts it needs and responds with the exact Open-Meteo request URLs.
  • The browser fetches them (Open-Meteo allows calls from any website) and sends the raw responses back.
  • The server parses them and finishes the plan.

Each visitor uses their own free allowance, and browser-supplied data is only used for that one request, never cached for other visitors. It's controlled by one flag, WEATHER_FROM_BROWSER.

5. A slider that needs zero network calls

The 0–48 hour heatmap is computed up front for all 49 hours in the same response. So scrubbing or hitting play on the slider is instant: React just swaps which hour it's drawing.

6. Demo mode without touching service code

A tiny container.py builds each service once and wires its dependencies. Flip USE_FAKE_PROVIDERS=true and it swaps the real TomTom and Open-Meteo clients for fake ones with built-in data. No API key, no internet, and the same fakes power the tests.

@lru_cache(maxsize=1)
def tomtom_client():
    if settings.USE_FAKE_PROVIDERS:
        return FakeTomTomClient()
    return TomTomClient(settings.TOMTOM_API_KEY, settings.GEOCODE_COUNTRY_SET)
Enter fullscreen mode Exit fullscreen mode

7. Thin views, logic in services

The DRF views do three things: validate, call one service, return. All the actual logic lives in small services (places, routes, checkpoints, weather, risk, assessment, recommendation, heatmap, and a planner that ties them together). Errors always come back in one shape, { "detail": "..." }: 400 for bad input, 422 when a place or route can't be found, 502 when an upstream API fails.

class TripPlanView(APIView):
    def post(self, request):
        trip = TripPlanRequestSerializer(data=request.data)
        trip.is_valid(raise_exception=True)
        return Response(to_json(container.trip_planner().plan_trip(trip.to_trip_request())))
Enter fullscreen mode Exit fullscreen mode

Testing and CI

There are 44 backend tests covering the risk rules (including the tricky boundaries like exactly 0.25 in/hr), ranking, arrival times, the heatmap, the API client parsers, the API endpoints and the browser-weather flow.

GitHub Actions runs two jobs on every push and pull request:

  • backend: install requirements, run python manage.py test
  • frontend: npm ci and npm run build (which also type-checks with tsc)

Deployment

I wanted one service and one URL, so the Dockerfile is a two-stage build:

  1. A Node 22 image builds the React app into static files.
  2. A Python 3.12 image installs the Django API and copies the built frontend in. WhiteNoise serves it at /, and Gunicorn runs the app.

On Render it's a Blueprint: point it at the repo, it reads render.yaml, generates the Django secret key, and you only add your TomTom API key. There's a health check at /api/health.

API at a glance

Method Path Purpose
POST /api/trips/plan Plan a trip (routes, checkpoints, risk, recommendation, heatmap)
GET /api/places/suggest?q=denv Place suggestions for the search box
GET /api/health Health check (also reports demo mode)

What I'd add next

  • Hours-of-Service rest breaks. Right now arrival times assume continuous driving. Real drivers have legally required breaks, and those shift when they hit each stretch of weather.
  • Suggesting a better departure time when every route has No-Travel conditions, instead of just warning about it.

Run it yourself

# Backend (Python 3.12)
cd backend
python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env             # add TOMTOM_API_KEY, or set USE_FAKE_PROVIDERS=true
python manage.py runserver

# Frontend (Node 22)
cd frontend
npm install
npm run dev                      # http://localhost:5173
Enter fullscreen mode Exit fullscreen mode

No TomTom key yet? Set USE_FAKE_PROVIDERS=true and try Denver to Chicago on the demo data.


Wrapping up

The biggest lesson for me: the hard part wasn't calling the APIs, it was making the answer trustworthy. Timing weather to the actual arrival hour, making sure the miles add up, and explaining every decision in plain words is what turns "a map with some colors" into something a driver could actually rely on.

If you've built anything with routing or weather data, or you have ideas for what a driver would want next, I'd love to hear about it in the comments. And if you check out the repo, a ⭐ is always appreciated.

Routing and geocoding by TomTom. Weather by Open-Meteo. Map tiles © OpenStreetMap contributors.

Top comments (0)