---
title: "Build a Weather API on Vercel: Express, FastAPI, and Nitro"
description: Build a weather API on Vercel with FastAPI, Express, or Nitro. Compare the three runtimes, add caching and Observability, then deploy the route.
url: /kb/guide/weather-api-with-fastapi
canonical_url: "https://vercel.com/kb/guide/weather-api-with-fastapi"
published: 2025-11-03
last_updated: 2026-09-03
authors: Ricardo Gonzalez , Ismael Rumzan, Anshuman Bhardwaj
related:
  - /docs/frameworks/backend/fastapi
  - /docs/frameworks/backend/express
  - /docs/frameworks/backend/nitro
  - /docs/functions
  - /docs/fluid-compute
  - /docs/cli
  - /docs/functions/runtimes/python
  - /docs/caching/runtime-cache
  - /docs/observability
  - /docs/logs/runtime
  - /kb/guide/mcp-server-with-weather-tool-express
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

## How to build a weather API on Vercel with FastAPI, Express, or Nitro

A weather API takes a place name, turns it into coordinates, and returns current conditions for that location. You can build one on Vercel with [FastAPI](https://vercel.com/docs/frameworks/backend/fastapi), [Express](https://vercel.com/docs/frameworks/backend/express), or [Nitro](https://vercel.com/docs/frameworks/backend/nitro). Each one deploys as a [Vercel Function](https://vercel.com/docs/functions) that scales with traffic.

The route logic is the same in all three. What changes is the language you write it in and how much caching and monitoring you get without extra configuration.

## Which runtime should you build your weather API on?

Pick the runtime your project already uses. The route is short enough that the surrounding stack matters more than the framework.

Each one suits a different starting point:

- **FastAPI:** Python, with async support and a generated interactive documentation page at `/docs`. Choose it if your stack is already Python, or if you want that documentation page without writing it.
  
- **Express:** Node.js and TypeScript, with the least setup of the three. Choose it if you want one route file and nothing else.
  
- **Nitro:** TypeScript, with filesystem routing and a caching layer you configure rather than write. Choose it if you expect enough traffic that caching matters on day one.
  

All three run on [Fluid compute](https://vercel.com/docs/fluid-compute) by default, so the runtime you pick doesn't change how the deployment scales.

## What you need to build a weather API

All three runtimes need the same two things:

- A Vercel account
  
- The [Vercel CLI](https://vercel.com/docs/cli) installed locally
  

The rest depends on the runtime you chose:

- **FastAPI:** Python installed locally, plus familiarity with `async` and `await`. Vercel's [Python runtime](https://vercel.com/docs/functions/runtimes/python) supports 3.12, 3.13, and 3.14, with 3.12 as the default. Match your local version to one of those.
  
- **Express:** Node.js and pnpm installed locally, plus familiarity with Express routing and async TypeScript.
  
- **Nitro:** Node.js and pnpm installed locally, plus familiarity with Nitro's filesystem routing.
  

You don't need a weather API key. [Open-Meteo](https://open-meteo.com/) serves its free tier without registration.

## How the weather API route works

Open-Meteo's forecast endpoint takes coordinates, not place names. So the route makes two upstream calls before it returns anything.

The sequence is identical across the three runtimes:

1. Read the city name from the URL path and the optional `units` query parameter.
   
2. Call the [Open-Meteo geocoding API](https://open-meteo.com/en/docs/geocoding-api) to resolve that name to a latitude and longitude, returning a 404 when nothing matches.
   
3. Call the forecast endpoint with those coordinates, requesting temperature, humidity, apparent temperature, and wind speed.
   
4. Return one object combining the resolved location with the current conditions.
   

Open-Meteo returns Celsius and km/h by default, so imperial output means adding `temperature_unit=fahrenheit` and `wind_speed_unit=mph` to the forecast request. The route checks the `units` query parameter for the exact value `imperial`. Anything else falls back to metric, so a typo returns Celsius rather than an error.

Setting `timezone=auto` returns timestamps in the location's own local time, so the client doesn't have to convert from UTC.

## How to build a weather API with FastAPI

FastAPI needs one extra dependency, `httpx`, to make the two upstream calls with `async` and `await`.

### 1\. Create the project

Start from the [FastAPI boilerplate template](https://vercel.com/templates/python/fastapi-python-boilerplate), then clone the repository once Vercel has deployed it.

To scaffold locally instead, use the CLI to instantiate the project and install the dependencies:

```bash
vercel init fastapi
cd fastapi
uv sync
uv add httpx
```

Running `uv add httpx` records `httpx` in `pyproject.toml`, so Vercel installs it during deployment.

`uvicorn` serves the app locally. Vercel's Python runtime loads the FastAPI application directly, so it isn't needed in production.

### 2\. Add the weather route

In `app/main.py` and add the imports at the top of the file:

```python
import httpx
from fastapi import FastAPI, HTTPException, Query, Request
```

Then add the route after your existing ones:

```python
@app.get("/api/weather/{city}")
async def get_weather(city: str, units: str | None = Query(default="metric")):
    """
    Get current weather for a city.

    Args:
        city: City name (e.g., "London", "New York")
        units: Temperature units, "metric" (Celsius) or "imperial" (Fahrenheit)

    Returns:
        Weather data including temperature, humidity, wind speed, and location info
    """
    normalized_units = "imperial" if units == "imperial" else "metric"

    async with httpx.AsyncClient() as client:
        # Step 1: Geocode the city name to coordinates
        try:
            geo_response = await client.get(
                "https://geocoding-api.open-meteo.com/v1/search",
                params={
                    "name": city,
                    "count": 1,
                    "language": "en",
                    "format": "json"
                }
            )
            geo_response.raise_for_status()
            geo_data = geo_response.json()

            if not geo_data.get("results"):
                raise HTTPException(status_code=404, detail=f"City '{city}' not found")

            location = geo_data["results"][0]
            name = location["name"]
            country = location["country"]
            latitude = location["latitude"]
            longitude = location["longitude"]

        except httpx.HTTPError as e:
            raise HTTPException(status_code=500, detail=f"Geocoding API error: {str(e)}")

        # Step 2: Fetch current weather for those coordinates
        try:
            weather_params = {
                "latitude": latitude,
                "longitude": longitude,
                "current": "temperature_2m,relative_humidity_2m,apparent_temperature,wind_speed_10m",
                "timezone": "auto"
            }

            if units == "imperial":
                weather_params["temperature_unit"] = "fahrenheit"
                weather_params["wind_speed_unit"] = "mph"

            weather_response = await client.get(
                "https://api.open-meteo.com/v1/forecast",
                params=weather_params
            )
            weather_response.raise_for_status()
            weather_data = weather_response.json()

            return {
                "city": name,
                "country": country,
                "latitude": latitude,
                "longitude": longitude,
                "units": normalized_units,
                "current": weather_data["current"]
            }

        except httpx.HTTPError as e:
            raise HTTPException(status_code=500, detail=f"Weather API error: {str(e)}")
```

Both calls share a single `httpx.AsyncClient`, which reuses the connection between them. Each call has its own error handler, so a geocoding failure reports differently from a forecast failure.

### 3\. Run FastAPI locally

```bash
vercel dev
```

Open `http://localhost:3000/docs` to call the route through FastAPI's generated

documentation page.

## How to build a weather API with Express

Express needs no extra dependencies. The runtime's built-in `fetch` covers both upstream calls, so the route fits in one file.

### 1\. Create the project

Start from the [Express starter](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fvercel%2Ftree%2Fmain%2Fexamples%2Fexpress&template=express), then clone the repository once Vercel has deployed it.

To scaffold locally instead, use the CLI:

```bash
vercel init express
cd express
pnpm install
```

### 2\. Add the weather route

Express uses the `fetch`, `URLSearchParams`, and `AbortSignal` APIs included in Node.js, so the base route requires no additional dependency.

```tsx
app.get('/api/weather/:city', async (req, res) => {
  try {
    const city = req.params.city
    const units = req.query.units as string | undefined

    const normalizedUnits = units === 'imperial' ? 'imperial' : 'metric'

    // Step 1: Geocode the city name to coordinates
    const geoParams = new URLSearchParams({
      name: city,
      count: '1',
      language: 'en',
      format: 'json'
    })

    const geoResponse = await fetch(`https://geocoding-api.open-meteo.com/v1/search?${geoParams}`)

    if (!geoResponse.ok) {
      return res.status(geoResponse.status).json({
        error: 'Failed to fetch geocoding data'
      })
    }

    const geoData = await geoResponse.json()

    if (!geoData.results || geoData.results.length === 0) {
      return res.status(404).json({ error: `City '${city}' not found` })
    }

    const location = geoData.results[0]
    const { name, country, latitude, longitude } = location

    // Step 2: Fetch current weather for those coordinates
    const weatherParams: Record<string, string> = {
      latitude: latitude.toString(),
      longitude: longitude.toString(),
      current: 'temperature_2m,relative_humidity_2m,apparent_temperature,wind_speed_10m',
      timezone: 'auto'
    }

    if (units === 'imperial') {
      weatherParams.temperature_unit = 'fahrenheit'
      weatherParams.wind_speed_unit = 'mph'
    }

    const weatherUrlParams = new URLSearchParams(weatherParams)
    const weatherResponse = await fetch(`https://api.open-meteo.com/v1/forecast?${weatherUrlParams}`)

    if (!weatherResponse.ok) {
      return res.status(weatherResponse.status).json({
        error: 'Failed to fetch weather data'
      })
    }

    const weatherData = await weatherResponse.json()

    res.json({
      city: name,
      country,
      latitude,
      longitude,
      units: normalizedUnits,
      current: weatherData.current
    })

  } catch (error) {
    console.error('Weather API error:', error)
    res.status(500).json({
      error: 'Failed to fetch weather data',
      message: error instanceof Error ? error.message : 'Unknown error'
    })
  }
})
```

Express 5 forwards rejected async route handlers to its error-handling layer. This route catches errors locally so it can return a consistent JSON response instead of Express’s default error page.

### 3\. Run Express locally

Use the `vercel` CLI to run the project locally:

```bash
vercel dev
```

## How to build a weather API with Nitro

Nitro needs no extra dependencies either, and the route path comes from the filename rather than a registration call.

### 1\. Create the project

Start from the [Nitro starter template](https://vercel.com/templates/backend/nitro-starter), then clone the repository once Vercel has deployed it.

To scaffold locally instead, use the CLI:

```bash
vercel init nitro
cd nitro
pnpm install
```

### 2\. Add the weather route

Create `server/routes/api/weather/[city].ts` and add the route:

```typescript
import { getRouterParam, getQuery, createError } from 'h3';
import { $fetch } from 'ofetch';

export default defineEventHandler(async (event) => {
  const cityParam = getRouterParam(event, 'city');
  if (!cityParam) {
    throw createError({ statusCode: 400, statusMessage: 'city is required' });
  }

  const city = decodeURIComponent(cityParam);
  const { units } = getQuery(event) as { units?: 'metric' | 'imperial' };
  const normalizedUnits = units === 'imperial' ? 'imperial' : 'metric';

  // Step 1: Geocode the city name to coordinates
  const geo = await $fetch<{
    results?: Array<{
      name: string;
      country: string;
      latitude: number;
      longitude: number;
    }>;
  }>('https://geocoding-api.open-meteo.com/v1/search', {
    params: { name: city, count: 1, language: 'en', format: 'json' },
  });

  if (!geo?.results?.length) {
    throw createError({ statusCode: 404, statusMessage: 'city not found' });
  }

  const { name, country, latitude, longitude } = geo.results[0];

  // Step 2: Fetch current weather for those coordinates
  const forecastParams: Record<string, any> = {
    latitude,
    longitude,
    current: [
      'temperature_2m',
      'relative_humidity_2m',
      'apparent_temperature',
      'wind_speed_10m',
    ].join(','),
    timezone: 'auto',
  };

  if (units === 'imperial') {
    forecastParams.temperature_unit = 'fahrenheit';
    forecastParams.wind_speed_unit = 'mph';
  }

  const forecast = await $fetch<{
    current: {
      time: string;
      temperature_2m: number;
      relative_humidity_2m: number;
      apparent_temperature: number;
      wind_speed_10m: number;
    };
  }>('https://api.open-meteo.com/v1/forecast', { params: forecastParams });

  return {
    city: name,
    country,
    latitude,
    longitude,
    units: normalizedUnits,
    current: forecast.current,
  };
});
```

Nitro's filesystem routing maps the `[city]` filename to the path parameter, so there's no route registration to write separately. The Nitro starter supplies the handler, router, error, query, and `$fetch` helpers through its generated auto-imports. Do not call `decodeURIComponent` on the router parameter: Nitro has already decoded it.

### 3\. Run Nitro locally

Start the Nitro app by running:

```bash
pnpm dev
```

## How to test the weather API route locally

The command that starts the dev server depends on your runtime. The requests you make against it don't.

Start the dev server:

- **FastAPI:** Run `vercel dev`.
  
- **Express:** Run `vercel dev`.
  
- **Nitro:** Run `pnpm install`, then `pnpm dev`.
  

All three serve on port 3000, so the same two requests exercise any of them:

```bash
# Metric units, the default
curl http://localhost:3000/api/weather/london

# Imperial units
curl "http://localhost:3000/api/weather/san%20francisco?units=imperial"
```

A successful response returns the resolved location alongside the current conditions. On FastAPI, you can also call the route from the generated documentation page at `http://localhost:3000/docs`.

## How to deploy your weather API to Vercel

The deploy sequence is the same, whichever runtime you built on.

Ship it from Git or from the CLI:

1. Push your changes to your remote repository, or run `vercel` from the project directory.
   
2. Open the preview deployment Vercel creates and test the route against it.
   
3. Merge to your main branch to promote the build to production.
   

What Vercel builds differs slightly by runtime. A FastAPI or Express application becomes a single [Vercel Function](https://vercel.com/docs/functions), resolved from its entrypoint file. Nitro bundles its server application into a Vercel Function. It uses [Fluid compute](https://vercel.com/docs/fluid-compute) by default, so compute scale with traffic without further configuration.

## How to cache and monitor a weather API route

Open-Meteo's free tier allows 600 calls per minute, 5,000 per hour, and 10,000 per day. This route calls it twice per request, so it consumes that budget at double the rate the request count suggests. Caching is what keeps you under it.

[Runtime cache](https://vercel.com/docs/caching/runtime-cache) stores data in the region where your function runs and works with every runtime. Items are capped at 2 MB, which a weather response stays well under.

### Cache the response in Nitro

Nitro reads cached values from a mount point, so configure the driver first and then wrap the handler.

Mount the Vercel driver in `nitro.config.ts`:

```tsx
import { defineNitroConfig } from 'nitropack/config';

export default defineNitroConfig({
  srcDir: 'server',
  // 2025-07-15 or later also enables per-route observability hints.
  compatibilityDate: '2025-07-15',
  storage: {
    '/cache/nitro': {
      // defineCachedEventHandler reads from the cache mount point,
      // so the driver has to be mounted here to take effect.
      driver: 'vercel-runtime-cache',
    },
  },
});
```


<!-- docsgraph:related -->
## Related pages

> **For AI agents:** Follow these links to understand how this page connects to the rest of the Vercel ecosystem. For the full cross-link map (inbound, outbound, prerequisites, and semantic neighbors), see the .graph.md link below.

- [How to Build a Weather API with Nitro and Vercel](https://vercel.com/kb/guide/weather-api-with-nitro?from=related&source_path=%2Fkb%2Fguide%2Fweather-api-with-fastapi&source_site=vercel-kb&relationship=related) — Provide real-time weather data to apps and websites with a single Nitro route, Vercel cache storage, and Observability.
- [Vite + Nitro on Vercel](https://vercel.com/docs/frameworks/full-stack/vite-with-nitro?from=related&source_path=%2Fkb%2Fguide%2Fweather-api-with-fastapi&source_site=vercel-kb&relationship=related) — Add a backend to any Vite app with Nitro and deploy to Vercel with zero configuration.
- [How to Build a Weather API with Express and Vercel](https://vercel.com/kb/guide/weather-api-with-express?from=related&source_path=%2Fkb%2Fguide%2Fweather-api-with-fastapi&source_site=vercel-kb&relationship=related) — Provide real-time weather data to apps and websites with a single Express route.
- [How to ship a Nitro app on Vercel](https://vercel.com/kb/guide/ship-a-nitro-app-on-vercel?from=related&source_path=%2Fkb%2Fguide%2Fweather-api-with-fastapi&source_site=vercel-kb&relationship=related) — Deploy a Nitro app to Vercel with zero configuration. Learn how to ship from a template, the Vercel CLI, or Git, and con
- [Migrate a TanStack Start app from Netlify to Vercel](https://vercel.com/kb/guide/migrate-a-tanstack-start-app-from-netlify-to-vercel?from=related&source_path=%2Fkb%2Fguide%2Fweather-api-with-fastapi&source_site=vercel-kb&relationship=related) — Move your TanStack Start app off Netlify and onto Vercel Functions, where Fluid compute scales it automatically. Swap to
- [Migrate a TanStack Start app from Cloudflare to Vercel](https://vercel.com/kb/guide/migrate-a-tanstack-start-app-from-cloudflare-to-vercel?from=related&source_path=%2Fkb%2Fguide%2Fweather-api-with-fastapi&source_site=vercel-kb&relationship=related) — Move your TanStack Start app off Cloudflare Workers and onto Vercel Functions, where Fluid compute scales it automatical

Full cross-link map for this page: [/kb/guide/weather-api-with-fastapi.graph.md](/kb/guide/weather-api-with-fastapi.graph.md?from=related&source_path=%2Fkb%2Fguide%2Fweather-api-with-fastapi&source_site=vercel-kb&relationship=graph)
<!-- /docsgraph:related -->

In `server/routes/api/weather/[city].ts`, swap `defineEventHandler` for `defineCachedEventHandler` and pass a duration:

```typescript
export default defineCachedEventHandler(
  async (event) => {
    // Geocoding and forecast calls unchanged
  },
  {
    maxAge: 3600, // one hour
  },
);
```

Nitro serves a stale value while it refreshes in the background, so a cached route stays fast through the refresh.

### Cache the response in FastAPI or Express

Neither framework has a declarative cache layer, so call the runtime cache directly.

For FastAPI, install the `vercel` package:

```bash
uv add vercel
```

Then read from the cache before making the upstream calls:

```python
from vercel.functions import AsyncRuntimeCache

cache = AsyncRuntimeCache()

@app.get("/api/weather/{city}")
async def get_weather(city: str, units: str | None = Query(default="metric")):
    normalized_units = "imperial" if units == "imperial" else "metric"
    key = f"weather:{city.lower()}:{normalized_units}"

    cached = await cache.get(key)
    if cached is not None:
        return cached

    # Existing geocoding and forecast calls go here, producing `result`

    await cache.set(key, result, {"ttl": 3600, "tags": ["weather"]})
    return result
```

For Express, the equivalent uses `getCache` from `@vercel/functions`:

```bash
pnpm i @vercel/functions
```

Then update `src/index.ts`:

```typescript
import { getCache } from '@vercel/functions'

app.get('/api/weather/:city', async (req, res) => {
  const cache = getCache()
  const city = req.params.city
  const units = req.query.units === 'imperial' ? 'imperial' : 'metric'
  const key = `weather:${city.toLowerCase()}:${units}`

  const cached = await cache.get(key)

  if (cached !== null) {
    return res.json(cached)
  }
  
  const result = await fetchWeather(city, units)

  await cache.set(key, result, { ttl: 3600, tags: ['weather'] })
  res.json(result)
})
```

The units belong in the cache key. Leave them out and the first caller sets the units for everyone who asks about that city afterward.

### Monitor the weather API in Observability

Once the route is live, the [Observability](https://vercel.com/docs/observability) tab reports invocations, active CPU time, and error rate. Nitro applications need a compatibility date of `2025-07-15` or later, on Nitro 2.12 or newer. With that set, `/api/weather/[city]` gets its own row instead of folding into an application-wide total. Runtime cache has its own panel showing cache reads and writes, hit rate, and on-demand revalidations. Call your production route several times in a row and confirm the hit rate climbs after the first request. Runtime cache usage is charged, and its scope depends on your plan. On Pro and Enterprise each project gets its own cache. On Hobby, every project on your team shares one, so a busy project can evict another project's entries. ## How to troubleshoot common weather API errors The geocoding step and Open-Meteo's rate limits account for most failures in this route. Match the behavior you're seeing to one of these: - **A city that exists returns 404:** The geocoding endpoint matches on the name string alone, so misspellings and less common transliterations find nothing. Try the local spelling before assuming the city is missing from the data.    - **The wrong city comes back:** Place names aren't unique, and `count: 1` returns whichever match ranks highest. Raise `count`, then filter the results on the `country` field before picking one.    - **Requests start failing with 429:** You've passed one of Open-Meteo's free-tier limits. Cache the response to cut the call count. The free tier is also [non-commercial only](https://open-meteo.com/en/terms), so a revenue-generating service needs a paid plan regardless of volume.
  
- **Imperial units return Celsius:** The comparison is exact and case-sensitive, so `Imperial` and `IMPERIAL` both fall back to metric. Lowercase the parameter before comparing it, or log the received value to confirm what arrived.
  

If the route works locally but fails in production, check the function's [runtime logs](https://vercel.com/docs/logs/runtime) for the upstream response. The geocoding and forecast calls return different error messages, which narrows the failure to one of the two.

## Next steps

The same pattern works for any external API that needs a lookup before the fetch.

[Start a new Vercel project](https://vercel.com/new) to deploy your own version, or [browse the backend templates](https://vercel.com/templates?type=backend) for a starting point in another runtime.

## Related resources

- [Vercel Functions](https://vercel.com/docs/functions)
  
- [Fluid compute](https://vercel.com/docs/fluid-compute)
  
- [Runtime cache](https://vercel.com/docs/caching/runtime-cache)
  
- [Observability](https://vercel.com/docs/observability)
  
- [Deploy a FastAPI app on Vercel](https://vercel.com/docs/frameworks/backend/fastapi)
  
- [Build an MCP server with weather tools using Express](https://vercel.com/kb/guide/mcp-server-with-weather-tool-express)