Post Cover Image

Building a Simple Weather App with Nuxt 3 and TailwindCSS - Part 3: API Integration Estimated read time: 27 minute(s)

Introduction

In previous parts of this series, we built a simple weather app using Nuxt 3 and TailwindCSS, created a basic structure, and added initial hard-coded functionality to display current temperature and weather condition.

In this final part, we'll integrate external APIs to fetch location and real-time weather data. We'll use the Nominatim (OpenStreetMap) API for location search and the Open Meteo API for fetching weather data.

Note: Both APIs are free for non-commercial use only and have limitations. Please refer to their official documentation for more details. For our learning purposes, we can safely use them without worrying about commercial restrictions.

Types and Interfaces

Since we're using TypeScript in this project, it's essential to define the types of data that will be used. We'll create interfaces and types to cover our needs and demonstrate how it works.

First, let's update the tsconfig.json file to include our custom types.

Copied to Clipboard!
Copy
{
  // https://nuxt.com/docs/guide/concepts/typescript
  "extends": "./.nuxt/tsconfig.json",
  "compilerOptions": {
    "typeRoots": ["./types"]
  }
}

After that, create a new file types/interfaces.d.ts with the following content:

Copied to Clipboard!
Copy
// Define interface for weather request parameters.
interface WeatherRequestParams {
  latitude?: number;
  longitude?: number;
  timezone?: string;
  timeformat?: string;
  forecast_days?: number;
  daily?: string[];
  hourly?: string[];
  current?: string[];
}

// Define interface for weather object.
interface WeatherObject {
  isDay: boolean;
  temperature: string;
  weatherCode: number;
  weatherCodeString: string;
  dateTimeString?: string | undefined;
}

// Define interface for units.
interface Units {
  temperature_2m_max: string;
  temperature_2m: string;
}

// Define interface for forecast data.
interface ForecastData {
  time: number[];
  temperature_2m_max: number[];
  temperature_2m: number[];
  weather_code: number[];
  is_day: number[];
}

// Define interface for location object.
interface LocationObject {
  id: number;
  name: string;
  displayName: string;
  latitude: number;
  longitude: number;
}

// Define interface for location data.
interface LocationData {
  place_id: number;
  name: string;
  display_name: string;
  lat: number;
  lon: number;
}

And finally, create a new file types/types.d.ts with the following content:

Copied to Clipboard!
Copy
// Define possible forecast types.
type ForecastType = "daily" | "hourly" | "current";

API Integration

Now let's create our composables that we can use in our templates later. Those composables will store all the code required to search for a location and fetch weather data. You can read more about composables here.

Before we proceed with composables, let's create a weather code map based on Open Meteo documentation. Place a new file misc/weather-condition-map.ts with the following content:

Copied to Clipboard!
Copy
export default {
  0: "Clear",
  1: "Mainly clear",
  2: "Partly cloudy",
  3: "Overcast",
  45: "Fog",
  48: "Rime fog",
  51: "Light drizzle",
  53: "Drizzle",
  55: "Dense drizzle",
  56: "Light freezing drizzle",
  57: "Freezing drizzle",
  61: "Light rain",
  63: "Rain",
  65: "Heavy rain",
  66: "Light freezing rain",
  67: "Freezing rain",
  71: "Light snow",
  73: "Snow",
  75: "Heavy snow",
  77: "Snow grains",
  80: "Light showers",
  81: "Showers",
  82: "Heavy showers",
  85: "Light snow showers",
  86: "Snow showers",
  95: "Thunderstorm",
  96: "Thunderstorm w/ light hail",
  99: "Thunderstorm w/ heavy hail",
} as { [key: number]: string };

Now that we have our weather condition map, let's create our composables. We'll start by creating a location-related composable in composables/useLocation.ts.

Copied to Clipboard!
Copy
/**
 * Location Composable
 */
export default () => {
  const useCurrentLocation = (): Ref<LocationObject | null> =>
    useState("currentLocation", () => null);

  // Define request URL and constants for fetching location based on lat., lon. (Nominatim).
  const requestUrl: string = "https://nominatim.openstreetmap.org/search";
  const MAX_RESULTS = 3;
  const LANGUAGE = "en-US";

  /**
   * Search for locations.
   *
   * @param location string
   *
   * @returns Promise<LocationObject[]>
   */
  const searchLocation = async (
    location: string,
  ): Promise<LocationObject[]> => {
    const locations: LocationObject[] = [];

    try {
      const locationDataArray: LocationData[] = await $fetch(requestUrl, {
        method: "GET",
        params: {
          q: location,
          format: "json",
          limit: MAX_RESULTS,
          "accept-language": LANGUAGE,
        },
      });

      for (const locationData of locationDataArray) {
        locations.push({
          id: locationData.place_id,
          name: locationData.name,
          displayName: locationData.display_name,
          latitude: locationData.lat,
          longitude: locationData.lon,
        });
      }
    } catch (error) {
      console.error("Error fetching locations:", error);
    }

    return locations;
  };

  return {
    useCurrentLocation,
    searchLocation,
  };
};

Next, we'll create a weather-related composable in composables/useWeather.ts. This composable will handle the fetching of weather data based on the provided location and forecast type.

Copied to Clipboard!
Copy
import weatherConditionMap from "~/misc/weather-condition-map";

/**
 * Weather Composable
 */
export default () => {
  // Define request URL for fetching weather data (Open Meteo).
  const requestUrl: string = "https://api.open-meteo.com/v1/forecast";

  // Get and define current location from location composable.
  const { useCurrentLocation } = useLocation();
  const currentLocation: Ref<LocationObject | null> = useCurrentLocation();

  /**
   * Helper function to create request params based on forecast type.
   *
   * @param type ForecastType
   *
   * @returns WeatherRequestParams
   */
  const createRequestParams = (type: ForecastType): WeatherRequestParams => {
    const params: WeatherRequestParams = {
      latitude: currentLocation.value?.latitude,
      longitude: currentLocation.value?.longitude,
      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
      timeformat: "unixtime",
    };

    switch (type) {
      case "daily":
        params.forecast_days = 8;
        params.daily = ["temperature_2m_max", "weather_code"];
        break;
      case "hourly":
        params.forecast_days = 2;
        params.hourly = ["is_day", "temperature_2m", "weather_code"];
        break;
      default:
        params.current = ["is_day", "temperature_2m", "weather_code"];
        break;
    }

    return params;
  };

  /**
   * Get forecast based on type of the forecast:
   * - 'current' for current weather conditions (default);
   * - 'daily' for daily weather forecast;
   * - 'hourly' for hourly weather forecast.
   *
   * @param type ForecastType
   *
   * @returns Promise<WeatherObject | WeatherObject[] | undefined>
   */
  const getForecast = async (
    type: ForecastType = "current",
  ): Promise<WeatherObject | WeatherObject[] | undefined> => {
    if (!currentLocation.value) return;

    try {
      const weatherData: any = await $fetch(requestUrl, {
        method: "GET",
        params: createRequestParams(type),
      });

      if (type === "current") {
        const temperature = Math.round(weatherData.current.temperature_2m) +
          weatherData.current_units.temperature_2m;

        return {
          weatherCodeString: weatherConditionMap[weatherData.current.weather_code],
          weatherCode: weatherData.current.weather_code,
          temperature,
          isDay: weatherData.current.is_day === 1,
        };
      }

      return prepareForecastArray(
        weatherData[type],
        weatherData[`${type}_units`],
        type,
      );
    } catch (error) {
      console.error("Error fetching weather:", error);

      return;
    }
  };

  /**
   * Helper function to get formatted temperature string.
   *
   * @param forecastData ForecastData
   * @param units Units
   * @param type ForecastType
   * @param index number
   *
   * @returns string
   */
  const getTemperature = (
    forecastData: ForecastData,
    units: Units,
    type: ForecastType,
    index: number,
  ): string => {
    const temp = type === "daily"
        ? forecastData.temperature_2m_max![index]
        : forecastData.temperature_2m![index];

    const unit = type === "daily" ? units.temperature_2m_max : units.temperature_2m;

    return `${Math.round(temp)}${unit}`;
  };

  /**
   * Helper function to get formatted date-time string.
   *
   * @param timestamp number
   * @param type ForecastType
   *
   * @returns string
   */
  const getDateTimeString = (timestamp: number, type: ForecastType): string => {
    const date = new Date(timestamp * 1000);

    return type === "hourly"
      ? date.toLocaleTimeString(undefined, {
          hour: "numeric",
          minute: "numeric",
        })
      : date.toLocaleDateString();
  };

  /**
   * Prepare forecast array.
   *
   * @param forecastData ForecastData
   * @param units Units
   * @param type ForecastType
   *
   * @returns WeatherObject[] | undefined
   */
  const prepareForecastArray = (
    forecastData: ForecastData,
    units: Units,
    type: ForecastType,
  ): WeatherObject[] | undefined => {
    const maxFutureEntries = type === "hourly" ? 6 : 7;
    const currentTimestamp = Math.floor(Date.now() / 1000);
    const forecastArray: WeatherObject[] = [];
    let futureEntries = 0;

    for (let i = 0; i < forecastData.time.length && futureEntries < maxFutureEntries; i++) {
      if (forecastData.time[i] > currentTimestamp) {
        const temperature = getTemperature(forecastData, units, type, i);
        const dateTimeString = getDateTimeString(forecastData.time[i], type);

        forecastArray.push({
          weatherCodeString: weatherConditionMap[forecastData.weather_code[i]],
          weatherCode: forecastData.weather_code[i],
          temperature,
          dateTimeString,
          isDay: type === "daily" ? true : forecastData.is_day![i] === 1,
        });

        futureEntries++;
      }
    }

    return forecastArray.length ? forecastArray : undefined;
  };

  /**
   * Helper function to icon for conditions based on weather code.
   *
   * @param isDay boolean
   * @param weatherCode number
   *
   * @returns string
   */
  const getConditionsIcon = (isDay: boolean, weatherCode: number): string => {
    // Clear sky
    if (weatherCode === 0 && isDay) return "sun";
    if (weatherCode === 0 && !isDay) return "moon";

    // Mostly clear, partly cloudy
    if ([1, 2].includes(weatherCode) && isDay) return "cloudSun";
    if ([1, 2].includes(weatherCode) && !isDay) return "cloudMoon";

    // Overcast
    if (weatherCode === 3) return "clouds";

    // Fog
    if ([45, 48].includes(weatherCode) && isDay) return "sunFog";
    if ([45, 48].includes(weatherCode) && !isDay) return "moonFog";

    // Rain & Drizzle
    if ([51, 53, 55, 61, 63, 65, 80, 81, 82].includes(weatherCode)) return "cloudRain";

    // Snow
    if ([71, 73, 75, 77].includes(weatherCode)) return "cloudSnow";

    // Thunderstorm & Rain
    if ([95, 96].includes(weatherCode)) return "cloudBolt";
    if (weatherCode === 99) return "cloudStorm";

    return "";
  };

  return {
    getForecast,
    getConditionsIcon,
  };
};

Using Composables

We have now our composables ready to be used anywhere in the project, and it's time to update our components to display real data instead of hard-coded one.

We'll start with our layout file layouts/default.vue:

Copied to Clipboard!
Copy
<script setup lang="ts">
  const { useCurrentLocation } = useLocation();
  const currentLocation: Ref<LocationObject | null> = useCurrentLocation();
</script>

<template>
  <div
    class="flex min-h-screen flex-col items-center justify-center bg-gradient-to-br from-background-from to-background-to p-4 text-white text-opacity-80"
  >
    <div class="container mx-auto flex flex-col gap-8">
      <transition name="fade" appear>
        <AppHeader />
      </transition>

      <transition name="fade" appear>
        <div v-show="currentLocation?.id" class="flex flex-1 flex-col gap-8">
          <slot />
        </div>
      </transition>

      <transition name="fade" appear>
        <UiGlassContainer
          v-show="!currentLocation?.id"
          class="flex w-full max-w-md flex-col items-center justify-center"
        >
          <LocationSearch class="mx-auto w-full max-w-md" />
        </UiGlassContainer>
      </transition>
    </div>
  </div>
</template>

What was done here is that we've added functionality to display a search field if there is no current location selected, added transitions for smoothness, and slightly adjusted styles.

With the layout updated, let's focus on the location search component. Update components/location/Search.vue with the following contents:

Copied to Clipboard!
Copy
<script setup lang="ts">
  const { useCurrentLocation, searchLocation } = useLocation();
  const currentLocation: Ref<LocationObject | null> = useCurrentLocation();
  const results: Ref<LocationObject[]> = ref([]);
  const showResults: Ref<boolean> = ref(false);
  const keyUpTimeout: Ref<NodeJS.Timeout | null> = ref(null);
  const searchInput: Ref<HTMLInputElement | null> = ref(null);

  /**
   * Handle input change.
   */
  const handleInputChange = async (event: Event): Promise<void> => {
    const input: HTMLInputElement = event.target as HTMLInputElement;

    // Clear the timeout if it exists to avoid multiple simultaneous API calls.
    if (keyUpTimeout.value) clearTimeout(keyUpTimeout.value);

    // Hide results if input has no value.
    if (!input.value) {
      keyUpTimeout.value = setTimeout(() => {
        showResults.value = false;
      }, 500);

      return;
    }

    // Fetch and show results.
    keyUpTimeout.value = setTimeout(async () => {
      results.value = await searchLocation(input.value);
      showResults.value = true;
    }, 500);
  };

  /**
   * Handle location selection.
   */
  const handleSelectLocation = (location: LocationObject): void => {
    currentLocation.value = location;
    results.value = [];
    showResults.value = false;

    if (searchInput.value) searchInput.value.value = "";
  };
</script>

<template>
  <div
    class="flex flex-col gap-2 overflow-hidden tracking-wide transition-all duration-300"
    :class="showResults ? 'h-72' : 'h-24'"
  >
    <!-- Location Search Input -->
    <label for="search" class="text-lg font-bold">Search Location</label>
    <div class="relative w-full">
      <input
        class="w-full bg-white bg-opacity-5 p-4 shadow transition-colors duration-300 placeholder:text-white placeholder:text-opacity-75 focus:bg-opacity-5 focus:outline-none focus:ring-0"
        id="search"
        name="search"
        placeholder="Enter location name or address"
        type="text"
        autocomplete="off"
        ref="searchInput"
        @keyup.prevent="handleInputChange"
      />
      <Icon
        class="absolute bottom-0 right-4 top-0 my-auto w-8 opacity-75"
        icon-name="magnifier"
      />
    </div>

    <!-- Results Container -->
    <transition name="fade" appear>
      <div v-show="showResults" class="h-full" :key="results.length">
        <transition name="fade" appear>
          <div v-if="results.length > 0" class="flex flex-col gap-2">
            <template v-for="location in results" :key="location.id">
              <span
                class="truncate bg-white bg-opacity-10 p-4 transition-colors duration-300 hover:cursor-pointer hover:bg-opacity-15"
                @click="handleSelectLocation(location)"
              >
                {{ location.displayName }}
              </span>
            </template>
          </div>
        </transition>

        <transition name="fade" appear>
          <div
            v-if="!results.length"
            class="flex h-full items-center justify-center"
          >
            <p class="text-lg font-bold">No results found.</p>
          </div>
        </transition>
      </div>
    </transition>
  </div>
</template>

This code update will allow us to search for a real location using the API. We've also added some logic to handle empty results from the API and display text telling that no locations were found.

Next, we'll update our components/app/Menu.vue to display the current location only if we actually have one selected.

Copied to Clipboard!
Copy
<script setup lang="ts">
  const { useCurrentLocation } = useLocation();
  const currentLocation: Ref<LocationObject | null> = useCurrentLocation();
  const showMenu: Ref<boolean> = ref(false);
</script>

<template>
  <div class="select-none">
    <!-- Menu Toggle -->
    <Icon
      class="transition-colors duration-300 hover:cursor-pointer hover:text-white"
      :class="showMenu ? 'text-white' : ''"
      icon-name="menu"
      @click="showMenu = !showMenu"
    />

    <!-- Menu Content -->
    <transition name="fade" appear>
      <UiGlassContainer
        v-if="showMenu"
        class="absolute right-0 top-full mt-4 flex w-full max-w-md flex-col gap-8 backdrop-blur-2xl"
      >
        <!-- Current Location -->
        <div
          v-show="currentLocation?.id"
          class="flex flex-col gap-2 tracking-wide"
        >
          <span class="text-lg font-bold">Current Location</span>
          <div class="flex items-center gap-2">
            <Icon class="w-6" icon-name="mapPoint" />
            <span>{{ currentLocation?.displayName }}</span>
          </div>
          <div class="ml-8 text-xs font-medium opacity-75">
            <span>Latitude: {{ currentLocation?.latitude }}</span>
            &nbsp;
            <span>Longitude: {{ currentLocation?.longitude }}</span>
          </div>
        </div>

        <!-- Location Search Box -->
        <LocationSearch />
      </UiGlassContainer>
    </transition>
  </div>
</template>

Fire up npx nuxi dev if you haven't done it yet and try searching for a location and selecting it. You should see the current location being updated in the menu.

So we can now select a location, which means we can fetch weather data based on our selected location. Let's start with current weather data and update our components/weather/Now.vue file.

Copied to Clipboard!
Copy
<script setup lang="ts">
  const { useCurrentLocation } = useLocation();
  const { getForecast, getConditionsIcon } = useWeather();
  const currentLocation: Ref<any | null> = useCurrentLocation();
  const currentWeather: Ref<WeatherObject | undefined> = ref();

  // Watch for current location and update weather if it changes.
  watch(currentLocation, async () => {
    if (currentLocation.value) {
      currentWeather.value = (await getForecast()) as WeatherObject;
    }
  });

  /**
   * Handle icon name selection based on conditions.
   */
  const handleConditionsIcon = () => {
    if (currentWeather.value) {
      return getConditionsIcon(
        currentWeather.value.isDay,
        currentWeather.value.weatherCode,
      );
    }

    return "";
  };
</script>

<template>
  <transition name="fade" appear>
    <div class="flex flex-col gap-4">
      <h2 class="text-2xl font-bold">Now</h2>

      <UiGlassContainer
        class="flex flex-1 items-center justify-evenly gap-4 !py-12"
      >
        <div
          v-if="currentWeather && currentLocation"
          class="flex flex-col gap-8"
        >
          <span class="text-3xl font-medium">{{ currentLocation.name }}</span>

          <div class="flex items-end gap-2">
            <span class="text-5xl font-medium">
              {{ currentWeather.temperature }}
            </span>
            <span class="text-xl font-medium">
              {{ currentWeather.weatherCodeString }}
            </span>
          </div>
        </div>

        <Icon class="h-auto w-32" :icon-name="handleConditionsIcon()" />
      </UiGlassContainer>
    </div>
  </transition>
</template>

What we did is use our previously created composables to get the current location and current weather based on that location and display it in our current weather container. Weather data will always update automatically when the location changes because we are "watching" the current location state. You can now change the location, and you should see the current weather being updated.

Before we finish up with hourly and daily forecasts, we need to update our weather card component components/weather/Card.vue.

Copied to Clipboard!
Copy
<script setup lang="ts">
  defineProps({
    dateTimeString: {
      type: String as PropType<string | undefined>,
      required: true,
    },
    icon: {
      type: String,
      required: true,
    },
    weather: {
      type: String,
      required: true,
    },
    temperature: {
      type: String as PropType<string | undefined>,
      required: true,
    },
  });
</script>

<template>
  <div
    class="flex flex-col items-center justify-center gap-4 p-4 font-semibold"
  >
    <span class="text-lg">{{ dateTimeString }}</span>
    <Icon class="h-16" :icon-name="icon" />
    <div class="flex flex-col text-center">
      <span class="font-normal">{{ weather }}</span>
      <span class="text-xl">{{ temperature }}</span>
    </div>
  </div>
</template>

We just adjusted prop types and removed the hardcoded temperature unit as it is applied in our weather composable.

Finally, update hourly and daily components, and our simple weather app will be ready! Let's begin with components/weather/Hourly.vue.

Copied to Clipboard!
Copy
<script setup lang="ts">
  const { useCurrentLocation } = useLocation();
  const { getForecast, getConditionsIcon } = useWeather();
  const currentLocation: Ref<any | null> = useCurrentLocation();
  const hourlyForecast: Ref<WeatherObject[] | undefined> = ref();

  // Watch for current location and update weather if it changes.
  watch(currentLocation, async () => {
    if (currentLocation.value) {
      hourlyForecast.value = (await getForecast("hourly")) as WeatherObject[];
    }
  });

  /**
   * Handle icon name selection based on conditions.
   */
  const handleConditionsIcon = (weatherData: WeatherObject) => {
    if (weatherData) {
      return getConditionsIcon(weatherData.isDay, weatherData.weatherCode);
    }

    return "";
  };
</script>

<template>
  <transition name="fade" appear>
    <div class="flex h-full flex-col gap-4">
      <h2 class="text-2xl font-bold">Hourly</h2>
      <div class="flex h-full flex-col overflow-x-auto shadow-xl">
        <UiGlassContainer class="grid min-w-max flex-1 grid-cols-6">
          <template v-for="(weatherData, index) in hourlyForecast" :key="index">
            <transition name="fade" appear>
              <WeatherCard
                class="border-white border-opacity-5 last:!border-r-0 even:border-x"
                :class="`delay-${index + 1 * 300}`"
                :date-time-string="weatherData.dateTimeString"
                :icon="handleConditionsIcon(weatherData)"
                :weather="weatherData.weatherCodeString"
                :temperature="weatherData.temperature"
              />
            </transition>
          </template>
        </UiGlassContainer>
      </div>
    </div>
  </transition>
</template>

Here we have removed our hardcoded data and created a v-for loop to iterate over the forecast items. We also added a watch on the current location state to update the hourly forecast when the location changes.

Now let's move on to components/weather/Daily.vue.

Copied to Clipboard!
Copy
<script setup lang="ts">
  const { useCurrentLocation } = useLocation();
  const { getForecast, getConditionsIcon } = useWeather();
  const currentLocation: Ref<any | null> = useCurrentLocation();
  const dailyForecast: Ref<WeatherObject[] | undefined> = ref();

  // Watch for current location and update weather if it changes.
  watch(currentLocation, async () => {
    if (currentLocation.value) {
      dailyForecast.value = (await getForecast("daily")) as WeatherObject[];
    }
  });

  /**
   * Handle icon name selection based on conditions.
   */
  const handleConditionsIcon = (weatherData: WeatherObject) => {
    if (weatherData) {
      return getConditionsIcon(weatherData.isDay, weatherData.weatherCode);
    }

    return "";
  };
</script>

<template>
  <transition name="fade" appear>
    <div class="flex h-full flex-col gap-4">
      <h2 class="text-2xl font-bold">Daily</h2>
      <div class="flex h-full flex-col overflow-x-auto shadow-xl">
        <UiGlassContainer class="grid min-w-max flex-1 grid-cols-7">
          <template v-for="(weatherData, index) in dailyForecast" :key="index">
            <transition name="fade" appear>
              <WeatherCard
                class="border-white border-opacity-5 last:!border-r-0 even:border-x"
                :class="`delay-${index + 1 * 300}`"
                :date-time-string="weatherData.dateTimeString"
                :icon="handleConditionsIcon(weatherData)"
                :weather="weatherData.weatherCodeString"
                :temperature="weatherData.temperature"
              />
            </transition>
          </template>
        </UiGlassContainer>
      </div>
    </div>
  </transition>
</template>

As you'll notice, this file is almost the same as our Hourly.vue component, which I have left like that with purpose. Your task is to refactor those 2 components into a single reusable component.

We Are Done!

Congratulations, you have reached the end of this series! I hope that you enjoyed the tutorial and learned something new. To continue sharpening your skills, feel free to continue improving the same app with new or missing features, code refactoring, and more - practice makes perfect!

In case something is unclear, not working, or you just want to ask me a question, feel free to reach out via email at [email protected] - I will be happy to help.

Happy coding!

Links

Other parts of the series:

Building a Simple Weather App with Nuxt 3 and TailwindCSS - Part 1: Setup & Configuration

Building a Simple Weather App with Nuxt 3 and TailwindCSS - Part 2: App UI

GitHub repository:

Source code

Documentations

Open Meteo API

Nominatim Web Service API

Nuxt

Nuxt Google Fonts

Nuxt Tailwind

TailwindCSS

Social Share

Ready, Set, Create Your Project!

Turning your cool ideas into reality, the friendly way.